Fix payment review round 3: saved-card idempotency, stale-pending sweep, webhook fail-closed

R1/R4: saved_card branch in CreateTerminalPayment now mirrors CreateTipPayment
- advisory lock (crussell:payment:<bookingID>) serializes concurrent double-clicks
- deterministic key bookingID-sc-type-amount-cardID (<=45 chars) so a lost-response
  retry derives the same key and dedups instead of double-charging
- idempotency switch inside the lock: completed -> dedup, pending -> reuse with
  pence amount-guard, failed -> clean 409
- success response includes card_brand/card_last4 (frontend already reads them)

R2: add 'failed' case to all four retry switches (tip, booking, gift card, till)
- a swept/definitively-rejected record returns 409 instead of 500-ing on the
  idempotency_key UNIQUE constraint

R3: extend SweepStalePendingPayments to till_sales card rows
- sweeps pending till_sales (online_square/in_person_card) past Square's ~24h
  key retention, closing the double-charge window for till sales
- swept rows logged with the same CRITICAL manual-reconciliation marker as the
  refund sweep

Webhook fail-closed: reject 503 when SQUARE_WEBHOOK_SIGNATURE_KEY unset, 403 on
bad signature (was: skip verification in dev)

Refund status resolution: refunds now resolve by Square status
(COMPLETED/PENDING/FAILED/REJECTED) instead of assuming completed; real error
codes (REFUND_AMOUNT_INVALID, PAYMENT_NOT_REFUNDABLE, REFUND_ALREADY_PENDING)
added to the definitive/processed classification

HTTP client: CreateCard key truncated to <=45 chars, device_options always sent
(env SQUARE_TERMINAL_DEVICE_ID fallback), processing_fee reads amount_money,
ListCards cursor loop, refund keys hashed to <=45 chars

Other fixes: payment/till/gift-card advisory-lock + FOR UPDATE asymmetries,
GetPaymentByID NULL scans, loyalty redemption lock, card upsert on conflict,
mock ccof: prefix parity, IsValidSquareCheckoutID for real Square IDs,
isAdminRequest defense-in-depth on all 6 admin payment handlers, webhook
signature docs, M8/L5 debug markers removed

Docs: README/FC/TM/Overview updated (22 jobs, 20 CRITICAL sites, 23-section
GDPR export, sweep jobs, webhook fail-closed); P11 plan marks remaining items
(sandbox smoke test, M-8 customer_id, saved-card key dedup trade-off) as
deferred with rationale; gap backlog pruned of completed items
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent dcc70df75a
commit 7439fa86c1
30 changed files with 1239 additions and 184 deletions
+15 -3
View File
@@ -108,7 +108,16 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
if m.ShouldFail {
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
}
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s, source=%s", req.Amount, req.ReferenceID, req.SourceID)
// Do NOT log the full source token — it is a single-use nonce (cnon:) or a
// card reference (ccof:) that could be replayed. Log only its prefix and
// length for debugging (S-2).
sourcePrefix := ""
if len(req.SourceID) > 8 {
sourcePrefix = req.SourceID[:8] + "..."
} else {
sourcePrefix = req.SourceID
}
log.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, reference=%s, source=%s", req.Amount, req.ReferenceID, sourcePrefix)
mockSleep(1 * time.Second)
m.mu.Lock()
@@ -199,7 +208,6 @@ func (m *MockClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq)
Status: "PENDING",
AmountMoney: req.Amount,
Currency: req.Currency,
DeviceID: req.DeviceID,
ReferenceID: req.ReferenceID,
Note: req.Note,
CreatedAt: now.Format(time.RFC3339),
@@ -383,7 +391,11 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken str
brand, last4 := detectCardInfo(cardToken)
card := &CardOnFile{
ID: cardID,
CardID: fmt.Sprintf("ccof_mock_%d", now.UnixNano()),
// Prefix "ccof:" so the mock's own entry-method detection (and any
// consumer checking the prefix) sees ON_FILE, matching production where
// saved-card tokens are "ccof:xxx". An "ccof_mock_" id would silently
// exercise the KEYED path in tests while prod runs ON_FILE.
CardID: fmt.Sprintf("ccof:mock_%d", now.UnixNano()),
Brand: brand,
Last4: last4,
ExpMonth: 12,