Files
Crussell/backend/handlers/payments/giftcards.go
T
popertots e9b0f0f2a7 fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub
Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.

Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
  square_request_snapshot, so a retained idempotency key returns the original
  payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
  forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
  sources are charged and rescued; spent cnon: nonces surface
  ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
  claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
  'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
  booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].

2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
  all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
  admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
  mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
  plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.

Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
  redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
  separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
  square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
  policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
  packages green, 2,142 tests, svelte-check clean.
2026-08-22 00:34:49 +01:00

1554 lines
52 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"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"math"
"net/http"
"strconv"
"strings"
"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"`
}
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"`
}
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, created_at, is_inventory
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()
for gcRows.Next() {
var gc GiftCard
err = gcRows.Scan(
&gc.ID,
&gc.TotalFundsAdded,
&gc.AmountRemaining,
&gc.CreatedAt,
&gc.IsInventory,
)
if err != nil {
log.Printf("Failed to scan gift card: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
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
}
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"
}
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
}
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()
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
}
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 ---
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
}
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) {
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
}
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. 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.
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// M1: generate a deterministic idempotency key server-side if the client
// doesn't provide one. The key is based on user_id + amount + recipient_type
// + card_id (or "new" for new cards), ensuring retries of the same logical
// purchase use the same key while distinct purchases get different keys.
if req.IdempotencyKey == "" {
cardPart := "new"
if req.CardID != nil && *req.CardID != "" {
cardPart = *req.CardID
}
// Fallback key: base + a fresh random suffix (mirrors the refunds.go
// randomHexSuffix pattern) so two identical no-client-key purchases can
// never collapse onto one dedup key — the old deterministic fallback
// silently returned the first card's code for the second purchase.
// Clients who need retry-dedup supply their own idempotency_key; that
// path is unchanged.
base := fmt.Sprintf("gc-%s-%d-%s-%s", userID, req.Amount, req.RecipientType, cardPart)
req.IdempotencyKey = base + "-" + randomHexSuffix(6)
if len(req.IdempotencyKey) > 45 {
// Hash long keys to fit Square's 45-char limit
hash := sha256.Sum256([]byte(req.IdempotencyKey))
req.IdempotencyKey = fmt.Sprintf("gc-%x", hash[:16])
}
}
// 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 lockKey == "" {
lockKey = userID
}
pinConn, lockOK := acquireBookingPaymentLock(ctx, w, "crussell:giftcard:"+lockKey, "Purchase in progress, try again")
if !lockOK {
return
}
defer releaseBookingPaymentLock(pinConn, "crussell:giftcard:"+lockKey)
// 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)
}
if existing != nil {
if existing.Status == "completed" {
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
}
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
}
}
}
// 2FA gating (C5): charging a SAVED card requires 2FA when the feature is
// enforced. New-card (nonce) charges are not gated.
if req.CardID != nil && *req.CardID != "" {
if !requireTwoFactorForCardAccess(w, r, paymentService, userID) {
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.
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 && vatCfg.IsVATRegistered && vatCfg.VoucherType == "SPV" {
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,
}
// 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.
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 _, sErr := db.Conn.Exec(ctx, `UPDATE payments SET square_request_snapshot = $1 WHERE id = $2`, string(snap), 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"
}
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"
}
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)
}
}
// --- 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)
}
}