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
+161
View File
@@ -5,6 +5,7 @@ package square
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -19,6 +20,15 @@ import (
"github.com/stretchr/testify/require"
)
// replaySnapshotReq marshals a CreatePaymentReq into the stored
// square_request_snapshot shape (domain JSON) that ReplayPaymentByKey parses.
func replaySnapshotReq(t *testing.T, req CreatePaymentReq) []byte {
t.Helper()
snap, err := json.Marshal(req)
require.NoError(t, err)
return snap
}
func TestDevClient_CreatePayment_ReturnsCompleted(t *testing.T) {
client := NewDevClient().(*MockClient)
@@ -672,6 +682,157 @@ func TestDevClient_GetCardsOnFile_Empty(t *testing.T) {
assert.Empty(t, cards)
}
func TestDevClient_ReplayPaymentByKey_MatchingSource_ReturnsOriginal(t *testing.T) {
// Identical-body replay contract: a retained key with the MATCHING stored
// source returns the ORIGINAL payment (Square's idempotency guarantee) —
// never a second charge and never IDEMPOTENCY_KEY_REUSED.
client := NewDevClient().(*MockClient)
ctx := context.Background()
orig, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "replay-match-key",
})
require.NoError(t, err)
got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "replay-match-key",
}))
require.NoError(t, err)
assert.Equal(t, orig.ID, got.ID, "identical-body replay must return the original payment")
}
func TestDevClient_ReplayPaymentByKey_SourceMismatch_ReturnsKeyReused(t *testing.T) {
// Identical-body replay contract: reusing a retained key with a DIFFERENT
// source is Square's documented IDEMPOTENCY_KEY_REUSED rejection — a data
// bug, NOT proof the charge never happened. The mock must carry the
// structured code so ErrorCode(err) can read it (the sweep treats it as
// ambiguous).
client := NewDevClient().(*MockClient)
ctx := context.Background()
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "replay-mismatch-key",
})
require.NoError(t, err)
_, err = client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:different",
IdempotencyKey: "replay-mismatch-key",
}))
require.Error(t, err)
assert.Equal(t, "IDEMPOTENCY_KEY_REUSED", ErrorCode(err), "source-mismatch replay must carry IDEMPOTENCY_KEY_REUSED")
assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err))
assert.False(t, errors.Is(err, ErrReplayKeyNotRetained), "IDEMPOTENCY_KEY_REUSED is NOT proof the charge never happened")
}
func TestDevClient_ReplayPaymentByKey_UnknownKey_NotRetained(t *testing.T) {
// Identical-body replay contract: an unknown key makes Square attempt a
// real charge with the (expired/used) cnon: nonce, which is rejected with a
// 4xx — surfaced as ErrReplayKeyNotRetained (proof the charge never
// happened).
client := NewDevClient().(*MockClient)
ctx := context.Background()
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:test-card",
IdempotencyKey: "key-never-seen",
}))
require.Error(t, err)
assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "unknown-key cnon replay must surface ErrReplayKeyNotRetained, got %v", err)
}
func TestDevClient_ReplayPaymentByKey_UnknownKey_CcofSavedCard_ChargesAndRescues(t *testing.T) {
// B3 dev/prod parity: an unknown key with a STILL-VALID ccof: saved-card
// token makes real Square attempt a REAL charge that succeeds — the sweep
// must RESCUE such rows, never fail them. The mock mirrors this by looking
// up the saved card and creating a new COMPLETED payment under the key.
client := NewDevClient().(*MockClient)
ctx := context.Background()
card, err := client.CreateCardOnFile(ctx, "user-replay-rescue", "cnon:test-token", "cus_replay123")
require.NoError(t, err)
snapshot := replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: card.CardID,
CustomerID: "cus_replay123",
IdempotencyKey: "key-never-seen-ccof",
})
got, err := client.ReplayPaymentByKey(ctx, snapshot)
require.NoError(t, err)
assert.Equal(t, "COMPLETED", got.Status, "a still-valid ccof: source must charge successfully on an unknown key")
assert.Equal(t, int64(5000), got.Amount)
assert.Equal(t, "ON_FILE", got.EntryMethod)
// The charge must be recorded under the key so a later identical replay
// returns the SAME payment (Square's dedup) instead of charging twice.
got2, err := client.ReplayPaymentByKey(ctx, snapshot)
require.NoError(t, err)
assert.Equal(t, got.ID, got2.ID, "a replayed ccof: charge under the same key must dedup, never charge twice")
}
func TestDevClient_ReplayPaymentByKey_UnknownKey_UnregisteredCcof_NotRetained(t *testing.T) {
// A ccof: token that is NOT in the saved-card ledger mirrors real Square
// rejecting a deleted/disabled card with a definitive 4xx — the charge
// never happened.
client := NewDevClient().(*MockClient)
ctx := context.Background()
_, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "ccof:never-registered",
CustomerID: "cus_replay123",
IdempotencyKey: "key-never-seen-ccof-deleted",
}))
require.Error(t, err)
assert.True(t, errors.Is(err, ErrReplayKeyNotRetained), "an unregistered ccof: token must surface ErrReplayKeyNotRetained, got %v", err)
}
func TestDevClient_ReplayPaymentByKey_DedupSourceTracked(t *testing.T) {
// The mock must record the source used by each CreatePayment so a later
// identical-body replay can verify the source matches (the "works in dev ==
// works in prod" guarantee for the reconcile sweep).
client := NewDevClient().(*MockClient)
ctx := context.Background()
_, err := client.CreatePayment(ctx, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:dedup-src",
IdempotencyKey: "replay-dedup-key",
})
require.NoError(t, err)
client.mu.RLock()
stored := client.paymentSource["replay-dedup-key"]
client.mu.RUnlock()
assert.Equal(t, "cnon:dedup-src", stored, "the source of each keyed payment must be stored for replay parity")
got, err := client.ReplayPaymentByKey(ctx, replaySnapshotReq(t, CreatePaymentReq{
Amount: 5000,
Currency: "GBP",
SourceID: "cnon:dedup-src",
IdempotencyKey: "replay-dedup-key",
}))
require.NoError(t, err)
assert.NotEmpty(t, got.ID)
}
func TestDevClient_GetCheckout_StillPending(t *testing.T) {
client := NewDevClient().(*MockClient)
client.HoldCheckouts = true