fix: loop-B adversarial (503c326 baseline) — IDEMPOTENCY_KEY_REUSED reclassified ambiguous, 2FA reissue fail-closed alerts, family-cache crash window, consolidation regression checks

Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

- CRITICAL-ish: IDEMPOTENCY_KEY_REUSED (409) no longer classified as a definitive
  402 in chargeFailureStatus — it means the ORIGINAL charge may have landed with
  a different body, so it is now AMBIGUOUS (503): the frontend keeps the same
  idempotency key, the pending row stays rescuable by the sweep (which already
  treated it as ambiguous), and the frontend no longer regenerates the key into
  a possible double charge. SCA verification-required codes remain definitive 402.
- HIGH: reissueTwoFACodeAfterFailedCharge now writes a CRITICAL admin notification
  (insertCriticalPaymentNotification) when issuance is refused (missing pepper /
  unavailable delivery) instead of silently stranding the customer; documented that
  a pepper CHANGE invalidates all pending codes.
- MEDIUM: family-alive cache invalidation crash window documented (invalidate-after-
  commit leaves up to 30s warm on a crash; the near-TTL DB re-check bounds it).
- Consolidation regression checks (8b2fe3b helpers): writeChargeSnapshot guard
  preserved at all sites, postChargeRecheck identical, squareRefundStatusToLocal
  mappings verified, reissue fresh-only semantics confirmed at all 5 call sites.

Verified: 26/26 dev packages, both vet tags, frontend tests + build, env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 36887167c6
commit dfe856b181
10 changed files with 219 additions and 38 deletions
@@ -203,6 +203,39 @@ func writeChargeSnapshot(ctx context.Context, q db.Querier, table, rowID string,
}
}
// writeChargeSnapshotUnconditional stores the verbatim request JSON on a
// payments/till_sales row WITHOUT the first-attempt immutability guard that
// writeChargeSnapshot applies. It is used ONLY by the gift-card (BuyGiftCard)
// and till-sale (CreateTillSale) charge sites — the two sites that were
// UNCONDITIONAL before the writeChargeSnapshot consolidation (Loop A, finding 1
// regression check 4a). Their pending-reuse branches deliberately refresh
// square_request_snapshot in the SAME transaction as the square_source_id
// refresh (the Go-reencrypt refresh in giftcards.go / refreshTillSnapshotSource
// in till.go, B6), and this post-commit write stores the FRESH full body for
// the CURRENT attempt. The guard would wrongly skip this write on the reuse
// path when the in-transaction refresh failed best-effort — the row would then
// keep the stale first-attempt body while the live square_source_id column
// already points at the new source (the pre-consolidation code explicitly
// warned against "fixing" these sites into the guarded form). The booking/tip/
// terminal saved-card paths keep the guarded writeChargeSnapshot: their reuse
// paths do NOT refresh the snapshot, so the guard correctly records the
// immutable first-attempt body.
func writeChargeSnapshotUnconditional(ctx context.Context, q db.Querier, table, rowID string, body any, label string) {
snap, mErr := json.Marshal(body)
if mErr != nil {
log.Printf("Failed to marshal square_request_snapshot for %s %s: %v", label, rowID, mErr)
return
}
stored, eErr := encryptSnapshot(snap)
if eErr != nil {
log.Printf("Failed to encrypt square_request_snapshot for %s %s: %v", label, rowID, eErr)
return
}
if _, sErr := q.Exec(ctx, `UPDATE `+table+` SET square_request_snapshot = $1 WHERE id = $2`, string(stored), rowID); sErr != nil {
log.Printf("Failed to store square_request_snapshot for %s %s: %v", label, rowID, sErr)
}
}
// recheckBookingPayable re-reads the booking status after a Square charge
// succeeded (R9): a concurrent cancellation/eviction can move the booking out
// of a payable state between the pre-charge status check and the charge