Files
Crussell/backend/handlers/payments/errors.go
T
popertots 5dae0bba08 feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup
SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
2026-08-22 00:34:50 +01:00

100 lines
4.8 KiB
Go

package payments
import (
"context"
"errors"
"net/http"
"crussell/internal/square"
"crussell/mw"
)
// verificationRequiredCodes are Square CreatePayment error codes that mean the
// buyer must complete Strong Customer Authentication (3DS/SCA) before the
// charge can succeed: Square is demanding a fresh verification_token from the
// cardholder's buyer-verification flow. These are NOT plain declines — the
// frontend must surface the SCA challenge (the banking app / banking-app
// approval) and retry the charge with the resulting verification token. This
// is the SINGLE authoritative list of SCA-challenge codes; keep it in lock-step
// with the dev mock's simulated SCA toggle (square_dev.go).
var verificationRequiredCodes = map[string]bool{
"CARD_DECLINED_VERIFICATION_REQUIRED": true,
"VERIFICATION_TOKEN_EXPIRED": true,
"VERIFICATION_TOKEN_INVALID": true,
"MISSING_VERIFICATION_TOKEN": true,
}
// isVerificationRequiredError reports whether a SquareClient.CreatePayment
// error is an SCA/verification-required rejection (the charge must be retried
// through the buyer-verification flow with a fresh verification_token) rather
// than a plain decline. Matches square.ErrorCode against the four SCA codes;
// CVV_VERIFICATION_REQUIRED / ADDRESS_VERIFICATION_REQUIRED are deliberately
// excluded — those mean re-entering card data, not a 3DS challenge.
func isVerificationRequiredError(err error) bool {
return verificationRequiredCodes[square.ErrorCode(err)]
}
// writeVerificationRequiredResponse responds 402 with the structured
// verification_required body the frontend keys on to trigger the SCA challenge
// flow (mirrors the overflow_tip_confirmation_required / campaign_fully_redeemed
// structured-error pattern — mw.RespondJSON, code + human message). The message
// tells the buyer to approve the payment in their banking app. Used both by the
// charge-failure paths (Square returned an SCA-required code) and by the 2FA
// gate when the SCA-only posture has no fallback for a token-less charge.
func writeVerificationRequiredResponse(w http.ResponseWriter) {
mw.RespondJSON(w, http.StatusPaymentRequired, map[string]string{
"error": "Your card issuer requires verification. Approve this payment in your banking app.",
"code": "verification_required",
})
}
// chargeFailureStatus classifies a SquareClient.CreatePayment error into the
// HTTP status a payment handler should return:
//
// - 503 (Service Unavailable) for AMBIGUOUS failures: transport/network
// errors, Square 5xx responses, context cancellation/deadline, and the
// retryable 4xx statuses 429 (rate limited), 408 (request timeout), and
// 425 (too early) — the money state at Square is unknown, so the frontend
// should treat it as a retry (the pending record is resumed on a same-key
// retry). Square's own docs treat 429 as "retry later"; mapping it (or a
// timeout/early request) to 402 would mislabel a retryable condition as a
// permanent decline.
// - 402 (Payment Required) for DEFINITIVE declines: a structured Square
// error (squareAPIError) carrying any OTHER 4xx status (400/402/422 etc.)
// means Square positively rejected the charge (card declined/expired,
// AVS/CVV failure) — retrying with the same inputs cannot succeed.
//
// A nil error is never expected (callers only invoke this on the error path);
// it maps to 402 defensively. The dev mock returns plain errors for simulated
// failures, which classify as 503 (ambiguous) — correct for a mock standing in
// for an unreachable Square.
func chargeFailureStatus(err error) int {
if err == nil {
return http.StatusPaymentRequired
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return http.StatusServiceUnavailable
}
status := square.ErrorStatusCode(err)
if status == 0 || status >= 500 {
return http.StatusServiceUnavailable
}
// Retryable/ambiguous 4xx carve-outs: 429 (RATE_LIMITED), 408 (request
// timeout), and 425 (too early) are not definitive declines — Square's
// docs tell clients to retry later. Classify them as 503 so the pending
// record stays resumable on a same-key retry instead of being labelled a
// permanent decline. True declines (400/402/422 etc.) fall through to 402.
if status == http.StatusTooManyRequests || status == http.StatusRequestTimeout || status == http.StatusTooEarly {
return http.StatusServiceUnavailable
}
if status >= 400 && status < 500 {
return http.StatusPaymentRequired
}
// Anything else (1xx/2xx/3xx — impossible in practice, but defensive) is
// AMBIGUOUS: the money state at Square is unknown, so the failure must be
// retryable. The default is deliberately 503, never 402 — a definitive
// decline classification on an ambiguous outcome would suppress the
// same-key retry that resumes the pending record.
return http.StatusServiceUnavailable
}