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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user