fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub

Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.

Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
  square_request_snapshot, so a retained idempotency key returns the original
  payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
  forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
  sources are charged and rescued; spent cnon: nonces surface
  ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
  claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
  'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
  booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].

2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
  all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
  admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
  mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
  plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.

Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
  redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
  separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
  square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
  policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
  packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 4b28e93710
commit e9b0f0f2a7
50 changed files with 4223 additions and 413 deletions
+78 -21
View File
@@ -6,6 +6,7 @@ import (
"context"
"crussell/clock"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log"
@@ -29,9 +30,11 @@ func mockSleep(d time.Duration) {
type MockClient struct {
mu sync.RWMutex
cards map[string]map[string]*CardOnFile
cardByToken map[string]*CardOnFile // ccof: token (CardOnFile.CardID) → the saved card, for replay-by-key rescue
checkouts map[string]*CheckoutResult
payments map[string]*PaymentResult
paymentByKey map[string]*PaymentResult
paymentSource map[string]string // idempotency key → the source_id the original CreatePayment used
refunds map[string]*RefundResult
refundByKey map[string]*RefundResult
customers map[string]*CustomerResult
@@ -76,8 +79,8 @@ func (d *devProdClient) GetCheckout(ctx context.Context, checkoutID string) (*Pa
func (d *devProdClient) GetPayment(ctx context.Context, paymentID string) (*PaymentResult, error) {
return getPaymentHTTP(ctx, paymentID)
}
func (d *devProdClient) ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
return replayPaymentByKeyHTTP(ctx, idempotencyKey, amount)
func (d *devProdClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
return replayPaymentByKeyHTTP(ctx, snapshotJSON)
}
func (d *devProdClient) CreateCustomer(ctx context.Context, name, email string) (*CustomerResult, error) {
return createCustomerHTTP(ctx, name, email)
@@ -117,14 +120,16 @@ func NewDevClient() SquareClient {
}
log.Println("[SQUARE-MOCK] Using in-memory mock client")
return &MockClient{
cards: make(map[string]map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
cards: make(map[string]map[string]*CardOnFile),
cardByToken: make(map[string]*CardOnFile),
checkouts: make(map[string]*CheckoutResult),
payments: make(map[string]*PaymentResult),
paymentByKey: make(map[string]*PaymentResult),
paymentSource: make(map[string]string),
refunds: make(map[string]*RefundResult),
refundByKey: make(map[string]*RefundResult),
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
}
}
@@ -256,6 +261,7 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
m.payments[result.SquarePayID] = result
if req.IdempotencyKey != "" {
m.paymentByKey[req.IdempotencyKey] = result
m.paymentSource[req.IdempotencyKey] = req.SourceID
}
log.Printf("[SQUARE-MOCK] Payment created: id=%s, status=%s, amount=%d, fees=%d", paymentID, status, amount, fees)
return result, nil
@@ -404,22 +410,72 @@ func (m *MockClient) GetPayment(ctx context.Context, paymentID string) (*Payment
return payment, nil
}
// ReplayPaymentByKey mirrors the real client's replay-by-key reconcile
// (POST /v2/payments with the same idempotency key): the dedup map returns the
// ORIGINAL payment for a retained key — never a second charge — and an unknown
// key is rejected with ErrReplayKeyNotRetained, exactly as the real client
// rejects the synthetic probe source token it sends for an unknown key.
func (m *MockClient) ReplayPaymentByKey(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey: key=%s", idempotencyKey)
// ReplayPaymentByKey mirrors the real client's IDENTICAL-body replay-by-key
// reconcile (POST /v2/payments with the full stored request snapshot): a
// retained key with the matching stored source returns the ORIGINAL payment
// (never a second charge); a retained key with a DIFFERENT source returns a
// structured 400 IDEMPOTENCY_KEY_REUSED — exactly what Square returns when an
// idempotency key is reused with a different request body (the stored source
// must never differ from the original, so the sweep treats it as ambiguous);
// an unknown key makes Square attempt a real charge with the stored source: a
// still-valid ccof: saved-card token CHARGES successfully (returning a new
// COMPLETED payment the sweep rescues), while a spent/expired cnon: nonce is
// rejected with a 4xx — surfaced as ErrReplayKeyNotRetained.
func (m *MockClient) ReplayPaymentByKey(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
var req CreatePaymentReq
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err)
}
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey: key=%s, source=%s", req.IdempotencyKey, tokenPrefix(req.SourceID))
m.mu.RLock()
defer m.mu.RUnlock()
existing, ok := m.paymentByKey[req.IdempotencyKey]
storedSource := m.paymentSource[req.IdempotencyKey]
m.mu.RUnlock()
if existing, ok := m.paymentByKey[idempotencyKey]; ok {
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", idempotencyKey, existing.ID)
if ok {
if storedSource != "" && storedSource != req.SourceID {
// Same key, different body — Square's documented IDEMPOTENCY_KEY_REUSED
// rejection. A data bug (the stored source differs from the original
// charge), NOT proof the charge never happened.
return nil, &squareAPIError{
Code: "IDEMPOTENCY_KEY_REUSED",
Detail: "idempotency key was reused with a different request body",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: idempotency key %s reused with a different source_id", req.IdempotencyKey),
}
}
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey dedup hit: key=%s → id=%s", req.IdempotencyKey, existing.ID)
return existing, nil
}
return nil, fmt.Errorf("%w: Square has no payment under idempotency key", ErrReplayKeyNotRetained)
// Unknown key — mirror real Square: it attempts a real charge with the
// stored source. A still-valid ccof: saved-card token charges successfully
// (the sweep then rescues the row); a spent/expired cnon: nonce (or any
// unchargeable source) is rejected with a definitive 4xx.
if strings.HasPrefix(req.SourceID, "ccof:") {
m.mu.RLock()
_, cardOK := m.cardByToken[req.SourceID]
m.mu.RUnlock()
if !cardOK {
// The saved card is not in the mock ledger — mirror real Square
// rejecting a deleted/disabled card with a definitive 4xx.
return nil, fmt.Errorf("%w: Square has no saved card %s to charge", ErrReplayKeyNotRetained, tokenPrefix(req.SourceID))
}
if req.Currency == "" {
req.Currency = gbpCurrency
}
pr, err := m.CreatePayment(ctx, req)
if err != nil {
if replayErrorProvesNoCharge(err) {
return nil, fmt.Errorf("%w: %v", ErrReplayKeyNotRetained, err)
}
return nil, err
}
log.Printf("[SQUARE-MOCK] ReplayPaymentByKey charged saved card for unknown key: key=%s → id=%s", req.IdempotencyKey, pr.ID)
return pr, nil
}
return nil, fmt.Errorf("%w: Square has no payment under idempotency key (HTTP 400: source rejected)", ErrReplayKeyNotRetained)
}
func (m *MockClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) {
@@ -568,6 +624,7 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu
CreatedAt: now.Format(time.RFC3339),
}
m.cards[userID][cardID] = card
m.cardByToken[card.CardID] = card
log.Printf("[SQUARE-MOCK] Card created: id=%s, brand=%s, last4=%s", cardID, card.Brand, card.Last4)
return card, nil
}