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.
144 lines
6.2 KiB
Go
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
|
|
}
|