Go/Gin REST API for transactional payments and credits, built on MariaDB (GORM) with InfluxDB request metrics and Sentry error tracking — a reference implementation of the patterns needed for peak-load, no-data-loss payment services (venue/event wallet-style credit systems).
Live: https://billfold.ddns.net/api
flowchart LR
client[Client]
subgraph api[Gin API]
cors[CORS]
sentryMw[Sentry MW]
metrics[Metrics MW]
timeout[Timeout MW]
auth[Auth MW - JWT]
handlers[Handlers]
end
db[(MariaDB via GORM)]
influx[(InfluxDB)]
sentry[Sentry]
client --> cors --> sentryMw --> metrics --> timeout --> handlers
handlers -. protected routes .-> auth
handlers --> db
metrics --> influx
sentryMw --> sentry
Go, Gin, GORM (MariaDB), InfluxDB, Sentry, Swagger/OpenAPI annotations, Docker, GitLab CI.
go mod tidy
cp .env.example .env
go run main.gobillfold-gin-gorm-api/
main.go
config/ # DB (MariaDB), InfluxDB, Sentry, migrations
models/ # User, Post, Tag, Wallet, Transaction
handlers/ # Auth, CRUD, payment/credit handlers
routes/ # Route registration
middleware/ # Auth (JWT), DB transaction wrapper, request metrics, Sentry
Dockerfile
.gitlab-ci.yml
models/payment.go and handlers/payment_handler.go implement the transactional core:
Wallet.Balanceis only ever mutated inside a DB transaction alongside aTransactionledger row — balance and ledger can't drift apart even on a mid-request crash.- Row locking (
SELECT ... FOR UPDATE) on the wallet row prevents lost updates when concurrent requests hit the same wallet under peak load. - Every adjustment requires a client-supplied
idempotency_key, so retried requests (timeouts, load-balancer retries) are safely no-ops instead of double-applying. /payments/*routes require a valid JWT (middleware.RequireAuth) so balance mutations can't be triggered by unauthenticated callers.
sequenceDiagram
participant C as Client
participant H as AdjustBalance handler
participant DB as MariaDB (tx)
C->>H: POST /payments/adjust (idempotency_key)
H->>DB: BEGIN
H->>DB: SELECT transaction WHERE idempotency_key = ?
alt already applied
DB-->>H: existing row found
H->>DB: COMMIT (no-op)
H-->>C: 200 prior transaction
else new request
DB-->>H: not found
H->>DB: SELECT wallet FOR UPDATE
DB-->>H: wallet locked
H->>H: compute new balance
alt debit would overdraw
H->>DB: ROLLBACK
H-->>C: 422 insufficient balance
else balance ok
H->>DB: UPDATE wallet balance
H->>DB: INSERT transaction (ledger)
H->>DB: COMMIT
H-->>C: 200 balance adjusted
end
end
Base URL: https://billfold.ddns.net/api/v1 (local dev: http://localhost:8080/api/v1)
All bodies are JSON. Authenticated routes require Authorization: Bearer <token>.
POST /auth/register
// request
{ "username": "alice", "email": "alice@example.com", "password": "at-least-8-chars" }
// 201 response
{ "token": "eyJ...", "user": { "ID": 1, "username": "alice", "email": "alice@example.com", "is_active": true } }POST /auth/login
// request
{ "username": "alice", "password": "at-least-8-chars" }
// 200 response
{ "token": "eyJ...", "user": { "ID": 1, "username": "alice", "email": "alice@example.com" } }POST /payments/adjust — credit or debit a wallet. Transactional, row-locked, idempotent.
// request
{
"user_id": 1,
"type": "credit", // "credit" | "debit"
"amount_cents": 5000,
"idempotency_key": "order-1234-attempt-1", // required, 8-100 chars, unique per logical operation
"reference": "top-up via card" // optional
}
// 200 response
{
"message": "Balance adjusted successfully",
"transaction": {
"ID": 1, "wallet_id": 1, "type": "credit",
"amount_cents": 5000, "idempotency_key": "order-1234-attempt-1",
"reference": "top-up via card"
}
}Re-sending the same idempotency_key returns the original transaction instead of applying it twice. Debits that would overdraw the wallet return 422 with {"error": "insufficient balance"}.
GET /payments/wallets/:user_id
// 200 response
{ "ID": 1, "user_id": 1, "balance_cents": 5000 }POST /users— create (public registration endpoint;auth/registeris preferred for login-capable accounts)GET /users?page=&limit=&active=&search=— paginated listGET /users/:idPATCH /users/:id— partial update (username,email,bio,is_active)DELETE /users/:id?hard=true— soft delete by default,hard=truepermanently deletesPOST /users/:id/restore— undo a soft delete
POST /posts—{ "title", "content", "user_id", "tags": ["..."] }GET /posts?published=&author_id=&tag=— filterable listGET /posts/:id— includes author, tags, and threaded repliesPOST /posts/transfer—{ "post_id", "new_user_id" }, transactional ownership transfer
GET /health→{"status":"healthy"}— liveness, always 200 once the process is upGET /ready→{"status":"ready"}or503 {"status":"unready"}— pings MariaDB
Note:
/healthand/readyare unversioned, at the API root (/api/health, not/api/v1/health), and there is no handler at bare/apior/api/v1— hitting those paths directly returns 404 by design.
- Graceful shutdown on SIGINT/SIGTERM: in-flight requests (including payment adjustments) are drained before the process exits, with a 20s deadline.
- Per-request timeout (15s) via
middleware.TimeoutMiddlewareso a stalled DB call can't hold a connection open indefinitely. - CORS is locked to
ALLOWED_ORIGINS(comma-separated), not wildcard. config.RequireEnv()fails fast at startup ifJWT_SECRET/DB config is missing, instead of failing silently on the first request.- SQL query logging drops to warn-only when
GIN_MODE=release.
Dockerfile— multi-stage build, static binary on Alpine (for containerized hosts; the current production deploy runs natively, see below).gitlab-ci.yml— test (go vet+go test -race) → build → Docker image pushdocker-compose.prod.yml— API + MariaDB + InfluxDB, for a Docker-based single-host deploy
Running natively on the host (no Docker) as a launchd service:
- Binary built with
go build -o billfold-api ., run via~/Library/LaunchAgents/com.billfold.api.plist(RunAtLoad+KeepAlive, auto-restarts on crash) - MariaDB via Homebrew, local
billfolddatabase, dedicatedbillfold_appuser - nginx (Homebrew,
/opt/homebrew/etc/nginx/servers/billfold.conf) reverse-proxiesbillfold.ddns.net/api/*→127.0.0.1:8080, HTTP→HTTPS redirect - TLS via Let's Encrypt, issued with the webroot method (
certbot certonly --webroot -w /opt/homebrew/var/www/certbot -d billfold.ddns.net), matching how sibling sites on this host are configured
To redeploy after code changes:
go build -o billfold-api .
launchctl kickstart -k gui/$(id -u)/com.billfold.apiInfluxDB and Sentry are not yet wired in production — see below.
- InfluxDB metrics: the client library (
influxdata/influxdb-client-go/v2) targets the v2 HTTP API; the InfluxDB installed on the production host is v3 (different wire protocol).INFLUX_URLis left unset, soconfig.RecordRequestLatencyno-ops safely. Needs either a v2-compatible client swap or a v3-installed instance to activate. - Sentry:
SENTRY_DSNis unset in production; error tracking is inactive until a DSN is provided.