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.
This commit is contained in:
+20
-4
@@ -217,14 +217,30 @@ func familyAliveStore(key string, alive bool) {
|
||||
// (refresh-token reuse kill, logout) so bound access tokens die on their next
|
||||
// verification instead of riding the cache TTL (HIGH 1).
|
||||
func InvalidateFamilyAlive(familyID string) {
|
||||
if familyID == "" {
|
||||
InvalidateFamilyAliveBatch([]string{familyID})
|
||||
}
|
||||
|
||||
// InvalidateFamilyAliveBatch drops every cached family-alive verdict for a set
|
||||
// of families so the next VerifyToken re-queries the DB. Called wherever
|
||||
// rotation families are deleted — the reuse kill, logout, and the scheduled
|
||||
// cleanup of expired refresh tokens (handlers/scheduling/scheduled-cleanup.go)
|
||||
// — so access tokens bound to a family whose last member was deleted die on
|
||||
// their next verification instead of riding the familyAliveCacheTTL (LOW 6 /
|
||||
// finding 3). Empty and blank family ids are skipped.
|
||||
func InvalidateFamilyAliveBatch(familyIDs []string) {
|
||||
if len(familyIDs) == 0 {
|
||||
return
|
||||
}
|
||||
familyAliveCache.mu.Lock()
|
||||
defer familyAliveCache.mu.Unlock()
|
||||
for k := range familyAliveCache.m {
|
||||
if strings.HasPrefix(k, familyID+"|") {
|
||||
delete(familyAliveCache.m, k)
|
||||
for _, familyID := range familyIDs {
|
||||
if familyID == "" {
|
||||
continue
|
||||
}
|
||||
for k := range familyAliveCache.m {
|
||||
if strings.HasPrefix(k, familyID+"|") {
|
||||
delete(familyAliveCache.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,3 +899,36 @@ func TestInvalidateFamilyAliveByUser(t *testing.T) {
|
||||
// An empty user id is a no-op, never a panic.
|
||||
InvalidateFamilyAliveByUser("")
|
||||
}
|
||||
|
||||
// TestInvalidateFamilyAliveBatch pins the LOW finding-3 contract: the batch
|
||||
// form drops every cached verdict for the affected families (used by the
|
||||
// scheduled cleanup when expired refresh tokens kill whole families) while
|
||||
// leaving unrelated families untouched. Empty and blank ids are no-ops.
|
||||
func TestInvalidateFamilyAliveBatch(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
familyAliveCache.mu.Lock()
|
||||
familyAliveCache.m = make(map[string]familyAliveCacheEntry)
|
||||
familyAliveCache.mu.Unlock()
|
||||
})
|
||||
|
||||
familyAliveStore("family-a|user-1", true)
|
||||
familyAliveStore("family-b|user-1", true)
|
||||
familyAliveStore("family-c|user-2", true)
|
||||
familyAliveStore("family-a|user-2", true)
|
||||
|
||||
InvalidateFamilyAliveBatch([]string{"family-a", "family-b"})
|
||||
|
||||
_, okA1 := familyAliveLookup("family-a|user-1")
|
||||
_, okB1 := familyAliveLookup("family-b|user-1")
|
||||
_, okA2 := familyAliveLookup("family-a|user-2")
|
||||
_, okC2 := familyAliveLookup("family-c|user-2")
|
||||
require.False(t, okA1, "family-a's verdict for user-1 must be dropped")
|
||||
require.False(t, okB1, "family-b's verdict for user-1 must be dropped")
|
||||
require.False(t, okA2, "family-a's verdict for user-2 must be dropped")
|
||||
require.True(t, okC2, "family-c's verdict must survive")
|
||||
|
||||
// Empty, blank and nil inputs are no-ops, never a panic.
|
||||
InvalidateFamilyAliveBatch(nil)
|
||||
InvalidateFamilyAliveBatch([]string{""})
|
||||
InvalidateFamilyAlive("")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user