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
- `POST /api/billing/invites/redeem` with `{"code": "..."}` (trimmed, case-insensitive; codes use only ASCII letters, digits and `-`, and are stored uppercase) credits the signed-in user with the code's `credit_minor`: one row in `credit_ledger` (`user_id`, `amount_minor`, `reason` = `invite:<CODE>`), and marks the code redeemed (`redeemed_by`, `redeemed_at`). Answers 200 `{"creditMinor": n}`.
- A code is single-use across all users, forever: exactly one redemption may ever succeed (the newsletter's prize code: the first reader to redeem it wins). Every other attempt, including one sent at the same moment as the winning one, answers 409 `{"error": "already_redeemed"}` and writes nothing.
- Unknown code: 404 `{"error": "unknown_code"}`. A body that does not decode as `{"code": string}`, or a missing or blank `code`: 400 `{"error": "code_required"}`. Database errors: 500 `{"error": "internal"}`, cause logged, never sent to the client.
- The ledger row and the code update happen in one transaction: either both are written or neither is.
Files touched
- internal/billing/routes.go
- internal/billing/invites.go
--- 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