Build a Fintech Wallet & Transfers API

Build a Go REST API with double-entry bookkeeping and idempotency.

Introduction

30 Second Summary

Every time you tap "Send" on a payment app, the backend needs to guarantee that money leaves one account and arrives in another without being duplicated or lost. This is harder than it sounds.

In this project, you will build a wallet and transfers API in Go that handles money safely using database transactions, double-entry bookkeeping, and idempotency keys.

What You'll Build

A fintech-style REST API where users can create wallets, deposit funds, and transfer money between wallets with full transactional safety.

By the end of this project, you'll have:

  • A running Go REST API with endpoints for wallet creation, deposits, and transfers between wallets.
  • A PostgreSQL database managed by versioned migrations, running locally via Docker Compose.
  • Double-entry bookkeeping that records every transfer as a matching debit and credit entry, plus idempotency keys that prevent duplicate transfers on retry.
  • Secret Mission: Add pessimistic locking to prevent overdrafts under concurrent requests.

Are there any prerequisites?

You should be comfortable with basic Go syntax (structs, functions, interfaces) and understand HTTP methods like GET and POST. Familiarity with basic SQL (CREATE TABLE, INSERT, SELECT) is also helpful.

Before We Start

Before We Start...

Set Up Your Development Environment

Before you can build the wallet API, you need two foundational tools: Go (the programming language) and a local PostgreSQL database.

In this step, you will install Go and Docker Desktop, create your project, install dependencies, and spin up a PostgreSQL database container using Docker Compose.

In this step, get ready to:
  • Install and verify Go 1.25+ and Docker Desktop.
  • Create the project directory and install Go dependencies.
  • Start a PostgreSQL 16 database with Docker Compose.
Install Go and Docker Desktop

This project requires Go 1.25 or higher because the libraries you will use (Gin, GORM, Goose) all require it.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal and pressing Enter.
  • Check your Go version by running this command:
go version

✔️ I see version 1.25 or higher

Go is ready to use. Move on to verifying Docker below.

ⓧ I see an older version

You have Go installed, but it is too old for this project. You need version 1.25 or higher.

  • Download the latest Go installer for your operating system from go.dev/dl.
  • Run the installer. It will replace your existing Go version automatically.
  • Close and reopen your terminal, then run the version check again to confirm it shows 1.25 or higher.

ⓧ Command not found

Go is not installed yet. Let's install it.

  • Download the Go installer for your operating system from go.dev/dl. Choose the .pkg file for macOS or the .msi file for Windows.
  • Run the installer and accept the default settings.
  • Close and reopen your terminal, then run go version to confirm it shows 1.25 or higher.

Now check that Docker is installed. Docker Desktop runs PostgreSQL in a container so you do not need to install the database directly on your machine.

  • Check your Docker version by running this command:
docker --version

✔️ I see a version number

Docker is installed. Make sure Docker Desktop is running before continuing. You will need it for the PostgreSQL container later in this step.

ⓧ Command not found

Docker is not installed yet. Let's install Docker Desktop.

  • Download Docker Desktop from docker.com/get-started.
  • Run the installer and follow the prompts to complete the installation.
  • Open Docker Desktop after installation. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker, and press Enter.
  • Once Docker Desktop is running, open a new terminal window and run docker --version to confirm it is installed.
Create the project and install dependencies

With Go and Docker ready, you can create your project folder and pull in the libraries this project uses.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir wallets-api
cd wallets-api
  • Initialize a new Go module by running this command:
go mod init wallets-api

What does this command do?

This creates a go.mod file in your project. The go.mod file tracks your module name and all of its dependencies. Every Go project needs one.

  • Confirm the file was created by running:
ls

You should see go.mod listed in the output.

Now install the three library dependencies your project needs.

  • Install the project library dependencies by running these commands:
go get github.com/gin-gonic/gin@v1.12.0
go get gorm.io/driver/postgres@v1.6.0
go get gorm.io/gorm@v1.31.1

What are these libraries?

Gin is a fast HTTP framework for building REST APIs. It handles routing, request parsing, and JSON responses.

GORM is an Object-Relational Mapper. It lets you interact with your PostgreSQL database using Go structs instead of writing raw SQL for every operation.

The postgres driver connects GORM to PostgreSQL using pgx under the hood.

  • Install the Goose migration CLI tool by running this command:
go install github.com/pressly/goose/v3/cmd/goose@latest

Why is Goose installed differently?

The go get command adds libraries to your project. The go install command installs a standalone CLI binary on your machine. Goose is a command-line tool you will run directly in the terminal, not a library you import into your code.

  • Verify Goose installed correctly by running:
goose --version

You should see a version number printed in the terminal.

Seeing 'command not found' for goose?

Your Go bin directory is not in your system PATH. Add it by running this in your terminal:

export PATH=$PATH:$(go env GOPATH)/bin

Then try goose --version again. If it works, add that export line to your shell profile (~/.zshrc on macOS or ~/.bashrc on Linux) so it persists across terminal sessions.

help me fix goose command not found

Start PostgreSQL with Docker Compose

Your wallet API needs a database to store wallets, transfers, and ledger entries. Docker Compose lets you define that database as a simple configuration file and start it with one command.

  • Create a file called docker-compose.yml in your wallets-api folder. You can create it using your text editor or by running:
touch docker-compose.yml
  • Open docker-compose.yml in your editor and paste the following configuration:
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: fintech
      POSTGRES_PASSWORD: fintech123
      POSTGRES_DB: wallets_dev
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

What does this configuration do?

This file defines a single service called postgres that runs the official PostgreSQL 16 image.

  • environment sets the database username, password, and database name that PostgreSQL creates on first start.
  • ports maps port 5432 inside the container to port 5432 on your machine, so your Go app can connect to it.
  • volumes persists the database data so it survives container restarts.
  • Save the file.
  • Make sure Docker Desktop is running. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker, and press Enter to open it if it is not already running.
  • Start the PostgreSQL container by running this command:
docker compose up -d

What does this command do?

The -d flag runs the container in detached mode, meaning it runs in the background. Your terminal stays free for other commands.

  • Verify the container is running by running:
docker compose ps

You should see a postgres service listed with a status of running.

Container not starting?

Make sure Docker Desktop is fully started. The Docker whale icon in your menu bar or system tray should be steady, not animating.

If port 5432 is already in use (you might see a bind error), you may have another PostgreSQL instance running. Stop it, or change the host port in your docker-compose.yml to something like 5433:5432.

help me fix my Docker Compose PostgreSQL container

✔️ Awesome, I've got everything!

Double check you have saved your file and that your container is running.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml file:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: fintech
      POSTGRES_PASSWORD: fintech123
      POSTGRES_DB: wallets_dev
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Your go.mod file should contain these direct dependencies (you will also see additional // indirect entries, which is normal):

module wallets-api

go 1.25

require (
	github.com/gin-gonic/gin v1.12.0
	gorm.io/driver/postgres v1.6.0
	gorm.io/gorm v1.31.1
)

Your environment is ready. You have Go, Docker, your dependencies, and a running PostgreSQL database. Next up, you will create database migrations to define the tables your wallet service needs.

Create Database Migrations with Goose

Your PostgreSQL database is running, but it has no tables yet. Before you can store wallets, transfers, or ledger entries, you need to define the database schema.

Instead of writing raw SQL against the database manually, you will use Goose to manage versioned migration files. Each migration is a numbered SQL file that Goose applies in order, giving you a repeatable and auditable way to evolve your schema over time.

In this step, get ready to:
  • Install the Goose CLI and create a migrations directory.
  • Write three SQL migrations for wallets, transfers, and ledger entries.
  • Apply all migrations to your PostgreSQL database.
Install Goose and create the migrations directory

Goose is a standalone CLI tool that reads SQL files from a directory and applies them to your database in sequence. You need to install it separately from your Go module dependencies.

  • Install the Goose CLI by running this command:
go install github.com/pressly/goose/v3/cmd/goose@latest

What does this command do?

This downloads and compiles the goose binary, placing it in your Go bin directory. After this, you can run goose from any terminal session.

  • Verify Goose installed correctly by running:
goose -version

✔️ I see a version number

Goose is ready to use.

ⓧ Command not found

Your Go bin directory is not in your PATH. Add it by running:

export PATH=$PATH:$(go env GOPATH)/bin

Then try goose -version again.

  • Create the migrations directory by running:
mkdir migrations
  • Confirm the directory exists by running:
ls

You should see migrations listed alongside docker-compose.yml and go.mod.

Write the migration files

You will create three migration files that define your database schema. Each file has a -- +goose Up section (applied when migrating forward) and a -- +goose Down section (applied when rolling back).

Why Goose instead of GORM AutoMigrate?

GORM has a built-in AutoMigrate feature, but it cannot handle column removals, type changes, or data migrations. Goose gives you versioned, reversible migrations that are auditable and predictable.

In fintech, schema changes must be traceable. Goose migrations serve as a permanent record of every change made to your database.

  • Create the file migrations/001_create_wallets.sql and paste in the following SQL:
-- +goose Up
CREATE TABLE wallets (
    id SERIAL PRIMARY KEY,
    user_name VARCHAR(255) NOT NULL,
    balance BIGINT NOT NULL DEFAULT 0,
    currency VARCHAR(3) NOT NULL DEFAULT 'USD',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- +goose Down
DROP TABLE wallets;

What does this migration do?

  • balance is stored as BIGINT (cents, not dollars). Storing money as integers avoids floating-point precision errors. $10.50 becomes 1050.
  • currency defaults to USD and is limited to 3 characters (ISO currency codes).
  • The -- +goose Down section drops the table entirely, allowing a clean rollback.
  • Save migrations/001_create_wallets.sql.
  • Create the file migrations/002_create_transfers.sql and paste in the following SQL:
-- +goose Up
CREATE TABLE transfers (
    id SERIAL PRIMARY KEY,
    idempotency_key VARCHAR(255) NOT NULL,
    from_wallet_id INTEGER NOT NULL REFERENCES wallets(id),
    to_wallet_id INTEGER NOT NULL REFERENCES wallets(id),
    amount BIGINT NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'completed',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    CONSTRAINT transfers_idempotency_key_unique UNIQUE (idempotency_key)
);

-- +goose Down
DROP TABLE transfers;

What does this migration do?

  • idempotency_key has a unique constraint. This is the database-level guarantee that the same transfer cannot be processed twice, even if the client retries the request.
  • from_wallet_id and to_wallet_id are foreign keys referencing wallets(id). The database enforces that transfers can only happen between wallets that exist.
  • Save migrations/002_create_transfers.sql.
  • Create the file migrations/003_create_ledger_entries.sql and paste in the following SQL:
-- +goose Up
CREATE TABLE ledger_entries (
    id SERIAL PRIMARY KEY,
    transfer_id INTEGER NOT NULL REFERENCES transfers(id),
    wallet_id INTEGER NOT NULL REFERENCES wallets(id),
    entry_type VARCHAR(10) NOT NULL CHECK (entry_type IN ('debit', 'credit')),
    amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_ledger_entries_wallet_id ON ledger_entries(wallet_id);
CREATE INDEX idx_ledger_entries_transfer_id ON ledger_entries(transfer_id);

-- +goose Down
DROP TABLE ledger_entries;

What is double-entry bookkeeping?

Every transfer creates two ledger entries: a debit on the sender and a credit on the receiver. The sum of all entries for a wallet should always equal its balance.

This is how real financial systems work. If something goes wrong, you can reconcile by comparing balances against their ledger entry history.

  • Save migrations/003_create_ledger_entries.sql.
  • Confirm all three files exist by running:
ls migrations

You should see 001_create_wallets.sql, 002_create_transfers.sql, and 003_create_ledger_entries.sql listed.

Don't see all three files?

Make sure you created the files inside the migrations directory, not in the project root. The filenames must match exactly, including the leading numbers and underscores.

help me find my migration files

✔️ Awesome, I've got everything!

Great. Double check you have saved all three migration files.

ⓧ I'd like to double check the full code

-- +goose Up
CREATE TABLE wallets (
    id SERIAL PRIMARY KEY,
    user_name VARCHAR(255) NOT NULL,
    balance BIGINT NOT NULL DEFAULT 0,
    currency VARCHAR(3) NOT NULL DEFAULT 'USD',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- +goose Down
DROP TABLE wallets;
-- +goose Up
CREATE TABLE transfers (
    id SERIAL PRIMARY KEY,
    idempotency_key VARCHAR(255) NOT NULL,
    from_wallet_id INTEGER NOT NULL REFERENCES wallets(id),
    to_wallet_id INTEGER NOT NULL REFERENCES wallets(id),
    amount BIGINT NOT NULL,
    status VARCHAR(50) NOT NULL DEFAULT 'completed',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    CONSTRAINT transfers_idempotency_key_unique UNIQUE (idempotency_key)
);

-- +goose Down
DROP TABLE transfers;
-- +goose Up
CREATE TABLE ledger_entries (
    id SERIAL PRIMARY KEY,
    transfer_id INTEGER NOT NULL REFERENCES transfers(id),
    wallet_id INTEGER NOT NULL REFERENCES wallets(id),
    entry_type VARCHAR(10) NOT NULL CHECK (entry_type IN ('debit', 'credit')),
    amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_ledger_entries_wallet_id ON ledger_entries(wallet_id);
CREATE INDEX idx_ledger_entries_transfer_id ON ledger_entries(transfer_id);

-- +goose Down
DROP TABLE ledger_entries;
Run the migrations

The migration files define your schema, but they have not been applied to the database yet. Running goose up reads the files in order and executes each one against your PostgreSQL database.

  • Apply all migrations by running this command:
goose postgres "host=localhost user=fintech password=fintech123 dbname=wallets_dev port=5432 sslmode=disable" -dir ./migrations up

What does this command do?

  • goose postgres tells Goose to use the PostgreSQL driver.
  • The connection string provides the credentials matching your docker-compose.yml configuration.
  • -dir ./migrations points to the directory containing your SQL files.
  • up applies all pending migrations in order.

You should see three OK lines printed, one for each migration file. This confirms your wallets, transfers, and ledger_entries tables now exist in the database.

Seeing a connection error?

  • Make sure your PostgreSQL container is still running. Check with docker ps and look for the postgres:16 container.
  • If the container stopped, restart it with docker compose up -d from your project directory.
  • Double check the credentials in the connection string match your docker-compose.yml exactly: user fintech, password fintech123, database wallets_dev.

help me fix my goose migration connection error

Your database schema is ready. Next up, you will define GORM model structs in Go that map to these tables and build a database connection function.

Define GORM Models and Database Connection

Your database tables are live in PostgreSQL, but your Go application has no way to talk to them yet.

In this step, you will define GORM model structs that map directly to your database tables and write a connection function that manages a pool of database connections.

In this step, get ready to:
  • Define GORM model structs for Wallet, Transfer, and LedgerEntry.
  • Write a database connection function with connection pooling.
  • Verify the connection works by running the app.
Create the internal package structure

Go projects use an internal/ directory to organize packages that are private to the module.

  • Create the model and database package directories by running this command:
mkdir -p internal/models internal/database
  • Confirm the directories exist by running:
ls internal/

You should see database and models listed in the output.

Define the GORM model structs

GORM models are Go structs with special field tags that tell GORM how each field maps to a database column.

  • Create a new file called internal/models/models.go.
  • Add the package declaration, imports, and the Wallet struct by pasting this code:
package models

import (
	"time"
)

type Wallet struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	UserName  string    `json:"user_name" gorm:"not null"`
	Balance   int64     `json:"balance" gorm:"not null;default:0"`
	Currency  string    `json:"currency" gorm:"not null;default:USD"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

What does this code do?

The Wallet struct maps to the wallets table you created with Goose.

  • Balance is int64 (not float) because money is stored in cents to avoid floating-point precision errors.
  • The gorm: struct tags tell GORM about constraints like not null and default values.
  • The json: tags control how the struct serializes when you send it as a JSON API response.

You won't see a change yet. The next struct builds on this one.

  • Add the Transfer struct below the Wallet struct:
type Transfer struct {
	ID             uint      `json:"id" gorm:"primaryKey"`
	IdempotencyKey string    `json:"idempotency_key" gorm:"uniqueIndex;not null"`
	FromWalletID   uint      `json:"from_wallet_id" gorm:"not null"`
	ToWalletID     uint      `json:"to_wallet_id" gorm:"not null"`
	Amount         int64     `json:"amount" gorm:"not null"`
	Status         string    `json:"status" gorm:"not null;default:completed"`
	CreatedAt      time.Time `json:"created_at"`
	FromWallet     Wallet    `json:"from_wallet" gorm:"foreignKey:FromWalletID"`
	ToWallet       Wallet    `json:"to_wallet" gorm:"foreignKey:ToWalletID"`
}

What does this struct do?

The Transfer struct represents a money movement between two wallets.

  • IdempotencyKey has a uniqueIndex tag so the database rejects duplicate transfer attempts.
  • FromWallet and ToWallet are GORM associations. The foreignKey tag tells GORM which ID field links to the related Wallet.

You won't see a change yet. One more struct to go.

  • Add the LedgerEntry struct below the Transfer struct:
type LedgerEntry struct {
	ID           uint      `json:"id" gorm:"primaryKey"`
	TransferID   uint      `json:"transfer_id" gorm:"not null"`
	WalletID     uint      `json:"wallet_id" gorm:"not null"`
	EntryType    string    `json:"entry_type" gorm:"not null"`
	Amount       int64     `json:"amount" gorm:"not null"`
	BalanceAfter int64     `json:"balance_after" gorm:"not null"`
	CreatedAt    time.Time `json:"created_at"`
	Transfer     Transfer  `json:"transfer" gorm:"foreignKey:TransferID"`
	Wallet       Wallet    `json:"wallet" gorm:"foreignKey:WalletID"`
}

What is a ledger entry?

Every transfer creates two ledger entries: a debit on the sender and a credit on the receiver. This is double-entry bookkeeping.

BalanceAfter records the wallet's balance at that moment in time. This creates an audit trail so you can reconstruct any wallet's balance history from its ledger entries alone.

  • Save internal/models/models.go.
  • Verify the models package compiles by running:
go build ./internal/models

If there is no output, the build succeeded. Go only prints output when something goes wrong.

Seeing a compile error?

  • Check that the backtick struct tags are complete. Each tag must have matching opening and closing backticks on the same line.
  • Verify the import "time" statement is present at the top of the file.
  • Make sure the package declaration says package models (not package main).

Help me debug my GORM model compile error

✔️ Awesome, I've got everything!

Great. Double check you have saved internal/models/models.go.

ⓧ I'd like to double check the full code

package models

import (
	"time"
)

type Wallet struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	UserName  string    `json:"user_name" gorm:"not null"`
	Balance   int64     `json:"balance" gorm:"not null;default:0"`
	Currency  string    `json:"currency" gorm:"not null;default:USD"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type Transfer struct {
	ID             uint      `json:"id" gorm:"primaryKey"`
	IdempotencyKey string    `json:"idempotency_key" gorm:"uniqueIndex;not null"`
	FromWalletID   uint      `json:"from_wallet_id" gorm:"not null"`
	ToWalletID     uint      `json:"to_wallet_id" gorm:"not null"`
	Amount         int64     `json:"amount" gorm:"not null"`
	Status         string    `json:"status" gorm:"not null;default:completed"`
	CreatedAt      time.Time `json:"created_at"`
	FromWallet     Wallet    `json:"from_wallet" gorm:"foreignKey:FromWalletID"`
	ToWallet       Wallet    `json:"to_wallet" gorm:"foreignKey:ToWalletID"`
}

type LedgerEntry struct {
	ID           uint      `json:"id" gorm:"primaryKey"`
	TransferID   uint      `json:"transfer_id" gorm:"not null"`
	WalletID     uint      `json:"wallet_id" gorm:"not null"`
	EntryType    string    `json:"entry_type" gorm:"not null"`
	Amount       int64     `json:"amount" gorm:"not null"`
	BalanceAfter int64     `json:"balance_after" gorm:"not null"`
	CreatedAt    time.Time `json:"created_at"`
	Transfer     Transfer  `json:"transfer" gorm:"foreignKey:TransferID"`
	Wallet       Wallet    `json:"wallet" gorm:"foreignKey:WalletID"`
}
Write the database connection function

Your models define the shape of data. Now you need a function that opens a connection pool to PostgreSQL so GORM can read and write that data.

  • Create a new file called internal/database/database.go and paste the following code:
package database

import (
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func Connect(dsn string) (*gorm.DB, error) {
	// Open a GORM connection using the PostgreSQL driver
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info),
	})
	if err != nil {
		return nil, err
	}

	// Configure the underlying connection pool
	sqlDB, err := db.DB()
	if err != nil {
		return nil, err
	}
	sqlDB.SetMaxOpenConns(10)
	sqlDB.SetMaxIdleConns(5)

	return db, nil
}

What does this code do?

  • gorm.Open creates a GORM database instance using the PostgreSQL driver (which uses pgx under the hood).
  • logger.Info mode makes GORM print every SQL query it runs. This is useful during development so you can see exactly what your code does.
  • SetMaxOpenConns(10) caps the pool at 10 simultaneous connections. SetMaxIdleConns(5) keeps 5 connections warm so repeated requests skip the connection handshake.
  • Save internal/database/database.go.
  • Verify the database package compiles by running:
go build ./internal/database

No output means a successful build.

Build failing with import errors?

  • Make sure the package declaration says package database at the top of the file.
  • Run go mod tidy to ensure all dependencies are resolved. Then try the build again.

Help me fix my database package build error

✔️ Awesome, I've got everything!

Great. Double check you have saved internal/database/database.go.

ⓧ I'd like to double check the full code

package database

import (
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func Connect(dsn string) (*gorm.DB, error) {
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Info),
	})
	if err != nil {
		return nil, err
	}

	sqlDB, err := db.DB()
	if err != nil {
		return nil, err
	}
	sqlDB.SetMaxOpenConns(10)
	sqlDB.SetMaxIdleConns(5)

	return db, nil
}
Verify the database connection

The packages compile, but you have not proven they can actually reach PostgreSQL. A quick main.go will test the live connection.

  • Create a file called main.go in your project root (wallets-api/) and paste this code:
package main

import (
	"log"
	"os"

	"wallets-api/internal/database"
)

func main() {
	// Use DATABASE_URL if set, otherwise fall back to local defaults
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		dsn = "host=localhost user=fintech password=fintech123 dbname=wallets_dev port=5432 sslmode=disable"
	}

	// Connect to the database
	db, err := database.Connect(dsn)
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	// Verify the connection is alive
	sqlDB, _ := db.DB()
	if err := sqlDB.Ping(); err != nil {
		log.Fatalf("Failed to ping database: %v", err)
	}

	log.Println("Database connected successfully!")
}

What does this code do?

  • os.Getenv("DATABASE_URL") checks for an environment variable first. If it is empty, the code falls back to the local Docker Compose credentials.
  • sqlDB.Ping() sends a real request to PostgreSQL to confirm the connection is alive. GORM uses lazy connections, so Ping forces an immediate check.

This main.go is temporary

In the next step, you will replace this file with the full Gin router and HTTP handlers. For now, it only exists to prove the database connection works.

  • Save main.go.
  • Make sure your PostgreSQL container is running. If you stopped it earlier, start it again by running:
docker compose up -d
  • Run the app to test the connection:
go run main.go

You should see Database connected successfully! printed in your terminal. This confirms your Go app can reach PostgreSQL through the connection function you just wrote.

Seeing "Failed to connect to database"?

  • Verify your PostgreSQL container is running with docker ps. You should see the postgres:16 container listed.
  • Check that port 5432 is not being used by another process. If you have a local PostgreSQL installation, it may conflict with the Docker container.
  • Confirm the DSN values in main.go match your docker-compose.yml credentials: user fintech, password fintech123, database wallets_dev.

Help me debug my database connection error

Your data layer is ready. You have models that map to your database tables and a connection function that manages a pool of connections. Next up, you will build the HTTP endpoints that let clients create wallets and move money around.

Build the Wallet Endpoints

Your database schema is in place and your models are defined. Now it's time to build the API layer that actually serves HTTP requests.

In this step, you'll create Gin route handlers for wallet operations. The deposit endpoint is particularly interesting because it wraps the balance update in a database transaction. This ensures the read-then-write sequence is atomic.

In this step, get ready to:
  • Create the wallet handler with Create and Get endpoints.
  • Add the Deposit and Transactions handlers using database transactions.
  • Wire up the Gin router in main.go and test the endpoints.
Create the wallet handler structure

Before writing any endpoint logic, you need a handler struct that holds the database connection. Each handler method will use this connection to query and update wallets.

  • Create the file internal/handlers/wallet_handler.go by running this command from your wallets-api folder:
mkdir -p internal/handlers && touch internal/handlers/wallet_handler.go
  • Add the package declaration, imports, handler struct, constructor, and request structs by pasting this code into internal/handlers/wallet_handler.go:
package handlers

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
	"wallets-api/internal/models"
)

// WalletHandler holds the database connection for all wallet operations
type WalletHandler struct {
	db *gorm.DB
}

// NewWalletHandler creates a handler with the given database connection
func NewWalletHandler(db *gorm.DB) *WalletHandler {
	return &WalletHandler{db: db}
}

// CreateWalletRequest defines the expected JSON body for wallet creation
type CreateWalletRequest struct {
	UserName string `json:"user_name" binding:"required"`
	Currency string `json:"currency"`
}

// DepositRequest defines the expected JSON body for deposits
type DepositRequest struct {
	Amount int64 `json:"amount" binding:"required,gt=0"`
}

What does this code do?

WalletHandler is a struct that stores the GORM database connection. Every handler method receives this connection through the struct, so you don't need to pass db around manually.

The request structs use Gin's binding tags for automatic validation. binding:"required" rejects requests missing that field. binding:"required,gt=0" also ensures the amount is positive.

  • Save internal/handlers/wallet_handler.go.
Add the Create endpoint

The Create handler accepts a JSON body with a user name, creates a new wallet with a zero balance, and returns the wallet object.

  • In internal/handlers/wallet_handler.go, add the Create function below the DepositRequest struct:
func (h *WalletHandler) Create(c *gin.Context) {
	var req CreateWalletRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Default to USD if no currency is provided
	currency := req.Currency
	if currency == "" {
		currency = "USD"
	}

	// Create wallet with zero starting balance
	wallet := models.Wallet{
		UserName: req.UserName,
		Currency: currency,
		Balance:  0,
	}

	if err := h.db.Create(&wallet).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create wallet"})
		return
	}

	c.JSON(http.StatusCreated, wallet)
}

What does this code do?

  • c.ShouldBindJSON(&req) parses the request body and validates it against the struct tags. If user_name is missing, Gin returns a 400 error automatically.
  • h.db.Create(&wallet) inserts the wallet into the database. GORM fills in the auto-generated ID and CreatedAt fields.
  • c.JSON(http.StatusCreated, wallet) returns the complete wallet object as JSON with a 201 status code.
  • Save internal/handlers/wallet_handler.go.
Add the Get endpoint

Clients need a way to check a wallet's current balance. The Get handler looks up a wallet by its ID and returns it.

  • In internal/handlers/wallet_handler.go, add the Get function below the Create function:
func (h *WalletHandler) Get(c *gin.Context) {
	// Parse wallet ID from the URL path
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid wallet ID"})
		return
	}

	// Look up the wallet by primary key
	var wallet models.Wallet
	if err := h.db.First(&wallet, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Wallet not found"})
		return
	}

	c.JSON(http.StatusOK, wallet)
}

What does this code do?

  • c.Param("id") extracts the :id portion from the URL path (e.g. /wallets/3 gives "3").
  • strconv.ParseUint converts the string to an unsigned integer. If someone passes a non-numeric ID, you return a 400 error.
  • h.db.First(&wallet, id) queries by primary key. If no row matches, GORM returns an error and you respond with 404.
  • Save internal/handlers/wallet_handler.go.

Seeing a compile error?

If your editor shows an error on the strconv import, make sure it's listed in the import block at the top of the file. Go requires all imports to be explicitly declared.

help me fix an import error in my Go file

Add the Deposit handler with a database transaction

Depositing funds requires reading the current balance, adding the deposit amount, and saving the new balance. If any of those steps fails partway through, you'd have inconsistent data.

Wrapping the operation in a database transaction guarantees that either all steps succeed together, or none of them apply.

  • In internal/handlers/wallet_handler.go, add the Deposit function below Get:
func (h *WalletHandler) Deposit(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid wallet ID"})
		return
	}

	var req DepositRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Use a transaction to ensure atomic balance update
	var wallet models.Wallet
	err = h.db.Transaction(func(tx *gorm.DB) error {
		// Read current wallet state inside the transaction
		if err := tx.First(&wallet, id).Error; err != nil {
			return err
		}
		// Add deposit amount to balance
		wallet.Balance += req.Amount
		return tx.Save(&wallet).Error
	})

	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deposit"})
		return
	}

	c.JSON(http.StatusOK, wallet)
}

Why use db.Transaction here?

h.db.Transaction(func(tx *gorm.DB) error {...}) opens a database transaction. Every query inside uses tx (the transactional connection) instead of h.db (the regular connection).

If the function returns nil, GORM commits the transaction. If it returns an error, GORM rolls back automatically. This ensures the balance read and write happen as one atomic unit.

  • Save internal/handlers/wallet_handler.go.
Add the Transactions handler

Wallets need a history of all money movements. The Transactions handler returns every ledger entry associated with a wallet, ordered by most recent first.

  • In internal/handlers/wallet_handler.go, add the Transactions function below Deposit:
func (h *WalletHandler) Transactions(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid wallet ID"})
		return
	}

	// Query all ledger entries for this wallet, newest first
	var entries []models.LedgerEntry
	if err := h.db.Where("wallet_id = ?", id).Order("created_at DESC").Find(&entries).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transactions"})
		return
	}

	c.JSON(http.StatusOK, entries)
}

What does this code do?

This queries the ledger_entries table for all entries matching the wallet ID. The Order("created_at DESC") clause ensures the most recent transactions appear first. This endpoint will return an empty array until you start making transfers in the next step.

  • Save internal/handlers/wallet_handler.go.

✔️ Awesome, I've got everything!

Great. Double check you've saved internal/handlers/wallet_handler.go.

ⓧ I'd like to double check the full code

package handlers

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
	"wallets-api/internal/models"
)

type WalletHandler struct {
	db *gorm.DB
}

func NewWalletHandler(db *gorm.DB) *WalletHandler {
	return &WalletHandler{db: db}
}

type CreateWalletRequest struct {
	UserName string `json:"user_name" binding:"required"`
	Currency string `json:"currency"`
}

type DepositRequest struct {
	Amount int64 `json:"amount" binding:"required,gt=0"`
}

func (h *WalletHandler) Create(c *gin.Context) {
	var req CreateWalletRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	currency := req.Currency
	if currency == "" {
		currency = "USD"
	}

	wallet := models.Wallet{
		UserName: req.UserName,
		Currency: currency,
		Balance:  0,
	}

	if err := h.db.Create(&wallet).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create wallet"})
		return
	}

	c.JSON(http.StatusCreated, wallet)
}

func (h *WalletHandler) Get(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid wallet ID"})
		return
	}

	var wallet models.Wallet
	if err := h.db.First(&wallet, id).Error; err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "Wallet not found"})
		return
	}

	c.JSON(http.StatusOK, wallet)
}

func (h *WalletHandler) Deposit(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid wallet ID"})
		return
	}

	var req DepositRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var wallet models.Wallet
	err = h.db.Transaction(func(tx *gorm.DB) error {
		if err := tx.First(&wallet, id).Error; err != nil {
			return err
		}
		wallet.Balance += req.Amount
		return tx.Save(&wallet).Error
	})

	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deposit"})
		return
	}

	c.JSON(http.StatusOK, wallet)
}

func (h *WalletHandler) Transactions(c *gin.Context) {
	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid wallet ID"})
		return
	}

	var entries []models.LedgerEntry
	if err := h.db.Where("wallet_id = ?", id).Order("created_at DESC").Find(&entries).Error; err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch transactions"})
		return
	}

	c.JSON(http.StatusOK, entries)
}
Wire up the Gin router in main.go

The handlers exist, but nothing serves them yet. You need an entry point that connects to the database, registers routes, and starts the HTTP server.

  • Create main.go in the root of your wallets-api folder by running:
touch main.go
  • Paste this code into main.go:
package main

import (
	"log"
	"os"

	"github.com/gin-gonic/gin"
	"wallets-api/internal/database"
	"wallets-api/internal/handlers"
)

func main() {
	// Read database connection string from environment, with a local default
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		dsn = "host=localhost user=fintech password=fintech123 dbname=wallets_dev port=5432 sslmode=disable"
	}

	// Connect to PostgreSQL using the database package
	db, err := database.Connect(dsn)
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	// Create the Gin router with default middleware (logger + recovery)
	r := gin.Default()

	// Register all wallet routes under /api/v1
	api := r.Group("/api/v1")
	{
		walletHandler := handlers.NewWalletHandler(db)
		api.POST("/wallets", walletHandler.Create)
		api.GET("/wallets/:id", walletHandler.Get)
		api.POST("/wallets/:id/deposit", walletHandler.Deposit)
		api.GET("/wallets/:id/transactions", walletHandler.Transactions)
	}

	log.Println("Server starting on :8080")
	r.Run(":8080")
}

What does this code do?

  • os.Getenv("DATABASE_URL") checks for an environment variable first. If it's not set, the code falls back to the local Docker Compose credentials you configured earlier.
  • r.Group("/api/v1") creates a route group. Every route registered under api gets the /api/v1 prefix automatically.
  • r.Run(":8080") starts the HTTP server on port 8080. Gin prints all registered routes to the terminal when it starts.
  • Save main.go.
  • Make sure your PostgreSQL container is still running. Start the server by running:
go run main.go

You should see Gin print your registered routes and a message confirming the server is listening on :8080.

Server won't start?

  • If you see a database connection error, confirm your Docker Compose container is running with docker compose ps. The postgres service should show as running.
  • If you see an import error, run go mod tidy to resolve any missing or unused dependencies.

help me fix my Go server startup error

  • Open a second terminal window. Test wallet creation by running:
curl -X POST http://localhost:8080/api/v1/wallets \
  -H "Content-Type: application/json" \
  -d '{"user_name": "alice"}'

You should see a JSON response with the new wallet, including an id, a balance of 0, and a currency of USD.

  • Test the deposit endpoint by running:
curl -X POST http://localhost:8080/api/v1/wallets/1/deposit \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000}'

You should see the wallet returned with a balance of 5000 (representing $50.00 in cents).

Why cents instead of dollars?

Fintech systems store money as integers representing the smallest unit (cents for USD). This avoids floating-point precision errors that could lose or create money. $50.00 is stored as 5000.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.go and that your server is still running.

ⓧ I'd like to double check the full code

package main

import (
	"log"
	"os"

	"github.com/gin-gonic/gin"
	"wallets-api/internal/database"
	"wallets-api/internal/handlers"
)

func main() {
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		dsn = "host=localhost user=fintech password=fintech123 dbname=wallets_dev port=5432 sslmode=disable"
	}

	db, err := database.Connect(dsn)
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	r := gin.Default()

	api := r.Group("/api/v1")
	{
		walletHandler := handlers.NewWalletHandler(db)
		api.POST("/wallets", walletHandler.Create)
		api.GET("/wallets/:id", walletHandler.Get)
		api.POST("/wallets/:id/deposit", walletHandler.Deposit)
		api.GET("/wallets/:id/transactions", walletHandler.Transactions)
	}

	log.Println("Server starting on :8080")
	r.Run(":8080")
}

Your wallet API is live and handling requests. Next up, you'll build the transfer endpoint that moves money between wallets using double-entry bookkeeping.

Build the Transfer Endpoint with Double-Entry Bookkeeping

Your wallet endpoints handle creation and deposits, but transfers are where the real complexity lives. When money moves between wallets, your system must guarantee that funds are never lost, duplicated, or partially applied.

In this step, you will build the transfer endpoint using double-entry bookkeeping. Every transfer creates two ledger entries: a debit on the sender and a credit on the receiver. An idempotency key prevents duplicate processing if the same request arrives twice.

In this step, get ready to:
  • Create the transfer handler with request validation and idempotency protection.
  • Implement a GORM transaction with double-entry bookkeeping logic.
  • Register the transfer route in your API router.
Create the transfer handler with double-entry bookkeeping

In real payment systems, every movement of money is recorded twice. The sender gets a debit entry (money out), and the receiver gets a credit entry (money in). This is double-entry bookkeeping, and it makes reconciliation and auditing possible.

  • Create a new file at internal/handlers/transfer_handler.go.
  • Add the full transfer handler code by pasting the following:
package handlers

import (
	"errors"
	"net/http"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
	"wallets-api/internal/models"
)

// TransferHandler handles money transfer operations
type TransferHandler struct {
	db *gorm.DB
}

func NewTransferHandler(db *gorm.DB) *TransferHandler {
	return &TransferHandler{db: db}
}

// CreateTransferRequest defines the expected JSON body for a transfer
type CreateTransferRequest struct {
	FromWalletID   uint   `json:"from_wallet_id" binding:"required"`
	ToWalletID     uint   `json:"to_wallet_id" binding:"required"`
	Amount         int64  `json:"amount" binding:"required,gt=0"`
	IdempotencyKey string `json:"idempotency_key" binding:"required"`
}

func (h *TransferHandler) Create(c *gin.Context) {
	var req CreateTransferRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Prevent transfers to the same wallet
	if req.FromWalletID == req.ToWalletID {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot transfer to the same wallet"})
		return
	}

	// Idempotency check: return existing transfer if this key was already used
	var existingTransfer models.Transfer
	if err := h.db.Where("idempotency_key = ?", req.IdempotencyKey).First(&existingTransfer).Error; err == nil {
		c.JSON(http.StatusOK, existingTransfer)
		return
	}

	// Execute the transfer inside a single database transaction
	var transfer models.Transfer
	err := h.db.Transaction(func(tx *gorm.DB) error {
		// Read both wallets
		var fromWallet models.Wallet
		if err := tx.First(&fromWallet, req.FromWalletID).Error; err != nil {
			return errors.New("sender wallet not found")
		}

		var toWallet models.Wallet
		if err := tx.First(&toWallet, req.ToWalletID).Error; err != nil {
			return errors.New("receiver wallet not found")
		}

		// Check the sender has enough funds
		if fromWallet.Balance < req.Amount {
			return errors.New("insufficient balance")
		}

		// Debit the sender
		fromWallet.Balance -= req.Amount
		if err := tx.Save(&fromWallet).Error; err != nil {
			return err
		}

		// Credit the receiver
		toWallet.Balance += req.Amount
		if err := tx.Save(&toWallet).Error; err != nil {
			return err
		}

		// Create the transfer record
		transfer = models.Transfer{
			IdempotencyKey: req.IdempotencyKey,
			FromWalletID:   req.FromWalletID,
			ToWalletID:     req.ToWalletID,
			Amount:         req.Amount,
			Status:         "completed",
		}
		if err := tx.Create(&transfer).Error; err != nil {
			return err
		}

		// Double-entry bookkeeping: create debit entry on sender
		debitEntry := models.LedgerEntry{
			TransferID:   transfer.ID,
			WalletID:     fromWallet.ID,
			EntryType:    "debit",
			Amount:       req.Amount,
			BalanceAfter: fromWallet.Balance,
		}
		if err := tx.Create(&debitEntry).Error; err != nil {
			return err
		}

		// Double-entry bookkeeping: create credit entry on receiver
		creditEntry := models.LedgerEntry{
			TransferID:   transfer.ID,
			WalletID:     toWallet.ID,
			EntryType:    "credit",
			Amount:       req.Amount,
			BalanceAfter: toWallet.Balance,
		}
		if err := tx.Create(&creditEntry).Error; err != nil {
			return err
		}

		return nil
	})

	// Map known errors to 400, everything else to 500
	if err != nil {
		status := http.StatusInternalServerError
		if err.Error() == "insufficient balance" || err.Error() == "sender wallet not found" || err.Error() == "receiver wallet not found" {
			status = http.StatusBadRequest
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusCreated, transfer)
}

What does this code do?

This handler implements four key protections that real payment systems require:

  • Same-wallet guard: rejects transfers where the sender and receiver are the same wallet.
  • Idempotency check: queries for an existing transfer with the same idempotency_key. If found, it returns the original result instead of processing again.
  • GORM transaction: wraps the entire transfer in db.Transaction(). If any step inside the callback returns an error, GORM automatically rolls back all changes.
  • Double-entry ledger: creates one debit LedgerEntry on the sender and one credit LedgerEntry on the receiver. The sum of all entries for a wallet always equals its balance.

Why idempotency keys matter in payments

Network failures can cause a client to retry a request that actually succeeded the first time. Without idempotency, the retry would process the transfer again and move money twice.

The unique constraint on idempotency_key in the database is the last line of defense. Even if the application-level check misses a race condition, the database will reject the duplicate insert.

  • Save internal/handlers/transfer_handler.go.
  • You won't see a change yet. The next substep wires this handler to your router.

Seeing a red underline or import error?

Make sure the file is saved at the exact path internal/handlers/transfer_handler.go. The package declaration must be package handlers to match the existing wallet_handler.go in the same directory.

If your editor shows an error on the wallets-api/internal/models import, confirm that your go.mod file has module wallets-api as its module name.

help me fix import errors in my Go transfer handler

Register the transfer route

The handler exists but your router does not know about it yet. You need to register a POST /transfers route in main.go.

  • In main.go, find this line inside the api route group:
api.GET("/wallets/:id/transactions", walletHandler.Transactions)
  • Add the following two lines directly below it:
		transferHandler := handlers.NewTransferHandler(db)
		api.POST("/transfers", transferHandler.Create)

What does this do?

This creates a new TransferHandler instance with the same database connection and maps POST /api/v1/transfers to its Create method. Every incoming transfer request will hit the handler you just wrote.

  • Save main.go.
  • Stop your running server with Ctrl+C, then restart it by running:
go run main.go

You should see Gin print the registered routes in your terminal, including a line for POST /api/v1/transfers.

Seeing a compile error?

If Go reports an undefined reference to NewTransferHandler, confirm that transfer_handler.go is saved inside the internal/handlers/ directory (not in the project root).

If you see a type mismatch error, check that your TransferHandler struct has a db field of type *gorm.DB and that NewTransferHandler accepts *gorm.DB as its parameter.

help me fix this compile error in my Go wallets-api project

Double check your code

✔️ Awesome, I've got everything!

Great. Make sure both transfer_handler.go and main.go are saved.

ⓧ I'd like to double check the full code

Here is the complete transfer_handler.go:

package handlers

import (
	"errors"
	"net/http"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
	"wallets-api/internal/models"
)

type TransferHandler struct {
	db *gorm.DB
}

func NewTransferHandler(db *gorm.DB) *TransferHandler {
	return &TransferHandler{db: db}
}

type CreateTransferRequest struct {
	FromWalletID   uint   `json:"from_wallet_id" binding:"required"`
	ToWalletID     uint   `json:"to_wallet_id" binding:"required"`
	Amount         int64  `json:"amount" binding:"required,gt=0"`
	IdempotencyKey string `json:"idempotency_key" binding:"required"`
}

func (h *TransferHandler) Create(c *gin.Context) {
	var req CreateTransferRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if req.FromWalletID == req.ToWalletID {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot transfer to the same wallet"})
		return
	}

	var existingTransfer models.Transfer
	if err := h.db.Where("idempotency_key = ?", req.IdempotencyKey).First(&existingTransfer).Error; err == nil {
		c.JSON(http.StatusOK, existingTransfer)
		return
	}

	var transfer models.Transfer
	err := h.db.Transaction(func(tx *gorm.DB) error {
		var fromWallet models.Wallet
		if err := tx.First(&fromWallet, req.FromWalletID).Error; err != nil {
			return errors.New("sender wallet not found")
		}

		var toWallet models.Wallet
		if err := tx.First(&toWallet, req.ToWalletID).Error; err != nil {
			return errors.New("receiver wallet not found")
		}

		if fromWallet.Balance < req.Amount {
			return errors.New("insufficient balance")
		}

		fromWallet.Balance -= req.Amount
		if err := tx.Save(&fromWallet).Error; err != nil {
			return err
		}

		toWallet.Balance += req.Amount
		if err := tx.Save(&toWallet).Error; err != nil {
			return err
		}

		transfer = models.Transfer{
			IdempotencyKey: req.IdempotencyKey,
			FromWalletID:   req.FromWalletID,
			ToWalletID:     req.ToWalletID,
			Amount:         req.Amount,
			Status:         "completed",
		}
		if err := tx.Create(&transfer).Error; err != nil {
			return err
		}

		debitEntry := models.LedgerEntry{
			TransferID:   transfer.ID,
			WalletID:     fromWallet.ID,
			EntryType:    "debit",
			Amount:       req.Amount,
			BalanceAfter: fromWallet.Balance,
		}
		if err := tx.Create(&debitEntry).Error; err != nil {
			return err
		}

		creditEntry := models.LedgerEntry{
			TransferID:   transfer.ID,
			WalletID:     toWallet.ID,
			EntryType:    "credit",
			Amount:       req.Amount,
			BalanceAfter: toWallet.Balance,
		}
		if err := tx.Create(&creditEntry).Error; err != nil {
			return err
		}

		return nil
	})

	if err != nil {
		status := http.StatusInternalServerError
		if err.Error() == "insufficient balance" || err.Error() == "sender wallet not found" || err.Error() == "receiver wallet not found" {
			status = http.StatusBadRequest
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusCreated, transfer)
}

And here is the complete main.go:

package main

import (
	"log"
	"os"

	"github.com/gin-gonic/gin"
	"wallets-api/internal/database"
	"wallets-api/internal/handlers"
)

func main() {
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		dsn = "host=localhost user=fintech password=fintech123 dbname=wallets_dev port=5432 sslmode=disable"
	}

	db, err := database.Connect(dsn)
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	r := gin.Default()

	api := r.Group("/api/v1")
	{
		walletHandler := handlers.NewWalletHandler(db)
		api.POST("/wallets", walletHandler.Create)
		api.GET("/wallets/:id", walletHandler.Get)
		api.POST("/wallets/:id/deposit", walletHandler.Deposit)
		api.GET("/wallets/:id/transactions", walletHandler.Transactions)

		transferHandler := handlers.NewTransferHandler(db)
		api.POST("/transfers", transferHandler.Create)
	}

	log.Println("Server starting on :8080")
	r.Run(":8080")
}

Your transfer endpoint is live. Next up, you will add a transaction history endpoint and test the full flow end-to-end with real API calls.

Test the Full Flow

You have a fully built wallet API with endpoints for creating wallets, depositing funds, transferring money, and viewing transaction history. Now it is time to prove it all works together.

In this step, you will simulate a real-world scenario: two users, a deposit, a transfer, and a retry that tests your idempotency protection. By the end, you will have confirmed that money moves correctly, ledger entries balance, and duplicate requests are safely ignored.

In this step, get ready to:
  • Create two wallets and deposit funds into one.
  • Transfer money between wallets and verify the balances and ledger entries.
  • Test idempotency by sending the same transfer request twice.
Create two wallets and deposit funds

Your API server is already running on port 8080 from the previous step. You need a separate terminal window to send requests to it.

  • Open a new terminal tab or window.
  • Press Cmd+T (macOS) or Ctrl+Shift+T (Windows/Linux) to open a new tab in your terminal.

Why a second terminal?

Your Go server occupies the first terminal, listening for incoming requests. You need a second terminal to act as the client sending those requests with curl.

  • Create the first wallet for Alice by running this command:
curl -X POST http://localhost:8080/api/v1/wallets -H "Content-Type: application/json" -d '{"user_name": "alice"}'
  • You should see a JSON response with Alice's wallet, showing a balance of 0 and an id of 1.
  • Create the second wallet for Bob by running:
curl -X POST http://localhost:8080/api/v1/wallets -H "Content-Type: application/json" -d '{"user_name": "bob"}'
  • You should see Bob's wallet with id of 2 and a balance of 0.
  • Deposit $100.00 (10000 cents) into Alice's wallet by running:
curl -X POST http://localhost:8080/api/v1/wallets/1/deposit -H "Content-Type: application/json" -d '{"amount": 10000}'
  • You should see Alice's wallet returned with balance now showing 10000.

Why cents instead of dollars?

The API stores amounts in cents (integers) to avoid floating-point precision issues. This is standard practice in fintech. $100.00 becomes 10000, and $30.00 becomes 3000.

Getting connection refused?

Make sure your Go server is still running in the other terminal window. If it crashed or you closed it, navigate back to your wallets-api directory and run go run main.go again.

Also confirm your PostgreSQL container is still running with docker compose ps.

help me debug a connection refused error when curling my Go API

Transfer between wallets and verify balances

Alice has $100.00 in her wallet. Now you will transfer $30.00 from Alice to Bob and confirm the balances update correctly with matching ledger entries.

  • Transfer 3000 cents ($30.00) from wallet 1 to wallet 2 by running:
curl -X POST http://localhost:8080/api/v1/transfers -H "Content-Type: application/json" -d '{"from_wallet_id": 1, "to_wallet_id": 2, "amount": 3000, "idempotency_key": "txn-001"}'
  • You should see a JSON response with the transfer record, including status set to completed and the idempotency_key of txn-001.

What is the idempotency key doing?

The idempotency_key is a unique string you attach to each transfer request. If a network failure causes your client to retry, the server recognises the key and returns the original result instead of processing the transfer again.

This is how real payment systems prevent double-charging. You will test this protection in the next substep.

  • Verify Alice's balance dropped to 7000 by running:
curl http://localhost:8080/api/v1/wallets/1
  • You should see "balance":7000 in the response.
  • Verify Bob's balance increased to 3000 by running:
curl http://localhost:8080/api/v1/wallets/2
  • You should see "balance":3000 in the response.
  • Check Alice's transaction history to see the ledger entry by running:
curl http://localhost:8080/api/v1/wallets/1/transactions
  • You should see a JSON array with one entry where entry_type is debit, amount is 3000, and balance_after is 7000.

What is double-entry bookkeeping?

Every transfer creates two ledger entries: a debit on the sender and a credit on the receiver. The sum of all entries for a wallet always equals its current balance.

This pattern provides a complete audit trail. If a balance ever looks wrong, you can replay the ledger entries to find exactly where the discrepancy happened.

Test idempotency by retrying the same transfer

In real payment systems, network failures cause clients to retry requests. Without idempotency protection, a retry could process the same transfer twice and drain the sender's wallet.

  • Send the exact same transfer request again (same idempotency_key) by running:
curl -X POST http://localhost:8080/api/v1/transfers -H "Content-Type: application/json" -d '{"from_wallet_id": 1, "to_wallet_id": 2, "amount": 3000, "idempotency_key": "txn-001"}'
  • You should see the same transfer record returned (with the same id) instead of a new one being created.
  • Confirm Alice's balance is still 7000 (not 4000) by running:
curl http://localhost:8080/api/v1/wallets/1
  • You should still see "balance":7000. The duplicate request was safely ignored.

How does idempotency protection work here?

When the server receives a transfer request, it first checks the database for an existing transfer with the same idempotency_key. If one exists, it returns that transfer immediately without touching any balances.

The UNIQUE constraint on idempotency_key in the transfers table is the last line of defense. Even if the application-level check fails due to a race condition, the database rejects the duplicate insert.

Balance decreased on the retry?

If Alice's balance dropped to 4000 after the second request, your idempotency check is not working. Open internal/handlers/transfer_handler.go and confirm the h.db.Where("idempotency_key = ?", req.IdempotencyKey).First(&existingTransfer) block appears before the h.db.Transaction call.

help me debug why my idempotency key check is not preventing duplicate transfers

Your wallet API is fully tested and working. Money moves atomically, every transfer has a matching debit and credit in the ledger, and duplicate requests are handled gracefully. Next up, you will take on the Secret Mission and add pessimistic locking to handle concurrent requests safely.

Secret mission

Add Pessimistic Locking to Prevent Overdrafts

Your transfer endpoint checks balances before deducting, but what happens when two requests hit the same wallet at the exact same time? Both read the same balance, both think there's enough money, and both succeed. Time to fix that race condition with database-level locking.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing cloud costs. The only resource using system memory is the PostgreSQL Docker container.

Resources you used:

  • PostgreSQL Docker container and its data volume.
  • The wallets-api project directory on your Desktop.
  • The Goose CLI binary installed in your Go bin directory.
  • The postgres:16 Docker image.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the API soon.

  • Your PostgreSQL container continues running and uses around 50-100 MB of memory.
  • The Goose CLI binary remains in your Go bin directory.
  • Your project files stay on your Desktop.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and data stay intact so you can resume later.

  • Stop the PostgreSQL container by opening your terminal, navigating to your wallets-api folder, and running:
docker compose stop
  • When you're ready to resume, navigate back to the project folder and run:
docker compose start
  • Your database data persists in the Docker volume, so all your wallets and transfers will still be there.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Stop and remove the PostgreSQL container and its data volume by navigating to your wallets-api folder and running:
docker compose down -v
  • Remove the downloaded PostgreSQL Docker image by running:
docker image rm postgres:16
  • Remove the Goose CLI binary by running:
rm $(which goose)
  • Delete the project directory. On macOS or Linux, run:
rm -rf ~/Desktop/wallets-api
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\wallets-api

Nice Work!

Nice Work!

You just built the foundation of a real payment system. This Go API correctly handles money transfers with transactional safety, double-entry bookkeeping, and idempotency.

You've learned how to:

  • Build a Go REST API with Gin handling wallet creation, deposits, and transfers between wallets.
  • Implement double-entry bookkeeping so every transfer records a matching debit and credit entry in the ledger.
  • Use GORM database transactions and idempotency keys to ensure money is never lost, created, or duplicated on retry.
  • Secret Mission: Added pessimistic locking with SELECT FOR UPDATE to prevent overdrafts under concurrent requests.

Ready to quiz yourself?