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
+64 -48
View File
@@ -41,21 +41,17 @@ const (
// Handlers log these errors verbatim, so echoing more than a snippet risks
// leaking PII that Square may have mirrored from the request.
maxErrorBody = 500
// probePaymentSourceID is the synthetic Square source token carried by the
// sweep's replay-by-key reconcile (ReplayPaymentByKey). It uses the cnon:
// prefix so it passes this client's PCI token validation (isTokenLike), but
// it is NOT a real Square-issued nonce and can never be processed into a
// charge. When the replayed idempotency key is unknown at Square, Square
// therefore definitively rejects the request instead of creating a new
// payment — the replay can never charge a customer.
probePaymentSourceID = "cnon:sqr-reconcile-probe"
)
// ---------------------------------------------------------------------------
// HTTP client — shared by ProdClient (!dev) and devProdClient (dev).
// ---------------------------------------------------------------------------
// gbpCurrency is the currency sent in every Square money amount. UK-only app,
// so GBP is the only currency ever used; the named constant keeps the wire
// bodies (including the identical-body replay) consistent.
const gbpCurrency = "GBP"
type httpClient struct {
baseURL string
token string
@@ -458,6 +454,19 @@ func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *
if !isTokenLike(req.SourceID) {
return nil, fmt.Errorf("square: invalid card token %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(req.SourceID))
}
var resp sqCreatePaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", buildCreatePaymentBody(req, hc), &resp); err != nil {
return nil, err
}
return paymentFromSquare(&resp.Payment), nil
}
// buildCreatePaymentBody converts a CreatePaymentReq into the exact POST
// /v2/payments wire body. Shared by createPaymentHTTPWithClient (the original
// charge) and replayPaymentByKeyHTTPWithClient (the identical-body replay), so
// a charge replayed from the stored snapshot produces BYTE-IDENTICAL JSON to
// the original — Square's idempotency dedup compares the full request body.
func buildCreatePaymentBody(req CreatePaymentReq, hc *httpClient) sqCreatePaymentRequest {
body := sqCreatePaymentRequest{
SourceID: req.SourceID,
IdempotencyKey: req.IdempotencyKey,
@@ -473,11 +482,7 @@ func createPaymentHTTPWithClient(ctx context.Context, req CreatePaymentReq, hc *
if req.TipMoney != nil {
body.TipMoney = &sqMoney{Amount: *req.TipMoney, Currency: req.Currency}
}
var resp sqCreatePaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", body, &resp); err != nil {
return nil, err
}
return paymentFromSquare(&resp.Payment), nil
return body
}
func createCheckoutHTTP(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) {
@@ -572,25 +577,38 @@ func getPaymentHTTPWithClient(ctx context.Context, paymentID string, hc *httpCli
return paymentFromSquare(&resp.Payment), nil
}
func replayPaymentByKeyHTTP(ctx context.Context, idempotencyKey string, amount int64) (*PaymentResult, error) {
return replayPaymentByKeyHTTPWithClient(ctx, idempotencyKey, amount, newHTTPClient())
func replayPaymentByKeyHTTP(ctx context.Context, snapshotJSON []byte) (*PaymentResult, error) {
return replayPaymentByKeyHTTPWithClient(ctx, snapshotJSON, newHTTPClient())
}
// replayPaymentByKeyHTTPWithClient re-issues POST /v2/payments with the same
// idempotency key and amount. Square's documented idempotency behavior returns
// the ORIGINAL payment object when the key is reused — never a second charge.
// The body's source_id is probePaymentSourceID, a synthetic token that cannot
// be processed into a charge, so a key Square does not retain makes Square
// reject the request instead of creating a new payment; that rejection is
// surfaced as ErrReplayKeyNotRetained (proof the charge never happened).
func replayPaymentByKeyHTTPWithClient(ctx context.Context, idempotencyKey string, amount int64, hc *httpClient) (*PaymentResult, error) {
body := sqCreatePaymentRequest{
SourceID: probePaymentSourceID,
IdempotencyKey: idempotencyKey,
AmountMoney: sqMoney{Amount: amount, Currency: "GBP"},
// replayPaymentByKeyHTTPWithClient re-issues POST /v2/payments with an
// IDENTICAL body to the original charge: the square_request_snapshot stored on
// the pending row is the verbatim CreatePaymentReq JSON captured at charge
// time, and buildCreatePaymentBody reproduces the exact wire request the
// original charge sent (source_id, key, amount, customer_id, reference_id,
// note, buyer_email_address, verification_token, tip, location). Square's
// idempotency guarantee returns the ORIGINAL payment for a retained key (never
// a second charge); a key Square no longer retains makes Square attempt a real
// charge with the (expired/used) source, which Square rejects with a definitive
// 4xx — surfaced as ErrReplayKeyNotRetained (proof the charge never happened).
// A replay body missing fields the original charge carried would return
// IDEMPOTENCY_KEY_REUSED for a RETAINED key and strand the row pending forever,
// so the snapshot is never reconstructed from partial row data.
func replayPaymentByKeyHTTPWithClient(ctx context.Context, snapshotJSON []byte, hc *httpClient) (*PaymentResult, error) {
var req CreatePaymentReq
if err := json.Unmarshal(snapshotJSON, &req); err != nil {
// An unparsable snapshot must never look like proof of no charge — the
// sweep leaves such rows pending for manual reconciliation.
return nil, fmt.Errorf("square: replay-by-key cannot parse stored request snapshot: %w", err)
}
if req.SourceID == "" || req.IdempotencyKey == "" {
return nil, fmt.Errorf("square: replay-by-key snapshot missing source_id/idempotency_key")
}
if req.Currency == "" {
req.Currency = gbpCurrency
}
var resp sqCreatePaymentResponse
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", body, &resp); err != nil {
if err := hc.doJSON(ctx, http.MethodPost, "/v2/payments", buildCreatePaymentBody(req, hc), &resp); err != nil {
if replayErrorProvesNoCharge(err) {
return nil, fmt.Errorf("%w: %v", ErrReplayKeyNotRetained, err)
}
@@ -600,32 +618,30 @@ func replayPaymentByKeyHTTPWithClient(ctx context.Context, idempotencyKey string
}
// replayErrorProvesNoCharge reports whether a ReplayPaymentByKey error
// definitively proves Square has no payment under the key. A retained key
// makes Square return the original payment (HTTP 2xx); every other DEFINITIVE
// business rejection must therefore be Square attempting to process the
// synthetic probe source for an unknown key — which can never succeed, so the
// charge never happened. Auth (401/403 — affects every Square call, must not
// fail rows) and rate-limit (429 — transient) are deliberately NOT proof; a
// 5xx / transport error is ambiguous by definition.
// definitively proves Square has no payment under the key. The replay carries
// the ORIGINAL source_id (identical-body retry), so a retained key makes Square
// return the original payment (HTTP 2xx); any definitive 4xx business rejection
// must therefore be Square attempting a REAL charge with the expired/used
// source — which can never succeed, so the charge never happened under that
// key. IDEMPOTENCY_KEY_REUSED is the exception: it can only occur when the
// stored source differs from the original charge's source (a data bug), so it
// proves NOTHING about whether the original charge landed — it is AMBIGUOUS,
// never proof of no charge. Auth (401/403 — affects every Square call, must
// not fail rows), rate-limit (429 — transient), 5xx and transport errors are
// ambiguous by definition.
func replayErrorProvesNoCharge(err error) bool {
if err == nil {
return false
}
if ErrorCode(err) == "IDEMPOTENCY_KEY_REUSED" {
return false
}
switch ErrorStatusCode(err) {
case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests:
return false
}
if status := ErrorStatusCode(err); status >= 400 && status < 500 {
return true
}
// Errors without a structured HTTP status: a structured Square error code
// is a definitive business response; the message match covers the dev mock's
// plain rejection wording.
if ErrorCode(err) != "" {
return true
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "INVALID_REQUEST") || strings.Contains(msg, "SOURCE_ID")
status := ErrorStatusCode(err)
return status >= 400 && status < 500
}
// squareAPIError wraps a formatted Square API error while exposing the
@@ -740,7 +756,7 @@ func refundPaymentHTTPWithClient(ctx context.Context, req RefundPaymentReq, hc *
body := sqRefundPaymentRequest{
PaymentID: req.PaymentID,
IdempotencyKey: req.IdempotencyKey,
AmountMoney: sqMoney{Amount: req.Amount, Currency: "GBP"},
AmountMoney: sqMoney{Amount: req.Amount, Currency: gbpCurrency},
Reason: req.Reason,
}
var resp sqRefundPaymentResponse