feat: SCA-only saved-card charges — 2FA charge fallback removed (C6), versioned consent fields, token provenance

PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated
stored-credential charges; a merchant-side 2FA check cannot legally substitute
for it (authorising a token-less charge via 2FA leaves the MERCHANT liable for
ECI 7 / SLI 210 chargebacks and reg 77(6) compensation regardless of consent).

- payments/twofa.go: the homegrown 2FA fallback for token-less saved-card
  charges is REMOVED ENTIRELY. requireTwoFactorForCardAccess is now SCA-only:
  a non-empty Square verification_token (charge surfaces, token forwarded to
  Square) skips the gate; anything else is refused 402 verification_required.
  enforceSCAFallbackConsent is a compile-compatible no-op (fallback never runs).
- New requireTwoFactorForCardAccessWithTokenValidation distinguishes surfaces
  where the token IS forwarded to Square (charge — Square validates it) from
  card-SAVE surfaces (token client-asserted, never forwarded: a non-empty token
  must NOT skip the save gate, auth-F1).
- SCA tokenize-result wire contract (C1): a saved card charged with a fresh
  one-time tokenize-result sends the token as the charge SOURCE (new_card_token
  -> source_id) alongside saved_card_id, never a separate verification_token.
  resolveChargeSource resolves the saved-card branch FIRST (customer from the
  card row, token as source) so combined token+card requests are SCA-clean.
- C6 consent fields (consent_version / consent_accepted) added to the booking/
  tip/till/gift-card charge requests, enforced server-side before any fallback
  charge could reach Square and recorded on the 2fa_fallback_charge audit row;
  logVerificationTokenProvenance traces minted tokens to their charge.
- user 2FA issuance gate refactored into pure build-agnostic functions
  (twoFAPepperConfigured / twoFADeliveryChannelConfigured /
  twoFAEnsureIssueAllowedStrict) shared with the payments re-issue path and
  exercised directly by the test,dev suite; TWO_FACTOR_FALLBACK switch and
  .env.example entry removed; startup posture notes updated.
- Test coverage: fail-closed 2FA production gates (pepper/delivery), token
  validation on save vs charge surfaces, completion idempotency, idempotency
  key determinism, refund-policy 72h/24h epsilon boundaries, VAT parity.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 1179293777
commit 2cdbad0cea
25 changed files with 2356 additions and 774 deletions
+19 -17
View File
@@ -191,11 +191,11 @@ func initSquare() {
// 2FA enforcement is fail-closed (see payments.twoFactorEnforced): it is
// OFF only when REQUIRE_2FA explicitly disables it (false/0/off/no,
// case-insensitive) or SQUARE_ENVIRONMENT explicitly selects the dev/mock
// stack. Warn loudly when a non-dev env (empty/unknown — a likely
// misconfiguration) leaves the gate disabled, so saved-card charges can
// never silently ship without this merchant-level authorization gate (an
// additional fraud control; NOT PSD2 SCA — Square buyer verification is the
// SCA mechanism, wired for new-card charges).
// stack. In an enforced env a token-less saved-card charge is refused 402
// verification_required (SCA-only — see the posture note below); in a
// dev/mock env the gate lifts and the mock simulates SCA. Warn loudly when
// a non-dev env (empty/unknown — a likely misconfiguration) leaves the gate
// disabled.
enforced := payments.NewPaymentService().TwoFactorEnforced()
if enforced {
// Delivery is build-dependent (handlers/user/twofa_dev.go /
@@ -206,24 +206,26 @@ func initSquare() {
// misled into thinking codes are reaching users when issuance is
// actually failing closed.
if os.Getenv("TWO_FACTOR_ALLOW_LOG_DELIVERY") == "true" {
log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with backend log access can defeat the 2FA gate. Restrict log access and relay codes out-of-band; replace this loose-fake delivery with email/SMS (P6) before launch.")
log.Printf("WARNING: 2FA codes are delivered in PLAINTEXT via the server log ([2FA] prefix) — anyone with backend log access can defeat the account 2FA gate. Restrict log access and relay codes out-of-band; replace this loose-fake delivery with email/SMS (P6) before launch.")
} else {
log.Printf("WARNING: 2FA enforcement is ON but TWO_FACTOR_ALLOW_LOG_DELIVERY is unset: in a production build there is NO code-delivery channel (email/SMS is not wired — P6), so 2FA code issuance FAILS CLOSED and no user can complete setup or disable. Every enforced saved-card online payment for a user without 2FA will 403 with no way to enable it. Set TWO_FACTOR_ALLOW_LOG_DELIVERY=true to opt into the insecure [2FA] log-delivery channel (plaintext codes in the server log — restrict log access), or wire email/SMS (P6).")
log.Printf("WARNING: 2FA enforcement is ON but TWO_FACTOR_ALLOW_LOG_DELIVERY is unset: in a production build there is NO code-delivery channel (email/SMS is not wired — P6), so 2FA code issuance FAILS CLOSED and no user can complete 2FA setup or disable. Set TWO_FACTOR_ALLOW_LOG_DELIVERY=true to opt into the insecure [2FA] log-delivery channel (plaintext codes in the server log — restrict log access), or wire email/SMS (P6).")
}
}
if !enforced && !payments.IsExplicitDevOrMockEnv() {
log.Printf("WARNING: 2FA enforcement is OFF (REQUIRE_2FA=%q) with SQUARE_ENVIRONMENT=%q (not an explicit mock/dev value). Online saved-card payments will NOT require 2FA.", os.Getenv("REQUIRE_2FA"), env)
}
// SCA-primary / 2FA-backup posture (TWO_FACTOR_FALLBACK, default "true"):
// the homegrown 2FA gate is the BACKUP authorization for saved-card charges
// that carry no Square verification_token. TWO_FACTOR_FALLBACK=false makes
// the deployment SCA-only: a token-less saved-card charge is then rejected
// 402 verification_required (the frontend shows the 3DS challenge; if the
// bank cannot do SCA the payment cannot proceed). Warn when the operator
// opted out so the SCA-only posture is a deliberate, visible choice.
if !payments.NewPaymentService().TwoFactorFallbackEnabled() && !payments.IsExplicitDevOrMockEnv() {
log.Printf("WARNING: TWO_FACTOR_FALLBACK=false with SQUARE_ENVIRONMENT=%q — the homegrown 2FA is DISABLED as a saved-card charge fallback. Only charges carrying a Square verification_token (SCA performed) can proceed; a token-less charge is rejected 402 verification_required.", env)
}
// SCA-only posture (permanent — no switch): saved-card charges are
// authorised exclusively by Square PSD2 SCA. PSR 2017 reg 100 makes SCA
// mandatory and non-waivable for customer-initiated stored-credential
// charges, and the homegrown 2FA cannot legally act as an SCA fallback: a
// token-less saved-card charge is refused 402 verification_required ("pay
// online later") — a fallback-authorised charge would leave the MERCHANT
// liable for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6)
// compensation, and consent does not cure it. The dev mock simulates SCA
// (SimulateSavedCardVerificationRequired + cnon:sca-... tokenize-results),
// so development has full parity. There is no TWO_FACTOR_FALLBACK switch to
// warn about — it was removed; a token-less charge can never be authorised
// by 2FA in any environment.
// The mirror-image confusion: enforcement is ON but the Square client fell
// back to the in-memory mock (internal/square.NewDevClient only picks the
// real API for sandbox/production) because SQUARE_ENVIRONMENT is empty or