Files
Crussell/backend/handlers/payments/giftcard_limits.go
T
popertots 6d82535780 fix: adversarial review round — replay-rescue double-charge, discount credit, 2FA/per-IP limits, snapshot encryption, refund reconciliation, VAT, frontend parity, tests+docs
Addresses the adversarial fresh-eyes audit (findings A1-A20) plus review-round fixes:
- CRITICAL A1: replay-by-key rescue cross-checks replayed CreatedAt; ccof blind-fail leaves pending with CRITICAL + notification instead of clawing back
- A2/A3/A4: till idempotency key restored to unconditional hash; tip rejected in CreateBookingPayment; campaign discount now reduces the charged amount (deposit credit)
- A5: admin notifications on blind-fail, manual-refund re-arm, cap-stranded charge-group, webhook FAILED/REJECTED refunds
- A6/A10: BuyGiftCard idempotency user-scoped; gift-card slot scan advances past failed rows
- A7/A14/A15: 2FA user+IP limiter, SNAPSHOT_ENC_KEY startup validation, accurate pepper/log-delivery docs
- A8/A9: snapshot encryption on all write+reuse sites; MPV->SPV effective voucher type (single VAT point)
- A11/A12/A13/A16: amount-aware refund reconciliation; completed-booking refund re-validation; till retry dedup; PaymentWasRefunded on SquareClient interface
- A17/A18/A19/A20: CI runs npm test; confirm_overflow_tip frontend dialog; unknown-event admin notification; mock token redaction
- M7 ConfirmOverflowTip, M9 snapshot encryption, C1 discount ordering regression test
- Frontend vitest framework (41 tests), backend coverage for fixed functions, docs corrected (2,269 tests, SUPPORT_EMAIL tokens, resolution status)

All 25 backend packages pass; frontend 41/41; build + env-docs green.
2026-08-22 00:34:50 +01:00

121 lines
5.1 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, or transferred today (UTC day boundary,
// created_at >= CURRENT_DATE), returned as a float64 for pence conversion.
//
// Signal (chosen to be double-count free across the three 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.
// 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).
//
// 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. Till sales (CreateTillSale) write reference_type='till_sale'
// and are attributed to the CUSTOMER (user_id), so they are excluded here —
// the admin daily cap covers the admin API surface only.
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)
`, adminID).Scan(&value)
if err != nil {
return 0, err
}
return value, nil
}