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.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent ecef5da516
commit 5dae0bba08
35 changed files with 3683 additions and 249 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"collapse-filter": true,
"search": "",
"showTags": false,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": true,
"colorGroups": [],
"collapse-display": true,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 1,
"close": false
}
+22 -17
View File
@@ -4,21 +4,21 @@
"type": "split",
"children": [
{
"id": "4dec2140f7c65f3e",
"id": "d45436e02729bff7",
"type": "tabs",
"children": [
{
"id": "6776d739ee18449c",
"id": "7b4ed20d72674471",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Crussell/User Manual.md",
"file": "Crussell/payments and money processes.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "User Manual"
"title": "payments and money processes"
}
}
]
@@ -41,7 +41,9 @@
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
"autoReveal": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-folder-closed",
"title": "Files"
@@ -78,7 +80,8 @@
}
],
"direction": "horizontal",
"width": 300
"width": 300,
"collapsed": true
},
"right": {
"id": "2750d7726f904ef3",
@@ -94,7 +97,7 @@
"state": {
"type": "backlink",
"state": {
"file": "Crussell/Future Work - Gap Backlog.md",
"file": "Crussell/payments and money processes.md",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
@@ -104,7 +107,7 @@
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "Backlinks for Future Work - Gap Backlog"
"title": "Backlinks"
}
},
{
@@ -113,12 +116,12 @@
"state": {
"type": "outgoing-link",
"state": {
"file": "Crussell/Crussell Nails.md",
"file": "Crussell/payments and money processes.md",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "Outgoing links from Crussell Nails"
"title": "Outgoing links"
}
},
{
@@ -142,16 +145,17 @@
"state": {
"type": "outline",
"state": {
"file": "Crussell/Crussell Nails.md",
"file": "Crussell/payments and money processes.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "Outline of Crussell Nails"
"title": "Outline"
}
}
]
],
"currentTab": 3
}
],
"direction": "horizontal",
@@ -169,19 +173,20 @@
"bases:Create new base": false
}
},
"active": "6776d739ee18449c",
"active": "7b4ed20d72674471",
"lastOpenFiles": [
"Untitled.canvas",
"Crussell/payments and money processes.md",
"Crussell/Overview.md",
"Crussell/User Manual.md",
"Crussell/Future Work - Gap Backlog.md",
"Crussell/Technical Manual.md",
"Crussell/Loyalty & Discount System Reference.md",
"Crussell/Overview.md",
"Crussell/User Manual.md",
"Crussell/Admin Manual.md",
"Crussell/Test Implementation Plan.md",
"Crussell/Crussell Nails.md",
"Crussell/Backend/bookings.md",
"Untitled.base",
"Untitled.canvas",
"Express.js Cheat Sheet.md"
]
}