Let users redeem newsletter invite codes for credit

Go · Go · intermediate · modification

Invite codes from the newsletter can now be redeemed for account credit. Everything runs in one transaction: we look the code up, refuse it if it is already redeemed, then mark it and write the ledger row, so a failure halfway leaves nothing behind. Tested locally: fresh code (200), same code again (409), typo (404), lowercase input (200).

Go 1.22 service on the standard library (`net/http` method-pattern `ServeMux`, `log/slog`, `database/sql` with the pgx stdlib driver) on PostgreSQL 16 at the default READ COMMITTED isolation; `*sql.DB` is a pooled handle used by concurrent requests. The billing mux sits behind the auth middleware, and `auth.UserID(ctx) (int64, bool)` returns the signed-in user. Package `billing` already has `BalanceHandler` and `writeJSON(w http.ResponseWriter, status int, v any)`. Existing tables: `invite_codes(code text primary key, credit_minor bigint not null, redeemed_by bigint null references users, redeemed_at timestamptz null)` and `credit_ledger(id bigserial, user_id bigint not null, amount_minor bigint not null, reason text not null, created_at timestamptz default now())`. Each newsletter goes to ~40k subscribers with one shared code, and the first minutes after a send bring bursts of simultaneous redemptions of the same code. The server applies a global request-size limit.

Requirements

Files touched

--- internal/billing/routes.go
 	balance := &BalanceHandler{DB: db}
 	mux.HandleFunc("GET /api/billing/balance", balance.Get)
+
+	invites := &InviteHandler{DB: db}
+	mux.HandleFunc("POST /api/billing/invites/redeem", invites.Redeem)
 }
 
--- internal/billing/invites.go
+package billing
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"log/slog"
+	"net/http"
+	"strings"
+
+	"example.com/shop/internal/auth"
+)
+
+var (
+	errUnknownCode     = errors.New("unknown invite code")
+	errAlreadyRedeemed = errors.New("invite code already redeemed")
+)
+
+type InviteHandler struct {
+	DB *sql.DB

Review this PR

Go practice