Files
Crussell/backend/handlers/payments/giftcards.go
T
popertots fe88f2084d fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

2969 lines
118 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/validators"
"crussell/mw"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
// --- 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"`
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"`
// VerificationCode is the customer's current 2FA one-time code (B10): an
// enforced environment charges/persists a saved card only when this matches
// the customer's pending code.
VerificationCode string `json:"verification_code,omitempty"`
}
type RedeemGiftCardRequest struct {
Code string `json:"code"`
}
// --- Admin Handlers ---
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()
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.
if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence {
http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest)
return
}
// Daily limit (owner decision): an admin may create/top-up/transfer at
// most £5,000 of gift-card value per UTC day. Enforced before any write.
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))+int64(math.Round(req.Amount*100)) > 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
}
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()
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).
if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence {
http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest)
return
}
// Daily limit (owner decision): an admin may create/top-up/transfer at
// most £5,000 of gift-card value per UTC day. Enforced before any write.
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))+int64(math.Round(req.Amount*100)) > 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
err = tx.QueryRow(ctx, "SELECT redeemed_by, is_inventory, total_funds_added FROM gift_cards WHERE id = $1 FOR UPDATE", cardID).Scan(&redeemedBy, &isInventory, &currentTotalFunds)
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
}
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
}
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()
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).
if int64(math.Round(req.Amount*100)) > maxAdminGiftCardTransactionPence {
http.Error(w, "Amount exceeds the maximum of £250", http.StatusBadRequest)
return
}
// Daily limit (owner decision): an admin may create/top-up/transfer at
// most £5,000 of gift-card value per UTC day. Enforced before any write
// (and before the advisory lock acquisition below).
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))+int64(math.Round(req.Amount*100)) > 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
}
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
err = tx.QueryRow(ctx, `
SELECT amount_remaining, redeemed_by
FROM gift_cards
WHERE id = $1
FOR UPDATE
`, code).Scan(&amountRemaining, &redeemedBy)
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
}
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]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
}
if err := json.NewEncoder(w).Encode(map[string]float64{"balance": balance}); 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
}
detailsJSON := fmt.Sprintf(`{"balance": %.2f}`, balance)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
} else {
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
if _, err := tx.Exec(ctx, `
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
VALUES ($1, 'balance_check', $2, $3::jsonb)
`, adminID, userID, detailsJSON); err != nil {
log.Printf("Failed to record admin_audit_log (non-critical): %v", err)
}
if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction: %v", err)
}
}
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.
clientSuppliedKey := req.IdempotencyKey != ""
var noKeyCardPart string
if !clientSuppliedKey {
noKeyCardPart = "new"
if req.CardID != nil && *req.CardID != "" {
noKeyCardPart = *req.CardID
}
}
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.NewCardToken); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", 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-<userID>-<amount>-<type>-<cardPart>" 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.
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.
if (req.CardID != nil && *req.CardID != "") || req.SaveCard {
if !requireTwoFactorForCardAccess(w, r, paymentService, userID, req.VerificationCode) {
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).
sourceID, savedCardID, savedCardCustomerID, sourceOK := resolveChargeSource(ctx, w, paymentService, userID, req.NewCardToken, req.CardID, 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,
CreatedAt: clock.Now(),
UpdatedAt: clock.Now(),
CreatedBy: &userID,
}
err = tx.QueryRow(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, fees, user_saved_card_id, created_by, created_at, updated_at, square_source_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id
`, record.PaymentType, record.PaymentMethod, record.Status, record.Amount, record.SquarePaymentID, record.IdempotencyKey, record.Fees, record.UserSavedCardID, record.CreatedBy, record.CreatedAt, record.UpdatedAt, sourceID).Scan(&buyPaymentID)
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
vatCfg, vatErr := GetVATConfig(ctx, tx)
if vatErr == nil && vatAppliesToVoucher(vatCfg) {
if _, vatExecErr := tx.Exec(ctx, "SELECT apply_vat_to_payment($1, $2)", buyPaymentID, vatCfg.DefaultVATRate); vatExecErr != nil {
log.Printf("Failed to apply VAT to buy gift card payment %s: %v", buyPaymentID, vatExecErr)
}
}
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)
}
var verificationToken string
if req.VerificationToken != nil {
verificationToken = *req.VerificationToken
}
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: verificationToken,
}
// 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 req.CardID != nil || 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 (WHERE id = $2, no
// snapshot-is-null guard like the booking/tip/terminal flows): 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. Both paths converge on a
// correct snapshot, so a guard would either be dead (first attempt) or
// wrongly skip this write on the reuse path when the in-tx refresh failed
// best-effort. Do NOT "fix" this into the guarded form without reworking
// the reuse-branch refresh.
if snap, mErr := json.Marshal(paymentReq); mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for gift-card payment %s: %v", buyPaymentID, mErr)
} else if stored, eErr := encryptSnapshot(snap); eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for gift-card payment %s: %v", buyPaymentID, eErr)
} else if _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(stored), buyPaymentID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for gift-card payment %s: %v", buyPaymentID, sErr)
}
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.
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
}
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-<userID>-<amount>-<recipientType>-<cardPart>", 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 "-<seq>" 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)
for seq := 0; ; seq++ {
candidate := nextIdempotencyCandidate(baseKey, seq)
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 candidate, nil
}
if err != nil {
return "", err
}
}
}
// --- 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:]
}
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, &notes)
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 = &notes.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
}
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)
}
}
}
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 expiryDate.Valid && expiryDate.Time.Before(clock.Now()):
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)
if 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"
} else if !paymentOK {
st.CancellationReason = "The original purchase payment could not be found"
} else {
// 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
}
if expiryDate.Valid && expiryDate.Time.Before(clock.Now()) {
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 = 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":
// In-flight refund for this payment. Only resume if the pending
// row is OURS; a foreign pending row must not be re-issued.
if !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
refundKey = priorKey
refundAmount = priorAmount
resumingRefund = true
case "failed":
// Money provably never moved — safe to re-issue with the stored
// key (or persist the deterministic key when a legacy row has
// none, mirroring ensureRefundKey).
refundID = priorRefundID
refundAmount = priorAmount
resumingRefund = true
if priorKey != "" {
refundKey = priorKey
} else {
if _, uErr := tx.Exec(ctx, `
UPDATE refunds SET idempotency_key = $1
WHERE id = $2 AND idempotency_key IS NULL`, refundKey, refundID); uErr != nil {
log.Printf("Failed to persist refund idempotency key for %s: %v", refundID, uErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
}
}
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
}
if revIsInventory || revRedeemedBy.Valid || !approxEqual(revTotalFunds, purchaseAmount) || !approxEqual(revRemaining, expectRemaining) || (revExpiry.Valid && revExpiry.Time.Before(clock.Now())) {
// 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 := "completed"
if refundResult.Status == "PENDING" {
// 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.
status = "pending"
log.Printf("Square refund %s for gift-card purchase payment %s is PENDING — refund row left pending for reconciliation", refundResult.ID, paymentID)
} else if refundResult.Status == "FAILED" || refundResult.Status == "REJECTED" {
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
}
}
// 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
}