package payments import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log" "log/slog" "math" "net/http" "strconv" "strings" "sync" "time" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/internal/twofa" "crussell/internal/validators" "crussell/mw" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // --- Types --- // defaultGiftCardExpiryMonths is the CMA-recommended rolling expiry window used // whenever business_settings.gift_card_expiry_months is unset or invalid (< 1). const defaultGiftCardExpiryMonths = 24 // GetGiftCardExpiryMonths returns the configured gift-card expiry window in // months — the SINGLE source of truth for rolling expiry. It reads // business_settings.gift_card_expiry_months and falls back to // defaultGiftCardExpiryMonths when the settings row is missing (pgx.ErrNoRows) // or the stored value is < 1 (a sub-1-month window would make every card // effectively expired on creation). Exported so the scheduling package's // CleanupExpiredGiftCards job and the payment handlers share one implementation. func GetGiftCardExpiryMonths(ctx context.Context, q db.Querier) (int, error) { var months int if err := q.QueryRow(ctx, `SELECT gift_card_expiry_months FROM business_settings LIMIT 1`).Scan(&months); err != nil { if errors.Is(err, pgx.ErrNoRows) { return defaultGiftCardExpiryMonths, nil } return 0, err } if months < 1 { return defaultGiftCardExpiryMonths, nil } return months, nil } type GiftCard struct { ID string `json:"id"` TotalFundsAdded float64 `json:"total_funds_added"` AmountRemaining float64 `json:"amount_remaining"` CreatedBy *string `json:"created_by,omitempty"` CreatedAt time.Time `json:"created_at"` RedeemedAt *time.Time `json:"redeemed_at,omitempty"` RedeemedBy *string `json:"redeemed_by,omitempty"` IsInventory bool `json:"is_inventory"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` Cancellable bool `json:"cancellable"` CancellationReason string `json:"cancellation_reason,omitempty"` } type UserBalance struct { UserID string `json:"user_id"` Name string `json:"name"` Email string `json:"email"` Balance float64 `json:"balance"` UpdatedAt time.Time `json:"updated_at"` } type GiftCardListResponse struct { TotalUnclaimed float64 `json:"total_unclaimed"` TotalUserBalances float64 `json:"total_user_balances"` GiftCards []GiftCard `json:"gift_cards"` UserBalances []UserBalance `json:"user_balances"` Total int `json:"total"` UBTotal int `json:"ub_total"` Page int `json:"page"` PerPage int `json:"perPage"` TotalPages int `json:"totalPages"` NextCursor *string `json:"next_cursor,omitempty"` } type CreateGiftCardRequest struct { Amount float64 `json:"amount"` IsInventory bool `json:"is_inventory,omitempty"` } type TopUpGiftCardRequest struct { Amount float64 `json:"amount"` PaymentMethod string `json:"payment_method"` } type TransferGiftCardRequest struct { ToCardID string `json:"to_card_id"` Amount float64 `json:"amount"` } type BuyGiftCardRequest struct { Amount int64 `json:"amount"` RecipientType string `json:"recipient_type"` RecipientEmail string `json:"recipient_email,omitempty"` CardID *string `json:"card_id,omitempty"` // SavedCardID (saved_card_id) is the SCA path's reference to the stored // saved card (user_saved_cards.id), mirroring CreateBookingPayment / // CreateTerminalPayment / TillSaleRequest. It coexists with NewCardToken // when the frontend sends the SCA tokenize-result — // card.tokenize(verificationDetails, cardId) — as new_card_token: the // tokenize-result token is a fresh one-time source_id and the saved-card // row supplies the Square customer (resolveChargeSource). Without a token // it behaves exactly like card_id (legacy saved-card charge). SavedCardID *string `json:"saved_card_id,omitempty"` NewCardToken *string `json:"new_card_token,omitempty"` SaveCard bool `json:"save_card"` // IdempotencyKey is optional (M1): if not provided, a deterministic key is // generated server-side based on user_id + amount + recipient_type + card_id. // This ensures retries of the same logical purchase use the same key while // distinct purchases get different keys. Max=45 matches Square's limit. IdempotencyKey string `json:"idempotency_key,omitempty" validate:"omitempty,max=45"` VerificationToken *string `json:"verification_token,omitempty"` } type RedeemGiftCardRequest struct { Code string `json:"code"` } // giftCardAmountPence validates an admin gift-card transaction amount (pounds // float64) and converts it to pence. Non-finite values (NaN/Inf) and amounts // above the £250 per-transaction bound (maxAdminGiftCardTransactionPence) are // rejected BEFORE the float→int64 conversion: a huge float would otherwise wrap // int64(math.Round(...)) to INT64_MIN and silently bypass the cap (the check // `int64(...) > maxAdminGiftCardTransactionPence` would be false for a negative // wrap). Returns ok=false with the HTTP error already written on rejection. func giftCardAmountPence(w http.ResponseWriter, amount float64) (int64, bool) { if math.IsNaN(amount) || math.IsInf(amount, 0) { http.Error(w, "Amount must be a finite number", http.StatusBadRequest) return 0, false } if amount > maxAdminGiftCardTransactionPence/100.0 { http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest) return 0, false } return int64(math.Round(amount * 100)), true } // --- Admin Handlers --- // giftCardDailyCapLockKey is the session advisory-lock key that serializes one // admin's gift-card value operations (CreateGiftCard / TopUpGiftCard / // TransferGiftCard) so the £5,000/day cap check and the mutation recording the // new value are atomic (M7). Keyed per admin: distinct admins never contend. const giftCardDailyCapLockKey = "crussell:giftcard-daily-cap:" // giftCardUserCapLockKey is the session advisory-lock key that serializes one // USER's online gift-card purchases (BuyGiftCard) so the £500/day cap check // (userGiftCardSpentToday) and the purchase that records the new spend are // atomic. The idempotency-key lock (crussell:giftcard:) only serializes // SAME-key retries; two concurrent DISTINCT purchases would otherwise both // read spentToday=0 before either commits and both pass the cap. Keyed per // user: distinct users never contend. const giftCardUserCapLockKey = "crussell:giftcard-user-cap:" // acquireUserGiftCardCapLock acquires the per-user gift-card daily-cap lock // (a bounded try-lock on a pinned pool connection, mirroring // acquireGiftCardDailyCapLock). The cap read (userGiftCardSpentToday) and the // purchase that records the new spend must run under the same lock, or two // concurrent distinct purchases could both read the day's spend before either // commits and both pass the cap — overshooting the £500/day ceiling. Returns // the pinned connection once the lock is held (the caller must defer // capConn.Release() and releasePaymentLock(capConn, key)) or nil after writing // the error response. func acquireUserGiftCardCapLock(ctx context.Context, w http.ResponseWriter, userID string) (*pgxpool.Conn, bool) { lockKey := giftCardUserCapLockKey + userID pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for gift-card user-cap lock %s: %v", lockKey, err) http.Error(w, "internal server error", http.StatusInternalServerError) return nil, false } lockOK, err := acquireAdvisoryLock(ctx, pinConn, lockKey) if err != nil { pinConn.Release() log.Printf("Failed to acquire gift-card user-cap lock %s: %v", lockKey, err) http.Error(w, "internal server error", http.StatusInternalServerError) return nil, false } if !lockOK { pinConn.Release() log.Printf("Gift-card user-cap lock %s not acquired within bound — another gift-card purchase for this user is in progress", lockKey) http.Error(w, "Another gift-card purchase is already in progress — please try again in a moment", http.StatusConflict) return nil, false } return pinConn, true } // acquireGiftCardDailyCapLock acquires the per-admin gift-card daily-cap lock // (a bounded try-lock on a pinned pool connection, mirroring the payment // handlers' serialization pattern). The daily cap check // (adminGiftCardValueToday) and the transaction that records the new value must // run under the same lock, or two concurrent top-ups could both read the day's // value before either writes and both pass the cap — over-issuing value. // Returns the pinned connection once the lock is held (the caller must defer // capConn.Release() and releasePaymentLock(capConn, key)) or nil after writing // the error response. func acquireGiftCardDailyCapLock(ctx context.Context, w http.ResponseWriter, adminID string) (*pgxpool.Conn, bool) { lockKey := giftCardDailyCapLockKey + adminID pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for gift-card daily-cap lock %s: %v", lockKey, err) http.Error(w, "internal server error", http.StatusInternalServerError) return nil, false } lockOK, err := acquireAdvisoryLock(ctx, pinConn, lockKey) if err != nil { pinConn.Release() log.Printf("Failed to acquire gift-card daily-cap lock %s: %v", lockKey, err) http.Error(w, "internal server error", http.StatusInternalServerError) return nil, false } if !lockOK { pinConn.Release() log.Printf("Gift-card daily-cap lock %s not acquired within bound — another gift-card value operation for this admin is in progress", lockKey) http.Error(w, "Another gift-card value operation is already in progress for this admin — please try again in a moment", http.StatusConflict) return nil, false } return pinConn, true } func GetGiftCards(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Parse query parameters query := r.URL.Query() searchTerm := query.Get("q") // Pagination parameters perPage := 10 cursorStr := query.Get("cursor") if perPageStr := query.Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp } } // Accept page param for backward compat (deprecated) page := 1 if pageStr := query.Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } var resp GiftCardListResponse resp.GiftCards = []GiftCard{} resp.UserBalances = []UserBalance{} resp.Page = page resp.PerPage = perPage // --- Aggregate totals (unfiltered, unpaginated) --- err := db.Conn.QueryRow(ctx, ` SELECT COALESCE(SUM(amount_remaining), 0) FROM gift_cards WHERE redeemed_by IS NULL `).Scan(&resp.TotalUnclaimed) if err != nil { log.Printf("Failed to calculate total unclaimed gift cards: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } err = db.Conn.QueryRow(ctx, ` SELECT COALESCE(SUM(balance), 0) FROM user_giftcard_balances `).Scan(&resp.TotalUserBalances) if err != nil { log.Printf("Failed to calculate total user balances: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // --- Gift cards (paginated, optionally filtered) --- filterType := query.Get("type") whereClauses := []string{} if searchTerm != "" { whereClauses = append(whereClauses, "id ILIKE $1") } if filterType == "customer" { whereClauses = append(whereClauses, "is_inventory = FALSE") } else if filterType == "inventory" { whereClauses = append(whereClauses, "is_inventory = TRUE") } whereSQL := "" if len(whereClauses) > 0 { whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") } var gcTotal int var gcListArgs []any gcListQuery := fmt.Sprintf(` SELECT id, total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date, created_at FROM gift_cards %s `, whereSQL) if searchTerm != "" { searchPattern := "%" + searchTerm + "%" gcListArgs = []any{searchPattern} if cursorStr != "" { cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) if err != nil { http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest) return } gcListQuery += " AND (created_at, id) < ($2, $3)" gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID) } gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1) gcListArgs = append(gcListArgs, perPage+1) } else { if cursorStr != "" { cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) if err != nil { http.Error(w, "invalid cursor: "+err.Error(), http.StatusBadRequest) return } gcListQuery += " WHERE (created_at, id) < ($1, $2)" gcListArgs = append(gcListArgs, cursorCreatedAt, cursorID) } gcListQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(gcListArgs)+1) gcListArgs = append(gcListArgs, perPage+1) } // Count query: total matching gift cards (same WHERE, without cursor/ORDER BY/LIMIT). // Run BEFORE the data query to avoid "conn busy" when using a per-test // transaction (single connection). gcTotal = 0 if whereSQL != "" { countArgs := []any{} if searchTerm != "" { countArgs = append(countArgs, "%"+searchTerm+"%") } if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal); err != nil { log.Printf("Failed to scan filtered gift card count: %v", err) } } else { if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal); err != nil { log.Printf("Failed to scan gift card count: %v", err) } } gcRows, err := db.Conn.Query(ctx, gcListQuery, gcListArgs...) if err != nil { log.Printf("Failed to query gift cards: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer gcRows.Close() // Drain all rows before the per-card cancellation lookups below — pgx.Tx // (used by the test harness via a context-stored transaction) does not // support concurrent queries on one connection. type giftCardRow struct { id string totalFunds float64 remaining float64 redeemedBy sql.NullString isInventory bool expiryDate sql.NullTime createdAt time.Time } var drained []giftCardRow for gcRows.Next() { var r giftCardRow err = gcRows.Scan(&r.id, &r.totalFunds, &r.remaining, &r.redeemedBy, &r.isInventory, &r.expiryDate, &r.createdAt) if err != nil { log.Printf("Failed to scan gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } drained = append(drained, r) } gcRows.Close() if err := gcRows.Err(); err != nil { log.Printf("Gift card row iteration error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Surface whether each card is currently cancellable under the 14-day // cooling-off right (same assessment the user-facing GetMyGiftCards uses). // The purchase transaction is resolved per card so the admin can act on // behalf of the card's real purchaser; cards without an online purchase // (inventory/till/admin-created) simply report as not cancellable. for _, r := range drained { var gc GiftCard gc.ID = r.id gc.TotalFundsAdded = r.totalFunds gc.AmountRemaining = r.remaining gc.CreatedAt = r.createdAt gc.IsInventory = r.isInventory if r.redeemedBy.Valid { redeemedBy := r.redeemedBy.String gc.RedeemedBy = &redeemedBy } var purchaserID string var purchaseAmount float64 var purchasedAt time.Time err := db.Conn.QueryRow(ctx, ` SELECT amount, created_at, COALESCE(user_id, '') FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase' AND reference_type = 'api' ORDER BY created_at DESC LIMIT 1`, r.id).Scan(&purchaseAmount, &purchasedAt, &purchaserID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { log.Printf("Failed to load purchase transaction for gift card %s: %v", r.id, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } status := assessGiftCardCancellation(ctx, db.Conn, r.id, r.totalFunds, r.remaining, r.isInventory, r.expiryDate, purchaseAmount, purchasedAt, purchaserID) gc.Cancellable = status.Cancellable gc.CancellationReason = status.CancellationReason resp.GiftCards = append(resp.GiftCards, gc) } // --- User balances (all, optionally filtered) --- var ubTotal int var ubListQuery string var ubListArgs []any if searchTerm != "" { searchPattern := "%" + searchTerm + "%" ubListQuery = ` SELECT b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at FROM user_giftcard_balances b JOIN users u ON b.user_id = u.id WHERE u.n_first_name ILIKE $1 OR u.n_last_name ILIKE $1 OR u.email ILIKE $1 ORDER BY b.updated_at DESC ` ubListArgs = []any{searchPattern} } else { ubListQuery = ` SELECT b.user_id, u.n_first_name || ' ' || u.n_last_name AS name, u.email, b.balance, b.updated_at FROM user_giftcard_balances b JOIN users u ON b.user_id = u.id ORDER BY b.updated_at DESC ` ubListArgs = []any{} } ubRows, err := db.Conn.Query(ctx, ubListQuery, ubListArgs...) if err != nil { log.Printf("Failed to query user balances: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer ubRows.Close() // Count query for user balances. if searchTerm != "" { if err := db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances b JOIN users u ON b.user_id = u.id WHERE u.n_first_name ILIKE $1 OR u.n_last_name ILIKE $1 OR u.email ILIKE $1`, "%"+searchTerm+"%").Scan(&ubTotal); err != nil { log.Printf("Failed to scan filtered user balance count: %v", err) } } else { if err := db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal); err != nil { log.Printf("Failed to scan user balance count: %v", err) } } for ubRows.Next() { var ub UserBalance err = ubRows.Scan( &ub.UserID, &ub.Name, &ub.Email, &ub.Balance, &ub.UpdatedAt, ) if err != nil { log.Printf("Failed to scan user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } resp.UserBalances = append(resp.UserBalances, ub) } resp.UBTotal = ubTotal resp.Total = gcTotal var nextCursor *string if len(resp.GiftCards) > perPage { resp.GiftCards = resp.GiftCards[:perPage] last := resp.GiftCards[len(resp.GiftCards)-1] cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID nextCursor = &cursor } resp.NextCursor = nextCursor totalPages := (gcTotal + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 } resp.TotalPages = totalPages if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func CreateGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Defense-in-depth admin check (S-1) — creating a funded gift card moves // money, so it must stay admin-only even if the route is ever re-registered // on a router without mw.RequireAdmin. if !isAdminRequest(r) { http.Error(w, "Admin access required", http.StatusForbidden) return } adminID, _ := ctx.Value(mw.UserIDKey).(string) var req CreateGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if req.Amount < 0 { http.Error(w, "Amount must not be negative", http.StatusBadRequest) return } if req.Amount == 0 && !req.IsInventory { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } // C2: cap the admin-funded amount at £250 (25,000 pence) per transaction // (owner decision — tighter than the £10,000 ceiling ValidateAmount // enforces on other payment entry points, and matching the till's cap). // An inventory card may still be created at £0. The pence conversion is // bounded against non-finite/oversized floats BEFORE the int64 conversion // (a huge float would wrap to INT64_MIN and bypass the cap). amountPence, amountOK := giftCardAmountPence(w, req.Amount) if !amountOK { return } // Daily limit (owner decision): an admin may create/top-up/transfer at // most £5,000 of gift-card value per UTC day. The cap check and the value // recording transaction are serialized per admin (M7) so two concurrent // operations cannot both read the day's value before either writes. capPinConn, lockOK := acquireGiftCardDailyCapLock(ctx, w, adminID) if !lockOK { return } defer capPinConn.Release() defer releasePaymentLock(capPinConn, giftCardDailyCapLockKey+adminID) adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID) if err != nil { log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if int64(math.Round(adminValueToday*100))+amountPence > maxAdminGiftCardDailyPence { http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest) return } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var gc GiftCard var lastUsedAt sql.NullTime var purchaseVoucherType string err = tx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if err != nil { log.Printf("Failed to query voucher type: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } // HMRC VAT Notice 700/7: salon-only gift cards are SPV by definition, so // the EFFECTIVE type is written even when the stored setting is 'MPV' — // recording raw 'MPV' here would make the redemption path defer VAT a // second time (VAT is already collected at sale via the GetVATConfig SPV // override). purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, $1, $2, $3, NOW(), NOW() + ($5 * INTERVAL '1 month'), $4) RETURNING id, total_funds_added, amount_remaining, created_by, created_at, is_inventory, last_used_at `, req.Amount, adminID, req.IsInventory, purchaseVoucherType, expiryMonths).Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, &gc.CreatedBy, &gc.CreatedAt, &gc.IsInventory, &lastUsedAt, ) if err != nil { log.Printf("Failed to create gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if lastUsedAt.Valid { gc.LastUsedAt = &lastUsedAt.Time } var notes *string if req.IsInventory { s := "inventory card" notes = &s } else { s := "giveaway" notes = &s } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'purchase', $2, 'api', NULL, $3, $4) `, gc.ID, req.Amount, adminID, notes) if err != nil { log.Printf("Failed to record gift card transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // MEDIUM-3a coverage: an admin creating a funded gift card is an admin // money action — record it in admin_audit_log (best-effort, own tx). InsertAdminAuditCharge(ctx, adminID, "", "admin_gift_card_create", map[string]any{ "gift_card_id": gc.ID, "amount": req.Amount, "is_inventory": req.IsInventory, }) w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(gc); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func TopUpGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Defense-in-depth admin check (S-1) — topping up a gift card moves money, // so it must stay admin-only even if the route is ever re-registered on a // router without mw.RequireAdmin. if !isAdminRequest(r) { http.Error(w, "Admin access required", http.StatusForbidden) return } adminID, _ := ctx.Value(mw.UserIDKey).(string) cardID := chi.URLParam(r, "id") if cardID == "" || !validators.IsValidID(cardID) { http.Error(w, "Invalid gift card ID", http.StatusBadRequest) return } var req TopUpGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if req.Amount <= 0 { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } // C2: cap the top-up at £250 (25,000 pence) per transaction (owner // decision — tighter than the £10,000 ceiling ValidateAmount enforces on // other payment entry points, and matching the till's cap). The pence // conversion is bounded against non-finite/oversized floats BEFORE the // int64 conversion (a huge float would wrap to INT64_MIN and bypass the cap). amountPence, amountOK := giftCardAmountPence(w, req.Amount) if !amountOK { return } // Daily limit (owner decision): an admin may create/top-up/transfer at // most £5,000 of gift-card value per UTC day. The cap check and the value // recording transaction are serialized per admin (M7) so two concurrent // top-ups cannot both read the day's value before either writes. capPinConn, lockOK := acquireGiftCardDailyCapLock(ctx, w, adminID) if !lockOK { return } defer capPinConn.Release() defer releasePaymentLock(capPinConn, giftCardDailyCapLockKey+adminID) adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID) if err != nil { log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if int64(math.Round(adminValueToday*100))+amountPence > maxAdminGiftCardDailyPence { http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest) return } validMethods := map[string]string{ "cash": "cash", "card_machine": "in_person_card", "online_square": "online_square", "on_the_house": "on_the_house", } _, ok := validMethods[req.PaymentMethod] if !ok { http.Error(w, "Invalid payment method. Must be one of: cash, card_machine, online_square, on_the_house", http.StatusBadRequest) return } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var redeemedBy sql.NullString var isInventory bool var currentTotalFunds float64 var expiryDate sql.NullTime err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added, expiry_date FROM gift_cards WHERE id = $1 FOR UPDATE", cardID).Scan(&redeemedBy, &isInventory, ¤tTotalFunds, &expiryDate) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) return } log.Printf("Failed to check gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if redeemedBy.Valid { http.Error(w, "Cannot top up a card that has already been redeemed to an account", http.StatusBadRequest) return } // money-F4: an expired gift card must never be topped up — the UPDATE // below resets expiry_date to NOW()+months and would resurrect a card // whose remaining value the nightly cleanup job already forfeited. // Mirrors the till path's expiry gate (till.go:812-825) and // RedeemGiftCard's (same DB-clock comparison, same 400); a NULL // expiry_date (legacy) is treated as unexpired. expired, err := giftCardExpired(ctx, tx, expiryDate) if err != nil { log.Printf("Failed to check gift card expiry: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if expired { http.Error(w, "Gift card has expired", http.StatusBadRequest) return } expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } txType := "topup" var notes *string if isInventory && currentTotalFunds == 0 { txType = "purchase" s := "first top-up on inventory card" notes = &s } var gc GiftCard var createdBy sql.NullString err = tx.QueryRow(ctx, ` UPDATE gift_cards SET total_funds_added = total_funds_added + $1, amount_remaining = amount_remaining + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 RETURNING id, total_funds_added, amount_remaining, created_by, created_at `, req.Amount, cardID, expiryMonths).Scan( &gc.ID, &gc.TotalFundsAdded, &gc.AmountRemaining, &createdBy, &gc.CreatedAt, ) if err != nil { log.Printf("Failed to top up gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if createdBy.Valid { gc.CreatedBy = &createdBy.String } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, $2, $3, 'api', NULL, $4, $5) `, cardID, txType, req.Amount, adminID, notes) if err != nil { log.Printf("Failed to record gift card transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // MEDIUM-3a coverage: an admin topping up a gift card is an admin money // action — record it in admin_audit_log (best-effort, own tx). InsertAdminAuditCharge(ctx, adminID, "", "admin_gift_card_topup", map[string]any{ "gift_card_id": cardID, "amount": req.Amount, }) if err := json.NewEncoder(w).Encode(gc); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func TransferGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Defense-in-depth admin check (S-1) — transferring gift-card value moves // money, so it must stay admin-only even if the route is ever re-registered // on a router without mw.RequireAdmin. if !isAdminRequest(r) { http.Error(w, "Admin access required", http.StatusForbidden) return } adminID, _ := ctx.Value(mw.UserIDKey).(string) fromCardID := chi.URLParam(r, "from") if fromCardID == "" || !validators.IsValidID(fromCardID) { http.Error(w, "Invalid source gift card ID", http.StatusBadRequest) return } var req TransferGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } req.ToCardID = validators.NormalizeGiftCardCode(req.ToCardID) if !validators.IsValidID(req.ToCardID) { http.Error(w, "Invalid destination gift card ID", http.StatusBadRequest) return } if strings.EqualFold(fromCardID, req.ToCardID) { http.Error(w, "Source and destination cards must be different", http.StatusBadRequest) return } if req.Amount <= 0 { http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) return } // C2: cap the transfer at £250 (25,000 pence) per transaction (owner // decision — tighter than the £10,000 ceiling ValidateAmount enforces on // other payment entry points, and matching the till's cap). The pence // conversion is bounded against non-finite/oversized floats BEFORE the // int64 conversion (a huge float would wrap to INT64_MIN and bypass the cap). amountPence, amountOK := giftCardAmountPence(w, req.Amount) if !amountOK { return } // Daily limit (owner decision): an admin may create/top-up/transfer at // most £5,000 of gift-card value per UTC day. The cap check and the value // recording transaction are serialized per admin (M7) so two concurrent // operations cannot both read the day's value before either writes. capPinConn, lockOK := acquireGiftCardDailyCapLock(ctx, w, adminID) if !lockOK { return } defer capPinConn.Release() defer releasePaymentLock(capPinConn, giftCardDailyCapLockKey+adminID) adminValueToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID) if err != nil { log.Printf("Failed to query admin gift-card value today for %s: %v", adminID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if int64(math.Round(adminValueToday*100))+amountPence > maxAdminGiftCardDailyPence { http.Error(w, "You have reached your £5,000 daily gift-card value limit", http.StatusBadRequest) return } // Serialize against a concurrent cancellation of the SOURCE card: the // gift-card cancel flow (CancelGiftCard) holds this session advisory lock // across its eligibility check AND its funding reversal, so a transfer // moving value OFF the source mid-cancellation would otherwise return the // customer's money while the transferred balance stays live (double value). // Taking the same lock here makes transfer-from and cancel mutually // exclusive (C2/F2). A transfer INTO a card is covered by the cancel // flow's FOR UPDATE re-verification (the destination's balance change makes // it ineligible), so only the source needs the lock. pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for gift-card transfer lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() lockOK, err = acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+fromCardID) if err != nil { log.Printf("Failed to acquire gift-card cancel lock for %s: %v", fromCardID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { log.Printf("Gift-card transfer lock for %s not acquired within bound — a cancellation is in progress", fromCardID) http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict) return } defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+fromCardID) tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var fromRedeemedBy, toRedeemedBy sql.NullString var fromRemaining, toRemaining float64 // Lock both rows FOR UPDATE in a globally deterministic order (lesser ID // first, then greater) so two concurrent cross-transfers (A→B and B→A) // acquire the locks in the same order and can never deadlock. Locking // "source first" is only deterministic per-request — the caller picks the // source, so opposite transfers would deadlock (N-5). IDs are compared // case-insensitively: gift card codes are 12-hex that may arrive mixed-case // from the URL param vs the body, and the same two cards must always sort // the same way for every concurrent transaction. lockFirstID, lockSecondID := fromCardID, req.ToCardID if strings.ToLower(lockFirstID) > strings.ToLower(lockSecondID) { lockFirstID, lockSecondID = lockSecondID, lockFirstID } lockFirstIsSource := strings.EqualFold(lockFirstID, fromCardID) err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", lockFirstID).Scan(&fromRedeemedBy, &fromRemaining) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if lockFirstIsSource { http.Error(w, "Source gift card not found", http.StatusNotFound) } else { http.Error(w, "Destination gift card not found", http.StatusNotFound) } return } log.Printf("Failed to check gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Lock the second row in the same order so both concurrent transactions // hold the same lock sequence and can never deadlock. err = tx.QueryRow(ctx, "SELECT redeemed_by, amount_remaining FROM gift_cards WHERE id = $1 FOR UPDATE", lockSecondID).Scan(&toRedeemedBy, &toRemaining) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if lockFirstIsSource { http.Error(w, "Destination gift card not found", http.StatusNotFound) } else { http.Error(w, "Source gift card not found", http.StatusNotFound) } return } log.Printf("Failed to check gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // The lock order may differ from the source/destination roles when the // destination ID sorts before the source ID; swap the scanned values back // so the business logic below always treats fromCardID as the source. if !lockFirstIsSource { fromRedeemedBy, toRedeemedBy = toRedeemedBy, fromRedeemedBy fromRemaining, toRemaining = toRemaining, fromRemaining } if fromRedeemedBy.Valid || toRedeemedBy.Valid { http.Error(w, "Cannot transfer balance to/from cards redeemed to accounts", http.StatusBadRequest) return } if fromRemaining < req.Amount { http.Error(w, "Insufficient balance on source gift card", http.StatusBadRequest) return } // A transfer is a "use" of BOTH cards per the rolling-expiry terms — each // card's timer resets at the same moment its balance moves. expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining - $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, req.Amount, fromCardID, expiryMonths) if err != nil { log.Printf("Failed to deduct from source: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = amount_remaining + $1, total_funds_added = total_funds_added + $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, req.Amount, req.ToCardID, expiryMonths) if err != nil { log.Printf("Failed to add to destination: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // MEDIUM-3a coverage: an admin transferring gift-card value is an admin // money action — record it in admin_audit_log (best-effort, own tx, mirrors // the top-up audit above). A transfer is only permitted between // unredeemed cards (both rows' redeemed_by must be NULL), so no cardholder // is determinable — the acting admin is recorded as the target. InsertAdminAuditCharge(ctx, adminID, adminID, "gift_card_transfer", map[string]any{ "from_card_id": fromCardID, "to_card_id": req.ToCardID, "amount": req.Amount, }) w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]string{"status": "success"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // --- User Handlers --- // --- Gift-card redeem failure rate limiting (B16) --- // // RedeemGiftCard converts a 12-hex gift-card code into account balance. The // code space is small enough that an attacker can brute-force plausible codes // through repeated redeem attempts; the route-level per-user limiter // (mw.RateLimitByUser in main.go) throttles by ACCOUNT, but a distributed probe // (many accounts, each trying codes) would still burn a DB lookup per attempt. // This in-memory per-CODE counter adds a second layer: after // giftCardRedeemFailMax consecutive "invalid code" failures for the same // normalized code within giftCardRedeemFailWindow, further redeem attempts for // that code are rejected 429 BEFORE any advisory lock or DB work. // // The counter is keyed on the normalized code, is reset whenever a redeem // attempt resolves the code to a REAL card (any found row breaks the streak of // invalid-code failures), and entries expire after the window so a one-off // typo'd code is never locked out forever. In-memory only (no schema change — // matching the A5 refund-failure counter in refunds.go, manualReconcileFailures), // best-effort: a process restart clears it, and it is NOT a replacement for the // account-level limiter. const ( // giftCardRedeemFailMax is how many consecutive invalid-code redeem // failures against the same card code trigger the 429 lockout. giftCardRedeemFailMax = 5 // giftCardRedeemFailWindow is how long a code's failure streak is // remembered. A card locked by repeated failures stays locked until the // window elapses, then a fresh attempt starts a new streak. giftCardRedeemFailWindow = 15 * time.Minute // giftCardRedeemFailMaxEntries caps the in-memory map so the counter can // never grow unbounded (one entry per code being probed). giftCardRedeemFailMaxEntries = 10_000 ) type giftCardRedeemFailState struct { count int windowEnd time.Time } var ( giftCardRedeemFailMu sync.Mutex giftCardRedeemFails = make(map[string]giftCardRedeemFailState) ) // giftCardRedeemLocked reports whether a code is currently in the 429 lockout // (>= giftCardRedeemFailMax consecutive invalid-code failures inside the // window). An expired entry is treated as not locked — the window has passed. func giftCardRedeemLocked(code string) bool { giftCardRedeemFailMu.Lock() defer giftCardRedeemFailMu.Unlock() st, ok := giftCardRedeemFails[code] if !ok || clock.Now().After(st.windowEnd) { return false } return st.count >= giftCardRedeemFailMax } // giftCardRedeemFail records one more consecutive invalid-code failure for a // code. A fresh streak (new code, or the previous streak expired) starts at 1 // with a fresh window. Bounded: expired entries are purged on insert and, if // the map is still full, one entry is evicted so the map never exceeds // giftCardRedeemFailMaxEntries. func giftCardRedeemFail(code string) { giftCardRedeemFailMu.Lock() defer giftCardRedeemFailMu.Unlock() now := clock.Now() if st, ok := giftCardRedeemFails[code]; ok && !now.After(st.windowEnd) { st.count++ giftCardRedeemFails[code] = st return } if len(giftCardRedeemFails) >= giftCardRedeemFailMaxEntries { for c, st := range giftCardRedeemFails { if now.After(st.windowEnd) { delete(giftCardRedeemFails, c) } } if len(giftCardRedeemFails) >= giftCardRedeemFailMaxEntries { for c := range giftCardRedeemFails { delete(giftCardRedeemFails, c) break } } } giftCardRedeemFails[code] = giftCardRedeemFailState{count: 1, windowEnd: now.Add(giftCardRedeemFailWindow)} } // giftCardRedeemReset clears a code's invalid-code-failure streak — called // whenever a redeem attempt resolves the code to a real gift card, because a // successful resolution breaks the consecutive-failure run. func giftCardRedeemReset(code string) { giftCardRedeemFailMu.Lock() delete(giftCardRedeemFails, code) giftCardRedeemFailMu.Unlock() } func RedeemGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req RedeemGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } code := validators.NormalizeGiftCardCode(req.Code) if !validators.IsValidID(code) { http.Error(w, "Invalid gift card code format", http.StatusBadRequest) return } // B16: a code in the per-card lockout (>= giftCardRedeemFailMax // consecutive invalid-code failures inside the window) is rejected 429 // BEFORE the advisory lock or any DB work — brute-force probing of the // 12-hex code space never reaches the database. if giftCardRedeemLocked(code) { log.Printf("Gift card redeem rate-limited: code %s has failed too many consecutive redeem attempts", code) http.Error(w, "Too many failed redeem attempts for this gift card — try again later", http.StatusTooManyRequests) return } // Serialize against a concurrent cancellation of this same card: the // gift-card cancel flow (CancelGiftCard) holds this session advisory lock // across its eligibility check AND its funding reversal, so redeeming the // balance mid-cancellation would otherwise return the customer's money // while the balance stays live (double value). Taking the same lock here // makes redeem and cancel mutually exclusive (C2/F2). pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for gift-card redeem lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+code) if err != nil { log.Printf("Failed to acquire gift-card cancel lock for %s: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { log.Printf("Gift-card redeem lock for %s not acquired within bound — a cancellation is in progress", code) http.Error(w, "This gift card is being processed — please try again in a moment", http.StatusConflict) return } defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code) tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var amountRemaining float64 var redeemedBy sql.NullString var expiryDate sql.NullTime err = tx.QueryRow(ctx, ` SELECT amount_remaining, redeemed_by, expiry_date FROM gift_cards WHERE id = $1 FOR UPDATE `, code).Scan(&amountRemaining, &redeemedBy, &expiryDate) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // B16: an "invalid code" failure — count it against the per-card // lockout so repeated brute-force attempts on the same code get 429 // after giftCardRedeemFailMax consecutive misses. giftCardRedeemFail(code) http.Error(w, "Invalid or expired gift card code", http.StatusNotFound) return } log.Printf("Failed to query gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // B16: the code resolved to a real gift card — a found row breaks the // consecutive invalid-code-failure streak, so clear the per-card counter. giftCardRedeemReset(code) if redeemedBy.Valid { http.Error(w, "This gift card has already been redeemed", http.StatusBadRequest) return } // LOW-6: enforce expiry at redemption, not just by the nightly cleanup job. // Between a card's expiry_date passing and the next run of // CleanupExpiredGiftCards the card still carries amount_remaining; without // this check the holder could redeem value that is already forfeit (the // nightly job moves it to gift_card_expired_balances and zeroes the card). // Legacy cards with a NULL expiry_date are treated as unexpired. The // comparison uses the DATABASE clock (SELECT NOW()), the same clock that // wrote expiry_date, so an app-clock drift can neither extend nor shorten // card life. expired, err := giftCardExpired(ctx, tx, expiryDate) if err != nil { log.Printf("Failed to check gift card expiry: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if expired { http.Error(w, "This gift card has expired", http.StatusBadRequest) return } if amountRemaining <= 0 { http.Error(w, "This gift card has no remaining balance", http.StatusBadRequest) return } expiryMonths, err := GetGiftCardExpiryMonths(ctx, tx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = 0, redeemed_at = NOW(), redeemed_by = $1, last_used_at = NOW(), expiry_date = NOW() + ($3 * INTERVAL '1 month') WHERE id = $2 `, userID, code, expiryMonths) if err != nil { log.Printf("Failed to update gift card: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, userID, amountRemaining) if err != nil { log.Printf("Failed to update user gift card balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'redeem_to_balance', $2, 'api', NULL, $3, NULL) `, code, amountRemaining, userID) if err != nil { log.Printf("Failed to record gift card transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "amount_redeemed": amountRemaining, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func GetGiftCardBalance(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var balance float64 err := db.Conn.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if err := json.NewEncoder(w).Encode(map[string]any{ "balance": 0.00, "daily_buy_limit": float64(maxUserGiftCardDailyPence) / 100.0, "daily_buy_spent": 0.00, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } log.Printf("Failed to query user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // daily_buy_limit / daily_buy_spent expose the backend's authoritative // £/day online purchase cap (maxUserGiftCardDailyPence) and today's spend, // so the account page's client-side cap mirror (account/+page.svelte) can // never drift from the server constant again. spentToday, spentErr := userGiftCardSpentToday(ctx, db.Conn, userID) if spentErr != nil { log.Printf("Failed to query today's gift-card spend: %v", spentErr) } if err := json.NewEncoder(w).Encode(map[string]any{ "balance": balance, "daily_buy_limit": float64(maxUserGiftCardDailyPence) / 100.0, "daily_buy_spent": spentToday, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // GetUserGiftCardBalanceAdmin Handler returns any user's balance for the admin. func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) { if !isAdminRequest(r) { http.Error(w, "Unauthorized", http.StatusForbidden) return } ctx := r.Context() userID := chi.URLParam(r, "id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "Invalid user ID", http.StatusBadRequest) return } adminID, _ := ctx.Value(mw.UserIDKey).(string) var balance float64 err := db.Conn.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance) if err != nil { if errors.Is(err, pgx.ErrNoRows) { if err := json.NewEncoder(w).Encode(map[string]float64{"balance": 0.00}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } log.Printf("Failed to query user balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Record the balance check through the shared admin-audit helper (same // columns as every other admin money action; best-effort, non-fatal, and // never able to abort this read-only handler). InsertAdminAuditCharge(ctx, adminID, userID, "balance_check", map[string]any{"balance": balance}) if err := json.NewEncoder(w).Encode(map[string]float64{"balance": balance}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } func BuyGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req BuyGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } // M1: idempotency_key is optional. When omitted, a deterministic fallback // key is derived server-side AFTER the advisory lock is acquired (see // deriveGiftCardIdempotencyKey): the base key (user_id + amount + // recipient_type + card_id) is reused while a PENDING payment row exists // for the same logical purchase (a lost-response retry lands on the SAME // key so Square dedups — the old fresh-random-suffix fallback generated a // NEW key per retry, missed the pending row, and charged twice), and it // advances deterministically past COMPLETED purchases so two genuinely // distinct no-key purchases never collapse onto one dedup key. Clients who // need full control still supply their own idempotency_key; that path is // unchanged. // savedCardRef is the effective saved-card reference for this request: // the legacy card_id field OR the SCA path's saved_card_id (they are the // same user_saved_cards.id; card_id wins when both are sent). Mirrors // CreateBookingPayment. scaTokenizedSavedCard is the SCA tokenize-result // wire contract: the token is the one-time charge source and the saved-card // row supplies the Square customer. savedCardRef := req.CardID if savedCardRef == nil || *savedCardRef == "" { savedCardRef = req.SavedCardID } scaTokenizedSavedCard := req.NewCardToken != nil && *req.NewCardToken != "" && savedCardRef != nil && *savedCardRef != "" clientSuppliedKey := req.IdempotencyKey != "" var noKeyCardPart string if !clientSuppliedKey { noKeyCardPart = "new" if savedCardRef != nil && *savedCardRef != "" { noKeyCardPart = *savedCardRef } } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // Product rule (security): only verified accounts may save cards. An // unverified/guest/affiliate user may still buy a gift card, but // save_card=true is rejected here — before any charge source resolution. if rejectSaveCardForUnverified(w, r, req.SaveCard) { return } allowedAmounts := map[int64]bool{1000: true, 2000: true, 5000: true} if !allowedAmounts[req.Amount] { http.Error(w, "Invalid amount. Must be £10, £20, or £50.", http.StatusBadRequest) return } if req.RecipientType != "self" && req.RecipientType != "friend" { http.Error(w, "Invalid recipient type", http.StatusBadRequest) return } if err := ValidateCardInfo(req.CardID, req.SavedCardID, req.NewCardToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // The SCA tokenize-result wire contract requires the saved-card reference // in saved_card_id — the frontend sends it there, never card_id (the // verification_token-less legacy buy keeps card_id alone). A token riding // alongside the legacy card_id field is the ambiguous shape that used to // silently drop saved_card_id and charge the SCA tokenize-result as a // new-card one-off without a customer binding — reject it outright. if req.CardID != nil && *req.CardID != "" && req.NewCardToken != nil && *req.NewCardToken != "" { log.Printf("Gift-card buy rejected: card_id + new_card_token coexist — the SCA tokenize-result path requires saved_card_id") http.Error(w, "Invalid card configuration: use saved_card_id for an SCA tokenized saved-card purchase", http.StatusBadRequest) return } if err := ValidateVerificationToken(req.VerificationToken); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } paymentService := NewPaymentService() // Serialize gift-card purchase attempts on the idempotency key to prevent // concurrent same-key retries from both reusing a pending record and both // executing the gift-card creation (2× value for 1 charge). Mirrors the tip // advisory-lock pattern (handlers.go). Lock is keyed on the idempotency key // so distinct purchases are unaffected; falls back to userID when absent. // Bounded try-lock (R6) so a contended lock never blocks the pool across // the Square round-trip. lockKey := req.IdempotencyKey if !clientSuppliedKey { // Identical logical no-key purchases must serialize on the SAME lock // key — the deterministic base, not a per-request random suffix — so // concurrent lost-response retries cannot both derive a fresh slot and // both charge. lockKey = fmt.Sprintf("gc-%s-%d-%s-%s", userID, req.Amount, req.RecipientType, noKeyCardPart) } pinConn, lockOK := acquireBookingPaymentLock(ctx, w, "crussell:giftcard:"+lockKey, "Purchase in progress, try again") if !lockOK { return } defer releaseBookingPaymentLock(pinConn, "crussell:giftcard:"+lockKey) // Derive the deterministic fallback key UNDER the lock so the spent-slot // scan races no concurrent purchase (mirrors deriveBookingPaymentIdempotencyKey // in handlers.go, which derives under the per-booking lock). if !clientSuppliedKey { derivedKey, dErr := deriveGiftCardIdempotencyKey(ctx, db.Conn, userID, req.Amount, req.RecipientType, noKeyCardPart) if dErr != nil { log.Printf("Failed to derive gift-card idempotency key: %v", dErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } req.IdempotencyKey = derivedKey } // Idempotency: only short-circuit when the existing record is 'completed'. // A 'pending' record means the previous Square call failed — returning it // as 200 would show a success without ever charging. Re-attempt below with // the same key (Square dedups safely) and reuse the pending record. reusePendingID := "" if req.IdempotencyKey != "" { existing, err := paymentService.CheckIdempotencyByKey(ctx, req.IdempotencyKey) if err != nil { log.Printf("Failed to check idempotency: %v", err) } // A6: CheckIdempotencyByKey matches on the key ALONE (service.go), so a // client-supplied deterministic/guessable key (e.g. another user's // "gc----" fallback key) would resolve // to ANOTHER user's payment row — returning it as the caller's completed // purchase, reusing it for a charge, or rejecting the caller on it // (cross-user hijack). Never reuse or return a row the caller does not // own: a foreign match is treated as a fresh request. if existing != nil && (existing.CreatedBy == nil || *existing.CreatedBy != userID) { log.Printf("Gift card idempotency key %q matched payment row %s (status %s) belonging to a different user — treating as a fresh request", req.IdempotencyKey, existing.ID, existing.Status) if clientSuppliedKey { // The collided key is occupied (payments.idempotency_key is // UNIQUE) — derive a fresh deterministic key for THIS user so // the new purchase inserts a new row instead of 500ing on the // constraint. derivedKey, dErr := deriveGiftCardIdempotencyKey(ctx, db.Conn, userID, req.Amount, req.RecipientType, noKeyCardPart) if dErr != nil { log.Printf("Failed to derive fresh gift-card idempotency key after foreign-key collision: %v", dErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } req.IdempotencyKey = derivedKey } existing = nil } if existing != nil { if existing.Status == "completed" { // RE-VALIDATE the matched purchase payment's refund state before // reporting it as success (same guard as the CreateBookingPayment // completed-dedup branches): a refunded purchase's money is no // longer live, and a same-key retry must not claim the purchase // succeeded when the money was already returned. if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil { log.Printf("Failed to re-validate gift-card dedup hit %s against refunds: %v", existing.ID, rErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } else if refunded { log.Printf("Gift card retry rejected: purchase payment %s (key %q) was refunded — refusing to report a refunded purchase as success", existing.ID, req.IdempotencyKey) http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict) return } if err := json.NewEncoder(w).Encode(existing); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } if existing.Status == "pending" { // Guard the amount: a retry with a different amount must not // reuse the pending record (gift card would be issued at the // new amount against the old charge record). if int64(math.Round(existing.Amount*100)) != req.Amount { log.Printf("Gift card retry amount mismatch: pending record %s has %.2f, request has %d pence", existing.ID, existing.Amount, req.Amount) http.Error(w, "Amount does not match the pending gift card payment", http.StatusBadRequest) return } // Defense-in-depth refund guard on the pending-reuse path too: a // pending purchase cannot normally carry a refund (refunds attach // to completed charges), but if one ever exists the money is in // flight/returned and re-attempting the charge must not proceed. if refunded, rErr := paymentHasLiveRefund(ctx, db.Conn, existing.ID); rErr != nil { log.Printf("Failed to re-validate pending gift-card reuse %s against refunds: %v", existing.ID, rErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } else if refunded { log.Printf("Gift card retry rejected: pending purchase payment %s (key %q) was refunded — refusing to reuse a refunded payment", existing.ID, req.IdempotencyKey) http.Error(w, "This payment has been refunded and can no longer be replayed", http.StatusConflict) return } reusePendingID = existing.ID log.Printf("[PAYMENTS] Reusing pending payment %s for idempotent gift-card retry (key %s)", existing.ID, req.IdempotencyKey) } if existing.Status == "failed" { // Swept as stale (>24h) or definitively rejected — a retry would // risk a second Square charge. Reject cleanly (R2). log.Printf("Gift card retry rejected: pending record %s was marked failed", existing.ID) http.Error(w, "This gift card purchase previously failed and can no longer be retried", http.StatusConflict) return } } } // daily limit (owner decision): a user may buy at most £500 of online // gift cards per UTC day. Sums only COMPLETED online purchases (the // gift_card_transactions rows BuyGiftCard writes) so a pending-retry of a // failed Square attempt is never blocked by its own un-issued value. // // The cap read and the purchase that records the new spend are serialized // per user (acquireUserGiftCardCapLock — a bounded try-lock on a pinned // pool connection, mirroring the admin per-admin cap lock): the // idempotency-key lock above only serializes SAME-key retries, so two // concurrent DISTINCT purchases could otherwise both read spentToday=0 // before either commits and both pass the cap. The lock is held (via the // defers) through the purchase's issue transaction, so the read-modify-write // cycle is atomic per user. A pending-reuse retry still passes through here // (only a COMPLETED purchase short-circuits above): its first attempt's // spend was never recorded (the gift_card_transactions row is written in // the issue transaction only after Square succeeds), so the re-read is // correct. userCapPinConn, userCapLockOK := acquireUserGiftCardCapLock(ctx, w, userID) if !userCapLockOK { return } defer userCapPinConn.Release() defer releasePaymentLock(userCapPinConn, giftCardUserCapLockKey+userID) spentToday, err := userGiftCardSpentToday(ctx, db.Conn, userID) if err != nil { log.Printf("Failed to query user gift-card spend today for %s: %v", userID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if int64(math.Round(spentToday*100))+req.Amount > maxUserGiftCardDailyPence { http.Error(w, "You have reached your £500 daily gift-card purchase limit", http.StatusBadRequest) return } // 2FA gating (C5): persisting or charging a card requires 2FA when the // feature is enforced — both paying with an existing saved card (CardID) // and SAVING a new card during this purchase (SaveCard), mirroring // CreateBookingPayment/CreateTipPayment. A one-off new-card (nonce) charge // that is not saved is not gated. A charge carrying a Square // verification_token (SCA performed) skips the gate; a token-less charge is // refused 402 verification_required (SCA-only — the homegrown 2FA fallback // was removed). giftCardVerificationToken := "" if req.VerificationToken != nil { giftCardVerificationToken = *req.VerificationToken } if (savedCardRef != nil && *savedCardRef != "" && !scaTokenizedSavedCard) || req.SaveCard { if gateOK, _ := requireTwoFactorForCardAccess(w, r, paymentService, userID, giftCardVerificationToken, reusePendingID == ""); !gateOK { return } } var sourceID string var savedCardID *string var savedCardCustomerID string // Resolve the new-card-vs-saved-card Square source — shared with // CreateBookingPayment/CreateTipPayment (see resolveChargeSource for the // R6 rationale). savedCardRef (card_id OR saved_card_id) is passed as the // card reference; when an SCA tokenize-result token rides along in // NewCardToken, resolveChargeSource uses the token as the source and the // card row for the customer. sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(ctx, w, paymentService, userID, req.NewCardToken, savedCardRef, req.SaveCard, "Card not found") if !sourceOK { return } amountPounds := float64(req.Amount) / 100.0 // Step 1: Insert payment with status='pending' inside a DB transaction. // Square is NOT called yet — if the tx fails, no harm done. // Gift card and balance are created AFTER payment succeeds (below). tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var buyPaymentID string if reusePendingID != "" { // Reusing the pending record from a failed prior attempt — do not // insert a duplicate (idempotency_key is UNIQUE). Proceed straight to // the Square call, which dedups on the same key. // Refresh square_source_id: this attempt may charge a DIFFERENT token // than the failed attempt (one-time cnon: nonces are spent), and the // sweep replays the charge from the stored source. // B6: keep square_request_snapshot's source_id in sync in the SAME // statement — the sweep replays the stored snapshot verbatim, and a // snapshot carrying the spent source would replay into // IDEMPOTENCY_KEY_REUSED, stranding the row pending forever. The // at-rest snapshot is AES-256-GCM-encrypted in non-mock deployments // (encryptSnapshot's "enc:v1:" marker), so the SourceID refresh must // run in Go — decrypt → set SourceID → re-encrypt — instead of the // legacy SQL jsonb_set, whose COALESCE(...,'{}')::jsonb cast cannot // parse ciphertext. A row with no stored snapshot (legacy) gets just // the source column refreshed, mirroring the booking reuse path // (handlers.go). Best-effort: any failure leaves the snapshot // untouched — the live square_source_id column stays authoritative // and the sweep overrides the replay source from it. var snap sql.NullString if err := tx.QueryRow(ctx, `SELECT square_request_snapshot FROM payments WHERE id = $1`, reusePendingID).Scan(&snap); err != nil { log.Printf("Failed to read square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, err) } var storedSnap *string if snap.Valid && snap.String != "" { body := []byte(snap.String) if !IsExplicitDevOrMockEnv() { if dec, dErr := decryptSnapshot(body); dErr != nil { log.Printf("Failed to decrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, dErr) } else { body = dec } } var req square.CreatePaymentReq if uErr := json.Unmarshal(body, &req); uErr != nil { log.Printf("Failed to parse square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, uErr) } else { req.SourceID = sourceID updated, mErr := json.Marshal(req) if mErr != nil { log.Printf("Failed to re-marshal square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, mErr) } else { stored := updated if !IsExplicitDevOrMockEnv() { if enc, eErr := encryptSnapshot(updated); eErr != nil { log.Printf("Failed to encrypt square_request_snapshot for reused gift-card payment %s: %v", reusePendingID, eErr) } else { stored = enc } } s := string(stored) storedSnap = &s } } } if storedSnap != nil { if _, srcErr := tx.Exec(ctx, ` UPDATE payments SET square_source_id = $1, square_request_snapshot = $2 WHERE id = $3 `, sourceID, *storedSnap, reusePendingID); srcErr != nil { log.Printf("Failed to update square_source_id/square_request_snapshot on reused gift-card payment %s: %v", reusePendingID, srcErr) } } else { if _, srcErr := tx.Exec(ctx, `UPDATE payments SET square_source_id = $1 WHERE id = $2`, sourceID, reusePendingID); srcErr != nil { log.Printf("Failed to update square_source_id on reused gift-card payment %s: %v", reusePendingID, srcErr) } } buyPaymentID = reusePendingID } else { fees := paymentService.CalculateFees(req.Amount, "online") record := PaymentRecord{ PaymentType: "full", PaymentMethod: "online_square", Status: "pending", Amount: amountPounds, SquarePaymentID: nil, IdempotencyKey: &req.IdempotencyKey, Fees: float64(fees) / 100.0, UserSavedCardID: savedCardID, SquareSourceID: &sourceID, CreatedAt: clock.Now(), UpdatedAt: clock.Now(), CreatedBy: &userID, } buyPaymentID, err = paymentService.CreatePaymentRecordTx(ctx, tx, record, nil) if err != nil { log.Printf("Failed to insert payment record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Apply VAT to the pending payment applyVATToChargeRecord(ctx, tx, buyPaymentID, false) if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit buy transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // Commit in the reuse path too. No rows were written, but the commit is // required in the test harness: there the context carries an outer test tx, // so Begin creates a nested savepoint whose deferred rollback would // otherwise undo the status UPDATE executed later on the same connection. // In production Begin is a plain tx and this commit is a harmless no-op. if reusePendingID != "" { if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit buy transaction (reuse): %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } // Step 2: DB transaction committed — safe to call Square now. // If Square fails, the payment record stays 'pending' for manual retry. var buyerEmail string if err := db.Conn.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&buyerEmail); err != nil { log.Printf("[SQUARE-PROD] Failed to resolve buyer email for user %s: %v (Square receipts will not be emailed)", userID, err) } paymentReq := square.CreatePaymentReq{ Amount: req.Amount, Currency: "GBP", SourceID: sourceID, // CustomerID carries the saved-card row's Square customer id on ccof: // charges (save-card path); a cnon: nonce charge (one-off) needs none // (R6). CustomerID: savedCardCustomerID, IdempotencyKey: req.IdempotencyKey, Note: "Gift Card Purchase", BuyerEmail: buyerEmail, VerificationToken: giftCardVerificationToken, } // C3: a card-on-file (ccof) charge — paying with an existing saved card or // saving a new card during this purchase — is customer-initiated: Square // requires customer_details on stored-credential payments. A one-off cnon: // nonce charge is not a stored credential and needs none. if (savedCardRef != nil && *savedCardRef != "") || req.SaveCard { paymentReq.CustomerDetails = &square.CreateCustomerDetails{CustomerInitiated: true} } // M1: store the verbatim request JSON so the sweep can replay the charge // with an IDENTICAL body under the same key — Square compares the whole // request on key reuse, and a reconstructed body returns // IDEMPOTENCY_KEY_REUSED, leaving the row pending forever. The write is // INTENTIONALLY unconditional (writeChargeSnapshotUnconditional — Loop A // regression check 4a restored it): the reuse branch above (B6) already // refreshed square_request_snapshot in the SAME transaction as the // square_source_id refresh, and this post-commit write stores the FRESH // full body for THIS attempt. The immutability guard would wrongly skip // this write on the reuse path when the in-tx refresh failed best-effort. writeChargeSnapshotUnconditional(ctx, db.Conn, "payments", buyPaymentID, paymentReq, "gift-card payment") paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq) if err != nil { log.Printf("Failed to process gift card purchase payment: %v", err) // Payment record intentionally left as 'pending' for manual retry. // SCA-required failures must surface the structured verification_required // body so the frontend triggers the 3DS challenge, not a plain decline. if isVerificationRequiredError(err) { writeVerificationRequiredResponse(w) return } http.Error(w, "Payment failed", chargeFailureStatus(err)) return } // Step 3: Square succeeded — atomically flip the payment to completed and // create the gift card + balance + transaction in ONE transaction. If any // step fails, the whole thing rolls back, the payment stays 'pending', and // a same-key retry re-attempts the Square charge (Square dedups) before // delivering the card. Previously these were separate non-transactional // writes: a failure after the payment-completed update left the customer // CHARGED but with no card, and the completed-dedup swallowed the retry. issueTx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin gift-card issue transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := issueTx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback gift-card issue transaction", "err", err) } }() _, upErr := issueTx.Exec(ctx, `UPDATE payments SET status = 'completed', square_payment_id = $1 WHERE id = $2`, paymentResult.SquarePayID, buyPaymentID, ) if upErr != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but payment %s update failed: %v — manual reconciliation required", paymentResult.SquarePayID, buyPaymentID, upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } // MEDIUM-2: a saved-card gift-card purchase reached its terminal SUCCESS // state — consume the verified 2FA code now, inside the transaction that // records the completed charge (the gate verified without consuming, so a // failed/ambiguous Square charge did not burn the code and a same-key // retry could re-verify the SAME code). if (savedCardRef != nil && *savedCardRef != "" && !scaTokenizedSavedCard) || req.SaveCard { if consErr := twofa.ConsumePendingCode(ctx, issueTx, userID); consErr != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but consuming the 2FA code for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, userID, consErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } } var cardID string expiryMonths, err := GetGiftCardExpiryMonths(ctx, issueTx) if err != nil { log.Printf("Failed to query gift card expiry months (using default %d): %v", defaultGiftCardExpiryMonths, err) expiryMonths = defaultGiftCardExpiryMonths } if req.RecipientType == "self" { var purchaseVoucherType string err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if err != nil { log.Printf("Failed to query voucher type: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } // HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so // voucher_type_at_purchase never records 'MPV' and redemption never // applies deferred VAT a second time. purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) err = issueTx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_at, redeemed_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, 0, $2, NOW(), $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) RETURNING id `, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = issueTx.Exec(ctx, ` INSERT INTO user_giftcard_balances (user_id, balance, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id) DO UPDATE SET balance = user_giftcard_balances.balance + EXCLUDED.balance, updated_at = NOW() `, userID, amountPounds) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but balance update for user %s failed: %v — manual reconciliation required", paymentResult.SquarePayID, userID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } _, err = issueTx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'self-purchase, auto-redeemed') `, cardID, amountPounds, userID) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card transaction record failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } else { var purchaseVoucherType string err = issueTx.QueryRow(ctx, `SELECT COALESCE(voucher_type, 'SPV') FROM business_settings LIMIT 1`).Scan(&purchaseVoucherType) if err != nil { log.Printf("Failed to query voucher type: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaseVoucherType == "" { purchaseVoucherType = "SPV" } // HMRC VAT Notice 700/7: write the EFFECTIVE type (SPV) so // voucher_type_at_purchase never records 'MPV' and redemption never // applies deferred VAT a second time. purchaseVoucherType = effectiveVoucherTypeForPurchase(purchaseVoucherType) err = issueTx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, NOW(), NOW() + ($4 * INTERVAL '1 month'), $3) RETURNING id `, amountPounds, userID, purchaseVoucherType, expiryMonths).Scan(&cardID) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card creation failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } recipient := req.RecipientEmail if recipient == "" { var userEmail string err = issueTx.QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&userEmail) if err != nil { log.Printf("Failed to query user email: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } recipient = userEmail } // TODO: send gift card code via email to recipient once SMTP is wired. // Do NOT log the spendable gift-card code — it is a credential (12-digit // code anyone can redeem). Log only the value and redacted recipient for // audit — the full email is third-party PII with no retention coverage // and the raw free-text field is a log-injection vector. log.Printf("Gift card purchased for friend — value: £%.2f, intended for: %s (code stored in DB, not logged)", amountPounds, redactEmail(recipient)) _, err = issueTx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'purchase', $2, 'api', NULL, $3, 'purchased for friend') `, cardID, amountPounds, userID) if err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift card transaction record failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } if err := issueTx.Commit(ctx); err != nil { log.Printf("CRITICAL: Square payment succeeded (ID=%s) but gift-card issue transaction commit failed: %v — manual reconciliation required", paymentResult.SquarePayID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "code": cardID, "amount": amountPounds, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // deriveGiftCardIdempotencyKey returns the deterministic fallback idempotency // key for a no-client-key gift-card purchase: // "gc----", sha256-truncated when the // verbatim form exceeds Square's 45-char limit (the hash stays deterministic). // // The key must distinguish "same live purchase retried" (dedup) from "new // purchase that happens to be identical" (new charge). The candidate is the // base key (seq 0) then the base key with a "-" suffix (seq >= 1) until a // slot without a COMPLETED or swept/declined FAILED purchase is found. A // COMPLETED purchase always advances the sequence — two genuine identical // no-key purchases (e.g. two £20 self cards) are distinct operations and must // diverge onto distinct keys (the old random-suffix fallback's collapse fix), // and a FAILED purchase (swept stale or definitively rejected) occupies its // slot the same way so the customer's identical repurchase diverges onto a // fresh key instead of being 409-rejected forever (A10). A PENDING row never // occupies a slot: a lost-response retry re-derives the base key, the // idempotency lookup below reuses the pending row, and Square's same-key dedup // returns the original charge — ONE charge instead of the old double-charge. // Must be called under the crussell:giftcard advisory lock so the spent-slot // scan races no concurrent purchase (mirrors deriveBookingPaymentIdempotencyKey // in handlers.go). func deriveGiftCardIdempotencyKey(ctx context.Context, q db.Querier, userID string, amount int64, recipientType, cardPart string) (string, error) { baseKey := fmt.Sprintf("gc-%s-%d-%s-%s", userID, amount, recipientType, cardPart) return scanIdempotencySlot(ctx, baseKey, func(candidate string) (bool, error) { var occupiedID string err := q.QueryRow(ctx, ` SELECT id FROM payments WHERE created_by = $1 AND idempotency_key = $2 AND status IN ('completed', 'failed') `, userID, candidate).Scan(&occupiedID) if errors.Is(err, pgx.ErrNoRows) { return false, nil } if err != nil { return false, err } return true, nil }) } // --- Helpers --- // redactEmail masks an email for logs (PII — third-party addresses are not // covered by retention/anonymization, and the raw free-text field is a // log-injection vector). Mirrors the dev mock's redaction: first two chars of // the local part plus the domain, e.g. "ja***@example.com"; malformed // addresses fall back to "[redacted]". The full email stays in the DB row. func redactEmail(email string) string { at := strings.Index(email, "@") if at < 2 || at+1 >= len(email) { return "[redacted]" } return email[:2] + "***@" + email[at+1:] } // giftCardExpired reports whether a card's expiry_date has passed, comparing // against the DATABASE clock (SELECT NOW()) — the SAME clock source the // CreateGiftCard / TopUpGiftCard / BuyGiftCard / TransferGiftCard expiry WRITES // use (expiry_date = NOW() + months). Every expiry comparison in giftcards.go // must use this DB clock, never crussell/clock.Now(): an application clock that // drifts behind the DB would extend card life past the expiry the DB itself // enforces, while one running ahead would cut it short. A card with a NULL // expiry_date (legacy) is treated as unexpired. func giftCardExpired(ctx context.Context, q db.Querier, expiryDate sql.NullTime) (bool, error) { if !expiryDate.Valid { return false, nil } var dbNow time.Time if err := q.QueryRow(ctx, `SELECT NOW()`).Scan(&dbNow); err != nil { return false, err } return expiryDate.Time.Before(dbNow), nil } type ExpiredBalance struct { ID string `json:"id"` AccountID *string `json:"account_id,omitempty"` OriginalBalance float64 `json:"original_balance"` ExpiredAt time.Time `json:"expired_at"` ClaimedAt *time.Time `json:"claimed_at,omitempty"` ClaimedByAdmin *string `json:"claimed_by_admin,omitempty"` Notes *string `json:"notes,omitempty"` } func GetExpiredBalances(w http.ResponseWriter, r *http.Request) { ctx := r.Context() rows, err := db.Conn.Query(ctx, ` SELECT id, account_id, original_balance, expired_at, claimed_at, claimed_by_admin, notes FROM gift_card_expired_balances ORDER BY expired_at DESC `) if err != nil { log.Printf("Failed to query expired balances: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer rows.Close() var balances []ExpiredBalance for rows.Next() { var b ExpiredBalance var accountID, claimedByAdmin, notes sql.NullString var claimedAt sql.NullTime err = rows.Scan(&b.ID, &accountID, &b.OriginalBalance, &b.ExpiredAt, &claimedAt, &claimedByAdmin, ¬es) if err != nil { log.Printf("Failed to scan expired balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if accountID.Valid { b.AccountID = &accountID.String } if claimedAt.Valid { b.ClaimedAt = &claimedAt.Time } if claimedByAdmin.Valid { b.ClaimedByAdmin = &claimedByAdmin.String } if notes.Valid { b.Notes = ¬es.String } balances = append(balances, b) } if err := rows.Err(); err != nil { log.Printf("Row iteration error: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if balances == nil { balances = []ExpiredBalance{} } if err := json.NewEncoder(w).Encode(map[string]any{ "expired_balances": balances, "total": len(balances), }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } type ClaimExpiredBalanceRequest struct { BalanceID string `json:"balance_id"` Notes *string `json:"notes,omitempty"` } func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) { ctx := r.Context() adminID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || adminID == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Defense-in-depth admin check (S-1) — claiming expired gift-card balance // moves money, so it must stay admin-only even if the route is ever // re-registered on a router without mw.RequireAdmin. if !isAdminRequest(r) { http.Error(w, "Admin access required", http.StatusForbidden) return } var req ClaimExpiredBalanceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.BalanceID == "" { http.Error(w, "balance_id is required", http.StatusBadRequest) return } tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var existingClaimedAt sql.NullTime err = tx.QueryRow(ctx, ` SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1 FOR UPDATE `, req.BalanceID).Scan(&existingClaimedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Expired balance not found", http.StatusNotFound) return } log.Printf("Failed to check expired balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if existingClaimedAt.Valid { http.Error(w, "Balance already claimed", http.StatusConflict) return } _, err = tx.Exec(ctx, ` UPDATE gift_card_expired_balances SET claimed_at = NOW(), claimed_by_admin = $1, notes = $2 WHERE id = $3 `, adminID, req.Notes, req.BalanceID) if err != nil { log.Printf("Failed to claim expired balance: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]string{"status": "claimed"}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // --- 14-day cooling-off cancellation (C3) --- // // The Gift Card T&C is a binding UK consumer contract that grants a 14-day // right to cancel online gift-card purchases with a refund to the original // payment method (Consumer Contracts (Information, Cancellation and Additional // Charges) Regulations 2013). Until this endpoint existed there was NO code // path to execute that right: RefundPayment explicitly rejects // gift-card-purchase payments (booking_id IS NULL) and no cancel route/UI // existed — a legal exposure. // // Eligibility signal for "purchased online by this user within 14 days": // - A gift_card_transactions row on the card with transaction_type='purchase', // reference_type='api' and user_id = the authenticated caller. BuyGiftCard // (the only customer-facing online purchase path) writes exactly this. // Till/admin sales can NEVER match: till sales write reference_type // 'till_sale' (see till.go) and admin-created cards (CreateGiftCard) write // reference_type='api' but with the ADMIN's user id, never the caller's. // - The card itself must be unredeemed (redeemed_by IS NULL — self-purchases // auto-redeem and are therefore not cancellable), non-inventory, not // expired, and still hold exactly its original purchase value // (total_funds_added == amount_remaining == purchase amount — a top-up, // transfer, or spend breaks this). // - The originating payments row: booking_id IS NULL (the same discriminator // RefundPayment and the stale-pending sweep use for gift-card purchases), // created_by = the buyer, payment_method='online_square', status='completed', // with a square_payment_id — the target of the refund. // giftCardCoolingOffPeriod is the 14-day statutory cancellation window // (Consumer Contracts (Information, Cancellation and Additional Charges) // Regulations 2013, regs. 29-38). const giftCardCoolingOffPeriod = 14 * 24 * time.Hour // giftCardCancelRefundReason is the Square refund reason. Square's // refund-reason limit is 192 chars; this is comfortably under it. const giftCardCancelRefundReason = "Gift card cancelled within 14-day cooling-off period (Consumer Contracts Regulations 2013)" type CancelGiftCardRequest struct { Code string `json:"code"` // PaymentID optionally identifies the originating purchase payment so the // handler skips the amount/timing match. When omitted the purchase payment // is located from the gift-card purchase transaction. PaymentID string `json:"payment_id,omitempty"` } // MyGiftCard is one of the caller's online-purchased (unredeemed) gift cards, // with the expiry date (T&C: "the expiry date is displayed in your account") // and whether the 14-day cancellation right currently applies. type MyGiftCard struct { Code string `json:"code"` Amount float64 `json:"amount"` PurchasedAt time.Time `json:"purchased_at"` ExpiryDate *time.Time `json:"expiry_date,omitempty"` Cancellable bool `json:"cancellable"` CancellationReason string `json:"cancellation_reason,omitempty"` PaymentID string `json:"payment_id,omitempty"` } type MyGiftCardsResponse struct { GiftCards []MyGiftCard `json:"gift_cards"` } // GetMyGiftCards lists the authenticated user's customer-facing online // gift-card purchases that are still held as cards (unredeemed — a "self" // purchase is auto-redeemed into the pooled balance and so never appears // here). Each entry carries the rolling expiry date and whether the 14-day // cooling-off cancellation right applies, so the account page can surface a // "Cancel & refund" action exactly where the consumer is legally entitled to // one. func GetMyGiftCards(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } // Only gift_card_transactions rows created by BuyGiftCard match // (reference_type='api' + user_id = caller); till sales ('till_sale') and // admin-created cards (user_id = admin) never appear. rows, err := db.Conn.Query(ctx, ` SELECT gc.id, gc.total_funds_added, gc.amount_remaining, gc.is_inventory, gc.expiry_date, gct.amount AS purchase_amount, gct.created_at AS purchased_at FROM gift_card_transactions gct JOIN gift_cards gc ON gc.id = gct.gift_card_id WHERE gct.user_id = $1 AND gct.transaction_type = 'purchase' AND gct.reference_type = 'api' AND gc.redeemed_by IS NULL ORDER BY gct.created_at DESC `, userID) if err != nil { log.Printf("Failed to query user gift cards: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // Drain all rows before the per-card payment/refund lookups below — // pgx.Tx (used by the test harness via a context-stored transaction) does // not support concurrent queries on one connection. type giftCardRow struct { code string totalFunds float64 remaining float64 isInventory bool expiryDate sql.NullTime purchaseAmount float64 purchasedAt time.Time } var drained []giftCardRow for rows.Next() { var r giftCardRow if err := rows.Scan(&r.code, &r.totalFunds, &r.remaining, &r.isInventory, &r.expiryDate, &r.purchaseAmount, &r.purchasedAt); err != nil { log.Printf("Failed to scan user gift card: %v", err) continue } drained = append(drained, r) } rows.Close() if err := rows.Err(); err != nil { log.Printf("User gift card row iteration error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } out := []MyGiftCard{} for _, r := range drained { gc := MyGiftCard{ Code: r.code, Amount: r.purchaseAmount, PurchasedAt: r.purchasedAt, } if r.expiryDate.Valid { expiry := r.expiryDate.Time gc.ExpiryDate = &expiry } status := assessGiftCardCancellation(ctx, db.Conn, r.code, r.totalFunds, r.remaining, r.isInventory, r.expiryDate, r.purchaseAmount, r.purchasedAt, userID) gc.Cancellable = status.Cancellable gc.CancellationReason = status.CancellationReason gc.PaymentID = status.PaymentID out = append(out, gc) } if out == nil { out = []MyGiftCard{} } if err := json.NewEncoder(w).Encode(MyGiftCardsResponse{GiftCards: out}); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // giftCardCancellationStatus captures whether a gift card is currently // cancellable under the 14-day cooling-off right, and why not when it is not. type giftCardCancellationStatus struct { Cancellable bool CancellationReason string PaymentID string // the originating purchase payment, when cancellable } // assessGiftCardCancellation evaluates a single gift card's cancellation // eligibility under the 14-day cooling-off rules (CCR 2013 regs. 29-38) using // the EXACT same signal set and reason strings as the user-facing // GetMyGiftCards list, so the admin list can surface the same state. // // purchaserID is the owner of the card's online purchase transaction // (transaction_type='purchase', reference_type='api'); empty means the card // was never purchased online (inventory/till/admin-created), so it can never // be cancellable. The caller must have drained all open rows before calling — // q must not be an open rows cursor (pgx.Tx cannot run a second query on the // same connection). func assessGiftCardCancellation(ctx context.Context, q db.Querier, code string, totalFunds, remaining float64, isInventory bool, expiryDate sql.NullTime, purchaseAmount float64, purchasedAt time.Time, purchaserID string) giftCardCancellationStatus { var st giftCardCancellationStatus if purchaserID == "" { st.CancellationReason = "This gift card was not purchased online, so it cannot be cancelled" return st } // A partially spent card is only cancellable when its shortfall is // verified till spend (reg 34(9) refunds the UNSPENT balance); top-ups // and unaccounted shortfalls (transfers/clawbacks) are not. partialSpendOK := true if !approxEqual(totalFunds, purchaseAmount) { partialSpendOK = false } else if !approxEqual(remaining, purchaseAmount) { if remaining <= 0 { partialSpendOK = false } else { spentAtTill, serr := giftCardSpendAtTill(ctx, q, code) if serr != nil { log.Printf("Failed to verify till spend for gift card %s: %v", code, serr) partialSpendOK = false } else { partialSpendOK = approxEqual(spentAtTill+remaining, purchaseAmount) } } } // The expiry comparison uses the DATABASE clock (SELECT NOW()), the same // clock that wrote expiry_date, so the assessment can never disagree with // the DB-enforced card life. expired, err := giftCardExpired(ctx, q, expiryDate) if err != nil { log.Printf("Failed to check gift card expiry for %s: %v", code, err) st.CancellationReason = "Unable to verify this gift card's expiry" return st } switch { case isInventory: st.CancellationReason = "This card was created as shop stock, not purchased online" case !approxEqual(totalFunds, purchaseAmount): st.CancellationReason = "This gift card has been topped up, transferred, or otherwise altered" case !approxEqual(remaining, purchaseAmount) && !partialSpendOK: if remaining <= 0 { st.CancellationReason = "This gift card has no remaining balance to refund" } else { st.CancellationReason = "This gift card has been topped up, transferred, or partially spent in a way that cannot be verified" } case expired: st.CancellationReason = "This gift card has expired" case purchasedAt.Before(clock.Now().Add(-giftCardCoolingOffPeriod)): st.CancellationReason = "The 14-day cancellation period has expired" default: paymentID, _, paymentOK, perr := findGiftCardPurchasePayment(ctx, q, purchaserID, "", purchaseAmount, purchasedAt) switch { case perr != nil: log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr) st.CancellationReason = "The original purchase payment could not be verified" case !paymentOK: st.CancellationReason = "The original purchase payment could not be found" default: // A completed/pending refund row means the money already // returned (or is in flight) — the card cannot be cancelled a // second time. var refundStatus string err := q.QueryRow(ctx, ` SELECT status FROM refunds WHERE payment_id = $1 AND status IN ('completed', 'pending') LIMIT 1`, paymentID).Scan(&refundStatus) switch { case err == nil: st.CancellationReason = "This gift card has already been refunded" case errors.Is(err, pgx.ErrNoRows): st.Cancellable = true st.PaymentID = paymentID default: log.Printf("Failed to check refund status for payment %s: %v", paymentID, err) st.CancellationReason = "Unable to verify the refund status of this gift card" } } } return st } // CancelGiftCard resolves the authenticated caller as the cancellation actor // and delegates to the shared cancellation core (see cancelGiftCardForUser for // the full money-safety contract). func CancelGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() userID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req CancelGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } cancelGiftCardForUser(ctx, w, r, userID, req) } // AdminCancelGiftCard is the admin-side cancellation surface for the 14-day // cooling-off right: it lets a partially spent online-purchased card be // managed from the admin Gift Card Management screen. The admin route group // (mw.RequireAdmin in main.go) guarantees the role; the admin's user id is the // ACTOR (recorded on the refunds row and the 'cancelled' audit transaction), // while the cancellation core still verifies the card's purchase transaction // against its ACTUAL purchaser, so an admin can act on behalf of the real // owner without weakening the money-safety invariants. func AdminCancelGiftCard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() adminID, ok := ctx.Value(mw.UserIDKey).(string) if !ok || adminID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } if !isAdminRequest(r) { http.Error(w, "Admin access required", http.StatusForbidden) return } var req CancelGiftCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } cancelGiftCardForUser(ctx, w, r, adminID, req) } // cancelGiftCardForUser executes the statutory 14-day right to cancel an // online gift-card purchase: it issues a Square refund to the original payment // method — the FULL purchase value for an unspent card, or the UNSPENT // remainder for a card whose shortfall is verified till spend (reg 34(9)) — // then reverses the gift-card funding (amount_remaining zeroed and the // card expired so the balance can never be spent). Registered under // mw.RequireAuth + mw.RequireNonGuest (guests cannot buy gift cards); the // admin surface (AdminCancelGiftCard) reuses this same core with the admin as // the actor. // // Failure safety: the refunds row is inserted (pending) BEFORE the Square call // with a deterministic idempotency key; if Square fails, the row stays pending // for a same-key retry (Square dedups) and the gift card is NOT touched — the // customer keeps the card and the request returns 5xx for a retry. Only once // Square accepts the refund is the card reversed, so a refund can never be // issued without the card value being neutralised, and the card can never be // cancelled while the money is still with the salon. func cancelGiftCardForUser(ctx context.Context, w http.ResponseWriter, r *http.Request, userID string, req CancelGiftCardRequest) { code := validators.NormalizeGiftCardCode(req.Code) if !validators.IsValidID(code) { http.Error(w, "Invalid gift card code format", http.StatusBadRequest) return } if req.PaymentID != "" && !validators.IsValidID(req.PaymentID) { http.Error(w, "Invalid payment id", http.StatusBadRequest) return } // Serialize cancellation attempts per gift card (bounded try-lock, // mirroring the RefundPayment handler) so two concurrent cancels of the // same card cannot both pass the eligibility + refunds-row guards. pinConn, err := db.Conn.Acquire(ctx) if err != nil { log.Printf("Failed to acquire connection for gift-card cancel lock: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer pinConn.Release() lockOK, err := acquireAdvisoryLock(ctx, pinConn, "crussell:giftcard-cancel:"+code) if err != nil { log.Printf("Failed to acquire gift-card cancel lock for %s: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !lockOK { log.Printf("Gift-card cancel lock for %s not acquired within bound — a cancellation is already in progress", code) http.Error(w, "A gift-card cancellation is already in progress, try again", http.StatusConflict) return } defer releasePaymentLock(pinConn, "crussell:giftcard-cancel:"+code) tx, err := db.Conn.Begin(ctx) if err != nil { log.Printf("Failed to begin gift-card cancel transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback gift-card cancel transaction", "err", err) } }() // Lock the card FOR UPDATE so concurrent cancels / top-ups / transfers // serialize on the eligibility read below. var totalFunds, remaining float64 var redeemedBy sql.NullString var isInventory bool var expiryDate sql.NullTime err = tx.QueryRow(ctx, ` SELECT total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date FROM gift_cards WHERE id = $1 FOR UPDATE`, code). Scan(&totalFunds, &remaining, &redeemedBy, &isInventory, &expiryDate) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) return } log.Printf("Failed to load gift card %s for cancellation: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // --- Eligibility (C3) --- if isInventory { http.Error(w, "This gift card was created as shop stock, not purchased online — it cannot be cancelled", http.StatusBadRequest) return } if redeemedBy.Valid { http.Error(w, "This gift card has already been redeemed to an account balance and cannot be cancelled", http.StatusBadRequest) return } // The card must be traceable to a customer-facing online purchase (see the // signal documentation above). The purchase row is resolved WITHOUT // filtering on the actor's user id so the card's TRUE purchaser is found: // in the user flow the actor must BE the purchaser (enforced below), while // an admin acts on behalf of the card's real owner. var purchaseAmount float64 var purchasedAt time.Time var purchaserID string err = tx.QueryRow(ctx, ` SELECT amount, created_at, COALESCE(user_id, '') FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase' AND reference_type = 'api' ORDER BY created_at DESC LIMIT 1`, code).Scan(&purchaseAmount, &purchasedAt, &purchaserID) if errors.Is(err, pgx.ErrNoRows) { if isAdminRequest(r) { http.Error(w, "This gift card was not purchased online, so it cannot be cancelled", http.StatusBadRequest) } else { http.Error(w, "This gift card was not purchased online by your account, so it cannot be cancelled here", http.StatusBadRequest) } return } if err != nil { log.Printf("Failed to load gift-card purchase transaction for %s: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if purchaserID != userID && !isAdminRequest(r) { http.Error(w, "This gift card was not purchased online by your account, so it cannot be cancelled here", http.StatusBadRequest) return } if purchasedAt.Before(clock.Now().Add(-giftCardCoolingOffPeriod)) { http.Error(w, "The 14-day cancellation period for this gift card has expired", http.StatusBadRequest) return } // Eligibility (C3): a full-refund cancellation is only valid while the // card still holds exactly its original purchase value. A partially spent // card remains cancellable when the shortfall is verified till spend — // reg 34(9) entitles the consumer to the UNSPENT balance back while the // value already consumed on services stays with the salon. Top-ups, // redeemed cards, and unaccounted shortfalls (transfers/clawbacks) are // rejected outright. refundAmount := purchaseAmount partialRefund := false var spentAtTill float64 if !approxEqual(totalFunds, purchaseAmount) { http.Error(w, "This gift card has been topped up, transferred, or partially spent and can no longer be cancelled for a full refund", http.StatusBadRequest) return } if !approxEqual(remaining, purchaseAmount) { if remaining <= 0 { http.Error(w, "This gift card has no remaining balance to refund", http.StatusBadRequest) return } spentAtTill, err = giftCardSpendAtTill(ctx, tx, code) if err != nil { log.Printf("Failed to verify till spend for gift card %s: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !approxEqual(spentAtTill+remaining, purchaseAmount) { http.Error(w, "This gift card has been partially spent, transferred, or otherwise altered and can no longer be cancelled; the remaining balance can be spent or redeemed to your account balance", http.StatusBadRequest) return } refundAmount = remaining partialRefund = true } // The expiry comparison uses the DATABASE clock (SELECT NOW()), the same // clock that wrote expiry_date, so an app-clock drift can neither extend // nor shorten the cancellation window. expired, err := giftCardExpired(ctx, tx, expiryDate) if err != nil { log.Printf("Failed to check gift card expiry for %s: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if expired { http.Error(w, "This gift card has already expired and cannot be cancelled", http.StatusBadRequest) return } // --- Locate the originating purchase payment (Square charge) --- // The payment is matched against the card's TRUE purchaser, never the // actor: for the user flow purchaserID == userID (enforced above), and for // an admin cancellation the card's owner is used. paymentID, squarePaymentID, paymentOK, perr := findGiftCardPurchasePayment(ctx, tx, purchaserID, req.PaymentID, purchaseAmount, purchasedAt) if perr != nil { log.Printf("Failed to locate purchase payment for gift card %s: %v", code, perr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if !paymentOK { http.Error(w, "The original purchase payment for this gift card could not be found, so it cannot be refunded", http.StatusBadRequest) return } refundKey := paymentID + "-gccancel-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) refundID := "" resumingRefund := false var priorRefundID, priorStatus, priorKey string var priorAmount float64 err = tx.QueryRow(ctx, ` SELECT id, status, amount, COALESCE(idempotency_key, '') FROM refunds WHERE payment_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, paymentID). Scan(&priorRefundID, &priorStatus, &priorAmount, &priorKey) switch { case errors.Is(err, pgx.ErrNoRows): // Fresh cancellation — insert the pending refund row below. case err != nil: log.Printf("Failed to check prior refund for payment %s: %v", paymentID, err) http.Error(w, "internal server error", http.StatusInternalServerError) return default: switch priorStatus { case "completed": if priorAmount >= refundAmount-0.005 { // The full cancellation entitlement (the full purchase value, // or the unspent remainder of a partially spent card) was // already refunded. Neutralise the card so the value cannot // be spent on top of the returned money, then report. if remaining > 0 { if cerr := cancelGiftCardFunding(ctx, tx, code, userID, priorRefundID, refundAmount); cerr != nil { log.Printf("CRITICAL: gift card %s was already refunded but funding reversal failed: %v — MANUAL RECONCILIATION REQUIRED", code, cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } } if cerr := tx.Commit(ctx); cerr != nil { log.Printf("Failed to commit gift-card cancel transaction: %v", cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "message": "This gift card was already refunded and has now been cancelled.", "amount_refunded": refundAmount, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } // Partial prior refund (C6/F8): the card still holds its full // cancellation entitlement (the full purchase value, or the unspent // remainder of a partially spent card) but only part of it was // returned. Refund the DIFFERENCE via Square with a fresh // deterministic key before neutralising, so the un-refunded remainder // is never silently swallowed. refundAmount -= priorAmount refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) log.Printf("Gift-card purchase %s has a prior partial refund of £%.2f — issuing the £%.2f remainder", paymentID, priorAmount, refundAmount) case "pending", "failed": // Resume an in-flight (pending) or previously-failed refund for // this payment. Only resume a pending row that is OURS; a foreign // pending row must not be re-issued. if priorStatus == "pending" && !strings.HasPrefix(priorKey, paymentID+"-gccancel-") { log.Printf("Gift-card cancel rejected: payment %s has a pending refund row %s from another flow", paymentID, priorRefundID) http.Error(w, "A refund for this gift card purchase is already being processed — please try again later", http.StatusConflict) return } refundID = priorRefundID resumingRefund = true // currentEntitlement is what THIS request's eligibility re-verified // (the full purchase value, or the spend-verified unspent remainder). // The prior attempt's amount may predate a till spend, so never // re-issue more than min(priorAmount, currentEntitlement). currentEntitlement := refundAmount // Reconcile EVERY refund Square holds for this payment (pending and // completed, any amount), not just an exact-amount COMPLETED one. // The prior attempt may still be PENDING (in-flight) at Square — // invisible to an exact-amount reconcile — and re-issuing under a // fresh amount-derived key while it is in flight would mint a // SECOND Square refund (double refund once the first completes). // Only the full picture of the payment's Square refunds can decide // whether a re-issue is safe, how much is still owed, and whether // the money has already moved. sqRefunds, recErr := reconcileAllRefundsAtSquare(ctx, squarePaymentID) if recErr != nil { // Square state unknown — do not re-issue (double-refund risk) // and do not resolve: leave the row pending for the sweep. log.Printf("Gift-card cancel resume aborted for %s: could not reconcile prior refund %s against Square: %v", code, refundID, recErr) http.Error(w, "Refund state could not be verified — please try again", http.StatusInternalServerError) return } // Any still-in-flight (PENDING) Square refund for this payment // means money MAY still land. Re-issuing a second refund now would // double-refund once the first completes. Leave the row 'pending' // (this request makes no writes) for the sweep's refund // reconciliation to settle, and do not touch the card. for i := range sqRefunds { if sqRefunds[i].Status == "PENDING" { log.Printf("Gift-card cancel resume for %s: Square refund %s for payment %s is still PENDING (in flight) — leaving refund row %s pending for sweep reconciliation", code, sqRefunds[i].ID, squarePaymentID, refundID) http.Error(w, "This refund is still being processed by the payment provider — please try again later", http.StatusConflict) return } } // Every Square refund for this payment is terminal. FAILED / // REJECTED / CANCELED refunds never moved money and count as zero; // sum the COMPLETED (and APPROVED — Square's terminal-completed) // amounts to learn what has actually landed. entitlementPence := int64(math.Round(currentEntitlement * 100)) alreadyRefundedPence := int64(0) completedRefundID := "" for i := range sqRefunds { switch sqRefunds[i].Status { case "COMPLETED", "APPROVED": alreadyRefundedPence += sqRefunds[i].Amount if completedRefundID == "" { completedRefundID = sqRefunds[i].ID } } } // Never end up refunded more than the prior attempt claimed OR the // re-verified entitlement, whichever is lower: a till spend between // attempts shrinks the entitlement, and the prior attempt never had // the right to more than its own amount. maxRefund := math.Min(priorAmount, currentEntitlement) if alreadyRefundedPence >= int64(math.Round(maxRefund*100)) { // The money has already moved — at least the full cancellation // entitlement has landed at Square. Mark the row completed with // the Square refund id and neutralise the card so the returned // value can never be spent on top of it. if remaining > 0 { if cerr := cancelGiftCardFunding(ctx, tx, code, userID, refundID, refundAmount); cerr != nil { log.Printf("CRITICAL: gift card %s was already refunded at Square but funding reversal failed: %v — MANUAL RECONCILIATION REQUIRED", code, cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } } if _, upErr := tx.Exec(ctx, `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, completedRefundID, refundID); upErr != nil { log.Printf("CRITICAL: gift card %s was already refunded at Square but refund row %s could not be resolved: %v — MANUAL RECONCILIATION REQUIRED", code, refundID, upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if cerr := tx.Commit(ctx); cerr != nil { log.Printf("Failed to commit gift-card cancel transaction: %v", cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "message": "This gift card had already been refunded and has now been cancelled.", "refund_id": refundID, "amount_refunded": refundAmount, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return } // Less than the entitlement has landed — re-issue ONLY the // difference (entitlement minus what already completed at Square), // still capped at min(priorAmount, currentEntitlement), under a // FRESH deterministic key for THAT difference. The prior key // encodes the prior amount and must NOT be reused when the amount // changed: replaying the stale amount after a till spend would // over-refund. When nothing has landed the key is derived exactly // as the original attempt's (same amount → same key → Square dedups // a prior landed refund). The row is updated to the new key+amount // so a crash-retry re-reads the same state and the sweep reconciles // consistently. refundAmount = float64(entitlementPence-alreadyRefundedPence) / 100 if refundAmount > maxRefund { refundAmount = maxRefund } if alreadyRefundedPence > 0 { refundKey = paymentID + "-gccancel-diff-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) } else { refundKey = paymentID + "-gccancel-" + strconv.FormatInt(int64(math.Round(refundAmount*100)), 10) } if _, uErr := tx.Exec(ctx, `UPDATE refunds SET amount = $1, idempotency_key = $2 WHERE id = $3`, refundAmount, refundKey, refundID); uErr != nil { log.Printf("Failed to update resumed gift-card cancel refund %s to £%.2f / key %s: %v", refundID, refundAmount, refundKey, uErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } log.Printf("Gift-card purchase %s has already refunded £%.2f at Square of the £%.2f entitlement — issuing the £%.2f remainder with a fresh key", paymentID, float64(alreadyRefundedPence)/100, currentEntitlement, refundAmount) } } if refundID == "" { // Insert the pending refund row. booking_id stays NULL for // gift-card purchases (the payments row has no booking). err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin) VALUES ($1, NULL, $2, 'pending', $3, $4, $5, $6, 'giftcard_cancel') ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`, paymentID, refundAmount, giftCardCancelRefundReason, refundKey, userID, clock.Now()).Scan(&refundID) if errors.Is(err, pgx.ErrNoRows) { // A same-key row exists from a concurrent attempt (unreachable // under the advisory lock, but stay safe): treat it as ours. if rErr := tx.QueryRow(ctx, `SELECT id FROM refunds WHERE idempotency_key = $1`, refundKey).Scan(&refundID); rErr != nil { log.Printf("Failed to re-read concurrent refund row for key %s: %v", refundKey, rErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } } else if err != nil { log.Printf("Failed to insert gift-card cancel refund record: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } } if err := tx.Commit(ctx); err != nil { log.Printf("Failed to commit gift-card cancel transaction: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // resolveRefundRow updates the committed refunds row to a definitive // outcome on the FAILURE paths below, where the reversal transaction has // already been rolled back (no money moved / declined / ambiguous). On the // success paths the row is instead resolved INSIDE the reversal // transaction, so 'completed' commits atomically with the funding // reversal (C2/F4) — a completed refund can never coexist with a live // card. resolveRefundRow := func(status, sqRefundID string) { _, upErr := db.Conn.Exec(ctx, `UPDATE refunds SET status = $1, square_refund_id = $2 WHERE id = $3`, status, sqRefundID, refundID) if upErr != nil { slog.Error("CRITICAL: Square refund resolved but refund row update failed — manual reconciliation required", "refund_id", refundID, "err", upErr) } } // --- Reversal + refund transaction (C2/F2, C2/F4) --- // The eligibility transaction above committed with the card still holding // its full purchase value. Between that commit and the reversal the card // must not be redeemable or transferable — the crussell:giftcard-cancel // advisory lock (held for this whole handler, and now also taken by // RedeemGiftCard and TransferGiftCard) serializes those operations against // this cancellation. The reversal transaction below re-verifies under // FOR UPDATE as a second line of defence, and the Square refund is issued // only AFTER that re-verification passes. rtx, rerr := db.Conn.Begin(ctx) if rerr != nil { slog.Error("CRITICAL: no transaction available to re-verify and reverse gift card", "gift_card_id", code, "refund_id", refundID, "err", rerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer func() { if err := rtx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback gift-card reversal transaction", "err", err) } }() var revTotalFunds, revRemaining float64 var revRedeemedBy sql.NullString var revIsInventory bool var revExpiry sql.NullTime err = rtx.QueryRow(ctx, ` SELECT total_funds_added, amount_remaining, redeemed_by, is_inventory, expiry_date FROM gift_cards WHERE id = $1 FOR UPDATE`, code). Scan(&revTotalFunds, &revRemaining, &revRedeemedBy, &revIsInventory, &revExpiry) if err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "Gift card not found", http.StatusNotFound) return } log.Printf("Failed to re-load gift card %s for cancellation reversal: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } // A partially spent card must re-verify against the SAME remaining value // that was spend-verified and funded at eligibility; a full-value card // re-verifies against the full purchase amount. expectRemaining := purchaseAmount if partialRefund { expectRemaining = remaining } // The expiry comparison uses the DATABASE clock (SELECT NOW()) so the // re-verification can never disagree with the clock that wrote expiry_date. revExpired, err := giftCardExpired(ctx, rtx, revExpiry) if err != nil { log.Printf("Failed to re-check gift card expiry for %s: %v", code, err) http.Error(w, "internal server error", http.StatusInternalServerError) return } if revIsInventory || revRedeemedBy.Valid || !approxEqual(revTotalFunds, purchaseAmount) || !approxEqual(revRemaining, expectRemaining) || revExpired { // The card changed between the eligibility commit and the reversal — // redeemed, spent, transferred, or expired. Do NOT refund on top of // the live balance (double value). The reversal tx has no writes and // rolls back via defer, so the card is untouched. if resumingRefund { // Resumed a prior pending/failed row — Square money state is // unknown, so leave the row for the sweep to reconcile. slog.Error("CRITICAL: gift card %s changed state while a gift-card-cancel refund was pending (refund %s) — refund row left pending for sweep reconciliation", "gift_card_id", code, "refund_id", refundID) } else { // Freshly-inserted row in THIS request — Square was never called, // so no money moved: mark it failed so it never blocks a retry. // Roll back the reversal tx first so the failed-status write is // not undone by the deferred rollback. if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr) } log.Printf("Gift-card cancel aborted for %s: card state changed since eligibility — refund row %s marked failed, Square never called", code, refundID) resolveRefundRow("failed", "") } http.Error(w, "This gift card has been redeemed, spent, or transferred since the cancellation was requested and can no longer be refunded", http.StatusConflict) return } // Re-verified still eligible — issue the Square refund now. The refunds // row is already committed (pending) with a deterministic key, so a // crash/response-loss retry re-reads the row and resumes with the SAME // key — Square dedups and the card is never double-refunded. refundResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: squarePaymentID, Amount: int64(math.Round(refundAmount * 100)), IdempotencyKey: refundKey, Reason: giftCardCancelRefundReason, }) switch { case sqErr == nil: status, terminal := SquareRefundStatusToLocal(refundResult.Status) if !terminal { // Square accepted the refund; the money is in flight. The card is // still neutralised immediately — leaving the balance spendable // while the refund is on its way would create value from nothing. // The pending row stays for the sweep to reconcile. log.Printf("Square refund %s for gift-card purchase payment %s is non-terminal (%s) — refund row left pending for reconciliation", refundResult.ID, paymentID, refundResult.Status) } else if status == "failed" { log.Printf("Square refund %s for gift-card purchase payment %s FAILED — refund row marked failed", refundResult.ID, paymentID) } if status == "failed" { // Roll back the reversal tx first so the failed-status write is // not undone by the deferred rollback. if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr) } resolveRefundRow("failed", refundResult.ID) http.Error(w, "Refund failed", http.StatusInternalServerError) return } // Money accepted (COMPLETED) or in flight (PENDING) — reverse the // gift-card funding and resolve the refunds row in THIS SAME // transaction. On a COMPLETED refund the row flips to 'completed' only // when this transaction commits, so a completed refund can never // coexist with a live card (C2/F4). A PENDING Square refund stays // 'pending' for the sweep. Any failure below rolls the whole // transaction back: the card stays live and the row stays pending. if cerr := cancelGiftCardFunding(ctx, rtx, code, userID, refundID, refundAmount); cerr != nil { slog.Error("CRITICAL: Square refund accepted but gift card %s funding reversal failed — refund row left pending for sweep", "gift_card_id", code, "refund_id", refundID, "err", cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if status == "completed" { if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET status = 'completed', square_refund_id = $1 WHERE id = $2`, refundResult.ID, refundID); upErr != nil { slog.Error("CRITICAL: Square refund accepted but gift card %s refund row could not be resolved in the reversal transaction — row left pending", "gift_card_id", code, "refund_id", refundID, "err", upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } } else if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET square_refund_id = $1 WHERE id = $2`, refundResult.ID, refundID); upErr != nil { slog.Error("CRITICAL: Square refund PENDING but gift card %s refund row could not record square_refund_id — row left pending", "gift_card_id", code, "refund_id", refundID, "err", upErr) } if cerr := rtx.Commit(ctx); cerr != nil { slog.Error("CRITICAL: Square refund accepted but gift card %s reversal commit failed — refund row left pending, card left live", "gift_card_id", code, "refund_id", refundID, "err", cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } message := "Gift card cancelled and the full amount refunded to your original payment method." if partialRefund { message = fmt.Sprintf("Gift card cancelled. £%.2f (the unspent portion) was refunded to your original payment method; the £%.2f already spent on salon services is not refundable.", refundAmount, spentAtTill) } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "message": message, "refund_id": refundID, "amount_refunded": refundAmount, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return case errors.Is(sqErr, square.ErrRefundAlreadyProcessed): // PAYMENT_ALREADY_REFUNDED — the money already moved at Square (a // prior same-key attempt of this flow refunded it). The card was // re-verified above and still holds its full value: reverse the // funding and resolve the row 'completed' in the SAME transaction // before returning success. if cerr := cancelGiftCardFunding(ctx, rtx, code, userID, refundID, refundAmount); cerr != nil { slog.Error("CRITICAL: Square refund already processed but gift card %s funding reversal failed — manual reconciliation required", "gift_card_id", code, "err", cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if _, upErr := rtx.Exec(ctx, `UPDATE refunds SET status = 'completed' WHERE id = $1`, refundID); upErr != nil { slog.Error("CRITICAL: Square refund already processed but gift card %s refund row could not be resolved — manual reconciliation required", "gift_card_id", code, "err", upErr) http.Error(w, "internal server error", http.StatusInternalServerError) return } if cerr := rtx.Commit(ctx); cerr != nil { slog.Error("CRITICAL: Square refund already processed but gift card %s reversal commit failed — manual reconciliation required", "gift_card_id", code, "err", cerr) http.Error(w, "internal server error", http.StatusInternalServerError) return } log.Printf("Gift-card purchase %s already refunded at Square — refund %s marked completed, card %s cancelled", paymentID, refundID, code) w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "status": "success", "message": "This gift card had already been refunded and has now been cancelled.", "refund_id": refundID, "amount_refunded": refundAmount, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } return case errors.Is(sqErr, square.ErrRefundDeclined): // Definitive rejection — the money will never move. Mark the refund // failed so it is never retried and never blocks the amount, and DO // NOT cancel the card (the customer keeps it — the reversal tx has no // writes and rolls back below). if rbErr := rtx.Rollback(ctx); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { log.Printf("Failed to rollback gift-card reversal transaction before marking refund failed: %v", rbErr) } resolveRefundRow("failed", "") log.Printf("Gift-card purchase refund %s definitively declined by Square: %v", refundID, sqErr) http.Error(w, "Refund failed", http.StatusInternalServerError) return default: // Ambiguous — Square may or may not have processed the refund. The // reversal tx is rolled back (no writes) so the card stays live, the // refunds row stays 'pending' for a same-key retry (Square dedups), // and the gift card is NOT cancelled, per the failure-safety // requirement. log.Printf("Gift-card refund %s left pending after ambiguous Square error: %v", refundID, sqErr) http.Error(w, "Refund failed — please try again", http.StatusInternalServerError) return } } // reconcileAllRefundsAtSquare lists EVERY refund Square has recorded for the // payment (pending AND completed, unfiltered by amount) so the gift-card cancel // resume can see an in-flight (PENDING) prior attempt that // reconcileRefundAtSquareExact — which only matches an exact-amount COMPLETED // refund — would miss. Missing it is the double-refund bug: the resume would // re-issue under a fresh amount-derived key while the prior refund is still in // flight, minting a SECOND Square refund that lands on top of the first. The // returned slice lets the caller classify refunds by Status (PENDING blocks a // re-issue; COMPLETED/APPROVED sum to the already-refunded money; // FAILED/REJECTED/CANCELED never moved money and count as zero). A non-nil // error means Square state is unknown and no re-issue decision may be made. func reconcileAllRefundsAtSquare(ctx context.Context, chargeID string) ([]square.RefundResult, error) { refunds, err := SquareClient.ListPaymentRefunds(ctx, chargeID, time.Time{}) if err != nil { log.Printf("Failed to reconcile refunds for charge %s against Square: %v", chargeID, err) return nil, err } ours := make([]square.RefundResult, 0, len(refunds)) for _, r := range refunds { if r.PaymentID == chargeID { ours = append(ours, r) } } return ours, nil } // giftCardSpendAtTill returns the total value of completed payments made // against the gift card at the till. Till spend (handlers.go) decrements // amount_remaining and records a payments row with payment_method='giftcard', // status='completed' and gift_card_id = the card's code; transfers and // top-ups deliberately do NOT create such rows. A shortfall between // total_funds_added and amount_remaining that is not explained by this sum // therefore signals an unverifiable alteration (a transfer, clawback, or // partial refund), in which case the 14-day cancellation right cannot be // exercised on the balance (reg 34(9) permits deducting only the value // genuinely consumed on services and refunding the unspent remainder). func giftCardSpendAtTill(ctx context.Context, q db.Querier, code string) (float64, error) { var spentAtTill float64 err := q.QueryRow(ctx, ` SELECT COALESCE(SUM(amount), 0) FROM payments WHERE gift_card_id = $1 AND status = 'completed'`, code).Scan(&spentAtTill) if err != nil { return 0, err } return spentAtTill, nil } // findGiftCardPurchasePayment resolves the originating payments row for an // online gift-card purchase (BuyGiftCard): booking_id IS NULL (the same // discriminator RefundPayment and the stale-pending sweep use to identify // gift-card purchases), created_by = the buyer, payment_method='online_square', // status='completed' with a square_payment_id. When the client supplies the // payment id it is verified against the same invariants instead of being // matched by amount/timing. The payment row is created just before the Square // charge and the gift card a moment later, so the auto-match uses a 15-minute // window that ends at the purchase transaction's timestamp. func findGiftCardPurchasePayment(ctx context.Context, q db.Querier, userID, suppliedPaymentID string, purchaseAmount float64, purchasedAt time.Time) (paymentID, squarePaymentID string, ok bool, err error) { if suppliedPaymentID != "" { var bookingID sql.NullString var createdBy, status, method string var sqID sql.NullString var amount float64 err := q.QueryRow(ctx, ` SELECT id, booking_id, created_by, status, payment_method, square_payment_id, amount FROM payments WHERE id = $1`, suppliedPaymentID). Scan(&paymentID, &bookingID, &createdBy, &status, &method, &sqID, &amount) if errors.Is(err, pgx.ErrNoRows) { return "", "", false, nil } if err != nil { return "", "", false, err } if bookingID.Valid || createdBy != userID || status != "completed" || method != "online_square" { return "", "", false, nil } if !sqID.Valid || !approxEqual(amount, purchaseAmount) { return "", "", false, nil } return paymentID, sqID.String, true, nil } err = q.QueryRow(ctx, ` SELECT id, square_payment_id FROM payments WHERE booking_id IS NULL AND created_by = $1 AND payment_method = 'online_square' AND status = 'completed' AND square_payment_id IS NOT NULL AND ABS(amount - $2) < 0.005 AND created_at BETWEEN $3::timestamptz - INTERVAL '15 minutes' AND $3::timestamptz + INTERVAL '15 minutes' ORDER BY created_at DESC LIMIT 1`, userID, purchaseAmount, purchasedAt). Scan(&paymentID, &squarePaymentID) if errors.Is(err, pgx.ErrNoRows) { return "", "", false, nil } if err != nil { return "", "", false, err } return paymentID, squarePaymentID, true, nil } // cancelGiftCardFunding reverses an online-purchased gift card after its // refund has been accepted: the balance is zeroed and the card expired so it // can never be redeemed or spent, and a 'cancelled' gift_card_transactions row // records the reversal against the refund. Runs inside the caller's // transaction. func cancelGiftCardFunding(ctx context.Context, tx pgx.Tx, code, userID, refundID string, amount float64) error { if _, err := tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = 0, expiry_date = NOW(), last_used_at = NOW() WHERE id = $1`, code); err != nil { return fmt.Errorf("failed to zero gift card %s: %w", code, err) } if _, err := tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) VALUES ($1, 'cancelled', $2, 'refund', $3, $4, '14-day cooling-off cancellation — refunded to original payment method') `, code, amount, refundID, userID); err != nil { return fmt.Errorf("failed to record gift card cancellation transaction for %s: %w", code, err) } return nil } // approxEqual reports whether two currency amounts are equal to the nearest // penny (float64 scans of NUMERIC can carry tiny representation error). func approxEqual(a, b float64) bool { return math.Abs(a-b) < 0.005 }