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