Files
Crussell/backend/handlers/payments/giftcard_limits.go
T
popertots 36887167c6 fix: loop-A fresh review (503c326 baseline) — overflow-guard bypass, discounted-deposit retry, GDPR audit scrub, till cap, sweep rescue, 2FA reissue + SCA retry, consolidation round
Loop A fresh money/security/dup-mod review of the whole payments overhaul. 28 consolidated findings fixed:

MONEY:
- HIGH-1: B12 overflow guard now uses the discounted obligation — a pre-start deposit can never mint an unintended tip; the discount is never truncated to £0 when the customer pays the discounted deposit
- HIGH-2: discounted-deposit pending-reuse retry compares pendingStoredAmountPence vs chargeAmount (the actual Square amount), not req.Amount — no more permanent amount_mismatch 400 on lost-response retries
- MEDIUM-3: sweep rescue now carves overflow as a tip record + runs completion side-effects (was booking overflow as service revenue, skipping completion)
- MEDIUM-4 (shared w/ security): admin_audit_log.admin_id made nullable + anonymize_user/delete_guest_user NULL it + scrub details.card_last4 — 2fa_fallback_charge PII no longer survives account deletion
- MEDIUM-5: till gift-card payment now passes the £5,000/day admin cap (giftcard_limits)
- LOW-6: expired gift-card balance surfaced as expired/zero in GetUserGiftCardBalance

SECURITY:
- 2FA single-use consume made atomic at verify time for all 5 saved-card gates (fresh charges consume; pending-reuse retries don't); deferred consumption removed
- reissueTwoFACodeAfterFailedCharge routed through the fail-closed issuance gate (pepper check, cooldown) + fresh-only semantics (only when a code was actually consumed)
- family-alive cache invalidated on the stale-family cleanup DELETE (no 30s warm window after expiry)
- frontend 503-retry no longer reuses a consumed 2FA code — aligns with backend re-issue

DUP/MOD:
- reissue helper single-sourced (5 call sites), squareRefundStatusToLocal (10 inline switches), writeChargeSnapshot (7 sites, immutability guard on gift-card/till), postChargeRecheck (3+1 sites), scanIdempotencySlot (2), applyVATToChargeRecord (3 patterns), user_saved_cards upsert (2), BuyGiftCard pending INSERT via service
- till completed-dedup now re-validates paymentHasLiveRefund (aligns with booking/tip/gift-card)
- frontend 402 idempotency-key regeneration added to PaymentModal (aligns with other CIT surfaces)
- PAYMENT_METHOD_SAVED_CARD constant standardised ('saved_card' everywhere)
- admin audit coverage added for AdminRefundBooking + gift-card buy/top-up
- audit-helper cross-package dedup (user/twofa.go now calls payments' exported insert)

Verified: 26/26 dev + 24/24 prod packages, both vet tags, frontend tests + build, gitleaks clean.
2026-08-22 00:34:50 +01:00

144 lines
6.2 KiB
Go

package payments
import (
"context"
"crussell/db"
)
// Gift-card purchase/transaction limits (owner decisions).
//
// - Every admin gift-card value operation (CreateGiftCard, TopUpGiftCard,
// TransferGiftCard) is capped at £250 per transaction — tighter than the
// £10,000 ceiling ValidateAmount enforces on other payment entry points.
// - A customer (BuyGiftCard) may buy at most £500 of online gift cards per
// UTC day.
// - An admin may create/top-up/transfer at most £5,000 of gift-card value
// per UTC day.
//
// till.go uses the same £250 transaction cap (maxAdminGiftCardTransactionPence)
// for its gift-card creates/topups — this shared constant is the single source
// of the owner decision.
const (
// maxAdminGiftCardTransactionPence caps a single admin gift-card
// create/top-up/transfer at £250 (25,000 pence).
maxAdminGiftCardTransactionPence = 25_000
// maxUserGiftCardDailyPence caps one user's online gift-card purchases at
// £500 (50,000 pence) per UTC day.
maxUserGiftCardDailyPence = 500_00
// maxAdminGiftCardDailyPence caps the gift-card value an admin can
// create/top-up/transfer in one UTC day at £5,000 (500,000 pence).
maxAdminGiftCardDailyPence = 500_000
)
// userGiftCardSpentToday returns the total value (in pounds) the user has
// spent on ONLINE gift-card purchases so far today, returned as a float64 so
// the caller can convert to pence with math.Round, matching the repo's
// currency convention.
//
// Signal: gift_card_transactions rows written by BuyGiftCard — the ONLY
// customer-facing online purchase path. Every BuyGiftCard purchase (self and
// friend) inserts a row with transaction_type='purchase', reference_type='api'
// and user_id = the buyer (see giftcards.go). Admin-created cards
// (CreateGiftCard/TopUpGiftCard) also write reference_type='api' but with the
// ADMIN's user id, and till sales write reference_type='till_sale', so neither
// can match a customer. The payments-based alternative (payments rows with
// payment_type='gift_card') does NOT exist in this schema — the payment_type
// enum is ('deposit','full','tip','balance','partial') and BuyGiftCard writes
// payment_type='full' — so the transactions audit log is the correct signal.
//
// "Today" is the UTC day boundary (created_at >= CURRENT_DATE), matching the
// repo's existing time convention: the DB session runs in timezone=UTC and
// completion.go uses the same CURRENT_DATE boundary for its daily loyalty
// stamp cap.
func userGiftCardSpentToday(ctx context.Context, q db.Querier, userID string) (float64, error) {
var spent float64
err := q.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0)
FROM gift_card_transactions
WHERE user_id = $1
AND transaction_type = 'purchase'
AND reference_type = 'api'
AND created_at >= CURRENT_DATE
`, userID).Scan(&spent)
if err != nil {
return 0, err
}
return spent, nil
}
// adminGiftCardValueToday returns the total gift-card value (in pounds) the
// admin has created, topped up, transferred, or issued via the till today (UTC
// day boundary, created_at >= CURRENT_DATE), returned as a float64 for pence
// conversion. This is the SINGLE daily-cap signal shared by the admin API
// surface (CreateGiftCard / TopUpGiftCard / TransferGiftCard) AND the till
// (CreateTillSale) — an admin surface that otherwise could issue unlimited
// balance (MEDIUM-5).
//
// Signal (chosen to be double-count free across the admin operations):
//
// 1. Cards the admin created today — SUM(total_funds_added). total_funds_added
// is cumulative, so a card created today already reflects any same-day
// top-up or transfer INTO it, and its creation amount. This covers cards
// created through BOTH the admin API and the till (a till create inserts
// the card with created_by = the admin).
// 2. API top-ups executed by this admin today on cards created BEFORE today
// (cards created today are excluded — term 1 already includes their
// funding via total_funds_added, so counting the top-up row again would
// double-count). This is the gift_card_transactions rows
// (reference_type='api', user_id=admin) written by CreateGiftCard
// ('purchase') and TopUpGiftCard ('topup', or 'purchase' on an inventory
// card's first top-up).
// 3. Till sales executed by this admin today on cards created BEFORE today —
// till_sales rows (created_by = admin, status completed/pending — a
// pending sale's card was already funded before the Square call). Cards
// created today are excluded exactly like term 2, so a till-created card
// is counted once via term 1's total_funds_added and a till top-up on an
// older card is counted once here. A till sale's gift_card_transactions
// row is attributed to the CUSTOMER (reference_type='till_sale'), so it
// never enters term 2.
//
// Transfers INTO pre-existing cards leave no attributable audit row
// (TransferGiftCard deliberately writes no gift_card_transactions entry), so
// they are not directly counted; a transfer also creates no NEW gift-card
// liability, so the daily cap still measures all value this admin has newly
// issued today.
func adminGiftCardValueToday(ctx context.Context, q db.Querier, adminID string) (float64, error) {
var value float64
err := q.QueryRow(ctx, `
SELECT
COALESCE((
SELECT SUM(gc.total_funds_added)
FROM gift_cards gc
WHERE gc.created_by = $1 AND gc.created_at >= CURRENT_DATE
), 0)
+ COALESCE((
SELECT SUM(gct.amount)
FROM gift_card_transactions gct
WHERE gct.user_id = $1
AND gct.reference_type = 'api'
AND gct.transaction_type IN ('purchase', 'topup')
AND gct.created_at >= CURRENT_DATE
AND gct.gift_card_id NOT IN (
SELECT gc2.id FROM gift_cards gc2
WHERE gc2.created_by = $1 AND gc2.created_at >= CURRENT_DATE
)
), 0)
+ COALESCE((
SELECT SUM(ts.total_amount)
FROM till_sales ts
WHERE ts.created_by = $1
AND ts.status IN ('completed', 'pending')
AND ts.created_at >= CURRENT_DATE
AND ts.item_id NOT IN (
SELECT gc3.id FROM gift_cards gc3
WHERE gc3.created_by = $1 AND gc3.created_at >= CURRENT_DATE
)
), 0)
`, adminID).Scan(&value)
if err != nil {
return 0, err
}
return value, nil
}