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.
121 lines
5.1 KiB
Go
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
|
|
}
|