fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
This commit is contained in:
@@ -220,7 +220,7 @@ npm run dev # Dev server with HMR
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go test -tags "test,dev" ./... # 2,333 tests passed (4 skipped), as of 14 Aug 2026
|
||||
go test -tags "test,dev" ./... # 2,440 backend test functions passed (4 skipped) + 61 frontend vitest cases, as of 15 Aug 2026
|
||||
go test -tags "test,dev" -v -run TestName ./... # Single test
|
||||
```
|
||||
|
||||
|
||||
@@ -689,6 +689,8 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
|
||||
|
||||
**Concurrency guard:** `CreateBookingPayment` acquires a PostgreSQL session-level advisory **try-lock** (`pg_try_advisory_lock(hashtext('crussell:payment:' || booking_id))`, via `acquireAdvisoryLock` in `handlers/payments/locks.go`) at entry and releases it in a defer. The lock is **bounded**: `tryAdvisoryLock` retries `pg_try_advisory_lock` ~30 times with a 100ms backoff (~3s total), and a contended second request gets a 409 "payment in progress, try again" instead of blocking a pool connection (blocking would hold the pinned connection hostage across the lock-holder's up-to-30s Square round-trip and could exhaust the pool). After the lock, the handler re-checks booking status (a concurrent payment may have promoted it) and runs a payment-type duplicate guard that prevents two `'full'` or `'deposit'` payments from being created for the same booking, even with different idempotency keys.
|
||||
|
||||
**CIT vs MIT classification (saved-card charges):** how Square classifies the charge depends on who initiates it. Online customer charges — the booking payment (`CreateBookingPayment`) and tips (`CreateTipPayment`) — send `customer_details.customer_initiated=true` (CIT, "C3"): SCA applies, and the charge carries Square's `verification_token` from the frontend's proactive buyer verification (tokenize-before-first-charge — a saved card is never charged as a naked `ccof:`). The admin saved-card paths are **merchant-initiated (MIT)** and send `customer_initiated=false` — `CreateTerminalPayment`'s admin "Charge Saved Card" (`handlers.go:1114-1121`) and the till's saved-card branch (`till.go:1174-1179`): Square reads those as SCA-exempt with **no liability shift**, because the cardholder is not at the keyboard. The homegrown 2FA gate (see the Two-Factor Authentication section) is the authorisation on the saved-card paths whenever SCA does not cover the charge.
|
||||
|
||||
3. **Deposit deadline passes without payment** → `CleanupExpiredDeposits()` moves the booking to `pending_release`. The slot becomes vulnerable — another booking can claim it via eviction. An admin notification `deposit_not_paid_by_deadline` is created. The user's time blocker reservations are also cleaned up.
|
||||
4. **Slot claimed by another booking** → If a new booking overlaps a `pending_release` slot, `EvictPendingReleaseOverlapping` (a shared function in `bookings.go`) evicts the pending_release booking to `deposit_lapsed`. The eviction runs inside the same transaction as the new booking's creation, so it rolls back if the new booking fails. The function is called by all 4 eviction sites: `CreateBookingHandler`, `ConfirmBookingHandler`, `AdminCreateBookingForUserHandler`, and `AdminRescheduleBookingHandler`. A `PAYMENT_IN_FLIGHT` time_blocker guard prevents evicting a booking that the user is currently paying for (5-minute window).
|
||||
5. **Payment arrives after deadline but before eviction** → The 20% threshold check promotes `pending_release` back to `confirmed` — the booking is saved and the slot is no longer vulnerable.
|
||||
@@ -830,15 +832,17 @@ validTransitions := map[string]map[string]bool{
|
||||
**Enforcement** (`twoFactorEnforced`, `handlers/payments/twofa.go`):
|
||||
- Enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT`, including empty and unknown values, which are treated as production-enforced. It is disabled only when `REQUIRE_2FA` is an explicit disable value (`false`/`0`/`off`/`no`, case-insensitive) **or** `SQUARE_ENVIRONMENT` is an explicit dev/mock value (`mock`, `dev`, `development`, `test`).
|
||||
- A mistyped or unset `SQUARE_ENVIRONMENT` can never silently disarm the gate. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
|
||||
- **Residual brute-force exposure (accepted):** a fresh-code delivery (setup, or a disable that mints because no pending code exists) resets the shared 5-attempt counter. An authenticated attacker who already holds the victim's password can therefore loop `disable` with wrong codes to obtain an unlimited series of fresh codes, each granting 5 guesses — the 2FA gate then reduces to a 6-digit guessing game bounded only by the per-IP rate limit (120 req/min on `/api/user`) and the 10-minute code TTL. This is the same reset-on-delivery tradeoff that makes codes deliverable to locked-out users; it is documented rather than fixed because a hard per-user lockout would strand a legitimate user who lost their code, with no email/SMS transport to recover (P6). Revisit when real delivery lands. (Because 2FA is now backup-only, the exposure is confined to the no-SCA fallback path — it no longer fronts every saved-card charge.)
|
||||
- **Posture switch (`TWO_FACTOR_FALLBACK`, default `true`):** read by `twoFactorFallbackEnabled` (`handlers/payments/twofa.go`) and logged at startup by `main.go`. `false` = SCA-only posture: a saved-card charge carrying no Square `verification_token` is denied 402 `verification_required` (the frontend shows the SCA challenge; if the bank cannot complete it, the charge fails) rather than falling back to the 2FA gate. `true` (the default) = the 2FA gate may authorise a token-less saved-card charge when SCA is unavailable, the user has enabled 2FA, and a delivery channel exists.
|
||||
- **Residual brute-force exposure (accepted, bounded by B11):** the 5-attempt counter is per-user and in-memory (`internal/twofa`, `MaxAttempts=5`, `AttemptWindow` = 10 minutes), and it resets **only** on a successful verify or when the attempt window lapses — **never** on a fresh-code delivery (`internal/twofa/twofa.go:49-51`; `ResetAttempts` is called only on successful verify, B11b). A fresh code therefore never grants a fresh guessing budget, and minting is additionally throttled to one code per minute per user (`twoFAMintCooldown`). The exposure that remains is a locked-out legitimate user who lost their code: they must wait out the 10-minute window, because a hard per-user lockout would strand them with no email/SMS transport to recover (P6). Revisit when real delivery lands. (Because 2FA is now backup-only, the exposure is confined to the no-SCA fallback path — it no longer fronts every saved-card charge.)
|
||||
|
||||
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash`, `two_factor_pending_code_expires` (10-minute TTL). Only a digest of the code is stored in the DB — never the plaintext. The digest is **HMAC-SHA256 keyed by `TWO_FACTOR_PEPPER`** when that env var is set (`hashTwoFACode`, `handlers/user/twofa.go`); an unset pepper falls back to the legacy unsalted SHA-256 digest **only** in dev/test builds and for the legacy-row migration window — production builds can never persist an unsalted digest because code issuance **fails closed** without the pepper (see `handlers/user/twofa_prod.go`). **Code delivery is build-dependent and production fails closed:** the **intended** channel is email/SMS (the method chosen at setup) — **not yet wired (P6)**. Until that transport lands, the **only** production channel is the operator's explicit opt-in to the insecure stdout-log relay: with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` the plaintext code is written to the server log with a `[2FA]` prefix (user id and code on **separate** lines, so a single record cannot trivially pair them), and the operator relays it. Without the opt-in, production code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced fallback saved-card payment 403s with no way forward — a loud failure rather than a silent lockout. Dev/test builds always write the `[2FA]` log line (and, when enforcement is off, the setup endpoint also returns the code and verify accepts any code, so the flow is testable without grepping logs). Each fresh code is checked under a shared **5-attempt lockout** (`twoFAMaxAttempts = 5` consecutive failed verifies invalidate the pending code); a fresh-code delivery resets that counter (see the residual brute-force note above).
|
||||
**State:** stored on `users` — `two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash`, `two_factor_pending_code_expires` (10-minute TTL). Only a digest of the code is stored in the DB — never the plaintext. The digest is **HMAC-SHA256 keyed by `TWO_FACTOR_PEPPER`** when that env var is set (`hashTwoFACode`, `handlers/user/twofa.go`); an unset pepper falls back to the legacy unsalted SHA-256 digest **only** in dev/test builds and for the legacy-row migration window — production builds can never persist an unsalted digest because code issuance **fails closed** without the pepper (see `handlers/user/twofa_prod.go`). **Code delivery is build-dependent and production fails closed:** the **intended** channel is email/SMS (the method chosen at setup) — **not yet wired (P6)**. Until that transport lands, the **only** production channel is the operator's explicit opt-in to the insecure stdout-log relay: with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` the plaintext code is written to the server log with a `[2FA]` prefix (user id and code on **separate** lines, so a single record cannot trivially pair them), and the operator relays it. Without the opt-in, production code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced fallback saved-card payment 403s with no way forward — a loud failure rather than a silent lockout. Dev/test builds always write the `[2FA]` log line (and, when enforcement is off, the setup endpoint also returns the code and verify accepts any code, so the flow is testable without grepping logs). Each fresh code is checked under a shared **5-attempt lockout** (`twoFAMaxAttempts = 5` consecutive failed verifies invalidate the pending code); the counter resets only on a successful verify or when the 10-minute attempt window elapses — never on a fresh-code delivery (B11b, see the residual brute-force note above).
|
||||
|
||||
**Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, saved-card till sales, gift-card saved-card charges — and on the save-card endpoints (`CreatePaymentMethod`, the `save_card=true` booking/tip branches). New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry, and under the SCA-primary model the same buyer verification is the primary authorisation for saved-card charges, with this gate as the fallback when SCA is unavailable. Disabling 2FA requires a verification code when enforcement is ON (a password-only attacker must not be able to lift the protection) — the disable flow reuses a still-valid pending code when one exists, otherwise it generates and delivers a fresh one via the same `[2FA]` log channel; the submitted code is checked under the shared 5-attempt lockout (the same per-user counter as verify). The "always generate a fresh code on disable" alternative was deliberately **not** adopted: with an out-of-band log-delivery channel, a code generated by a request could never be submitted within that same request. In dev (unenforced) environments no code is required to disable.
|
||||
|
||||
**Audit requirement (the fallback is fully traceable):**
|
||||
- `POST /api/admin/users/{id}/2fa/code` (`AdminSendVerificationCodeHandler`) mints (or reuses) a code keyed to the **target customer**, never the admin session — the gate verifies against the card owner. Every successful mint-or-reuse writes an `admin_audit_log` row, `action_type='2fa_code_mint'`, with details carrying `reused` (fresh vs reused) and `remaining_seconds` (the effective code lifetime).
|
||||
- Every admin saved-card charge writes its own audit row via `insertAdminAuditCharge` (`handlers/payments/handlers.go` ~38): `saved_card_charge` for the online path, `till_saved_card_charge` for the till, each with the target customer, amount, card, and Square payment id.
|
||||
- Every saved-card charge **authorised by the 2FA fallback** (no Square `verification_token` — SCA unavailable) additionally writes a `2fa_fallback_charge` row via `insertTwoFAFallbackAudit` (`handlers/payments/handlers.go` ~70): `sca_performed:false`, `fallback_reason:"verification_unavailable"`, the card's last four digits, and the charge reference — so a fallback-authorised charge is always distinguishable from an SCA-authorised one.
|
||||
- A customer's own requests (`POST /api/user/2fa/code`, `SendVerificationCodeHandler`) are per-user rate-limited and logged like every other 2FA delivery; the code is never included in the response when 2FA is enforced.
|
||||
|
||||
**Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`, `POST /api/user/2fa/code` (enabled user mints a charge code; 409 if not enabled, 429 on mint cooldown, 503 when no delivery channel), `POST /api/admin/users/{id}/2fa/code` (admin relay, audited), `POST /api/admin/users/{id}/2fa/remove` (admin recovery). UI: Account → Two-Factor Authentication.
|
||||
@@ -1328,7 +1332,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
|
||||
|
||||
### Test Coverage
|
||||
|
||||
**2,333 tests compiled** across all packages (4 skipped, 0 failures) — as of 14 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
|
||||
**2,440 backend test functions compiled** (4 skipped, 0 failures) plus **61 frontend vitest cases** — as of 15 Aug 2026. Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000.
|
||||
|
||||
| Package | Coverage Area |
|
||||
|---------|--------------|
|
||||
|
||||
@@ -272,7 +272,7 @@ A customer pays at the moment of booking (the deposit step) or afterwards from t
|
||||
From there the customer chooses how to pay:
|
||||
|
||||
- **A new card.** The Square Web Payments form runs entirely in the browser, and Square returns a short-lived token plus a verification token confirming the card owner is real (the SCA step). Crussell never sees a card number — that token-only posture is [[#Chapter 9: Square Integration, the Real Client and the Realistic Mock|Chapter 9]]'s territory. Nonces expire, so the front end discards any token older than four minutes or minted for a different amount and [tokenises](https://en.wikipedia.org/wiki/Tokenization_(data_security)) again.
|
||||
- **A saved card.** One-click checkout against a stored card, gated by the customer's current two-factor code at charge time, keyed to the card's owner, never to whoever operates the screen ([[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]). A stolen saved card is useless unless the thief also holds the customer's live code.
|
||||
- **A saved card.** One-click checkout against a stored card, authenticated by Square 3DS2 SCA as the primary authorisation — the customer approves the charge in their banking app and the resulting verification token rides the charge to Square ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]). The customer-keyed two-factor code gate from [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] is the backup, firing only when the customer's bank cannot run SCA. A stolen saved card is useless unless the thief can also pass the cardholder's verification — the bank's challenge, or the customer's live code where the 2FA gate is the operative authorisation.
|
||||
|
||||
Customers who haven't verified their email can still pay with a new card, but cannot **save** one — saving is refused before anything is written.
|
||||
|
||||
@@ -283,7 +283,7 @@ The owner takes money in the salon from the admin payment modal, which accepts f
|
||||
- **The card machine (Square Terminal).** A checkout is opened on the terminal and the app polls its status every two seconds until the charge completes. Tipping is folded into the amount up front (capped at £50) and Square's own tip prompt is disabled, so the customer is never asked twice (the till-tip rules are in [[#Chapter 5: Tips (Pre-Start vs Post-Start Rules)]]).
|
||||
- **Cash.** The owner enters what the customer hands over; change is computed on screen and handled at the counter. Cash completes instantly.
|
||||
- **A gift card or stored balance.** The code is entered or the balance looked up; the money is deducted from that pot *inside the same transaction* that records the payment, so the two can't diverge.
|
||||
- **A saved card, charged at the till.** The owner charges the customer's card on file — again gated on the customer's 2FA code, which the owner relays to them.
|
||||
- **A saved card, charged at the till.** The owner charges the customer's card on file — a merchant-initiated stored-credential charge (the cardholder is not at the keyboard), so Square classifies it `customer_initiated=false`: SCA-exempt, with no liability shift ([[#Chapter 14: Saved Cards & Square Customer Profiles|Chapter 14]]). The customer-keyed 2FA gate is the authorisation on this path, with the code relayed by the owner ([[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]).
|
||||
|
||||
One rule gates all of these: the admin can only take money for a booking that is **in progress or completed** — the till is for work actually happening or done.
|
||||
|
||||
@@ -940,7 +940,7 @@ Adding a card is itself 2FA-gated wherever enforcement is on, and it accepts onl
|
||||
|
||||
At charge time, the saved-card branch of the payment flow resolves the source from the card row, and the charge carries the customer's Square profile ID; a `ccof:` source simply cannot be charged without it. Rows created before customer provisioning existed can be retrofitted on the fly: the system looks up the profile, provisions one if the row predates P14, and persists the ID before the charge is allowed to proceed. A provisioning failure aborts the charge; the system never guesses.
|
||||
|
||||
Every saved-card charge is also marked as customer-initiated in the payload it sends Square, shaping how Square classifies issuer responses. And every saved-card charge in an enforced environment passes the two-factor gate described in the next chapter.
|
||||
How Square classifies a saved-card charge depends on who initiates it. A **customer-initiated** charge — the customer paying online from their own booking flow or account page — is marked `customer_details.customer_initiated=true` in the payload it sends Square, shaping how Square classifies issuer responses, and carries Square's verification token (SCA performed) as its primary authorisation ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]). An **admin-initiated** charge — the till, or the admin booking payment — is merchant-initiated instead: `customer_initiated=false`, SCA-exempt, with no liability shift, because the cardholder is not at the keyboard. And every saved-card charge in an enforced environment still passes the two-factor gate described in the next chapter when the SCA path cannot authorise it.
|
||||
|
||||
### Deleting a card: disable before delete
|
||||
|
||||
@@ -980,6 +980,8 @@ The brute-force defences around codes are layered. A user gets five failed attem
|
||||
|
||||
The gate's key decision is **B10**: merely having 2FA enabled does not unlock saved-card charges. In an enforced environment, every saved-card charge must present an *actual code at charge time*. The enabled flag proves the user went through setup; the code proves the owner is present *right now*. This closes the hole where an attacker with a captured session could charge a saved card just because 2FA was configured.
|
||||
|
||||
The gate is the *backup*, and on the customer-initiated online paths it usually never fires: the frontend runs Square's buyer verification proactively — the **tokenize-before-first-charge** flow from [[#Appendix A: SCA & the Approve-in-App model|Appendix A]] — so the charge carries a fresh verification token (SCA performed) and the 2FA gate is skipped entirely. The gate becomes the operative authorisation only when that verification cannot complete.
|
||||
|
||||
A correct code authorises exactly one charge. The code is consumed at the gate, atomically with the successful check, so two concurrent attempts can never both pass on the same code. If the subsequent Square charge fails, the customer gets a freshly minted code rather than being allowed to re-verify the old one. Saving a card consumes its code the same way, because a save is a terminal operation with no charge to attach consumption to. The gate sits on every saved-card path: booking payments, tips, till charges, gift-card purchases, and the add-card endpoint, so there is no unguarded side door.
|
||||
|
||||
### Customer-keyed, and the admin relay
|
||||
@@ -988,6 +990,8 @@ The single most important property is who the code belongs to. The gate verifies
|
||||
|
||||
That is why the admin cannot mint a code for themselves and pass it through. The admin relay endpoint (`POST /api/admin/users/{id}/2fa/code`) takes the target customer's ID and mints the code against the **customer's** record. A code minted against the admin's session would never match the gate's check against the card owner, so a session-scoped mint could never authorise the charge at all. The design keeps the customer as the authentication subject for their own card, always. And every admin mint-or-reuse writes an audit log entry (which admin, which customer, fresh or reused, remaining lifetime), so admin-assisted code issuance is never silent. If the code is being relayed, there is a record that it was.
|
||||
|
||||
And every charge actually authorised by the fallback writes its own strict audit row: `2fa_fallback_charge`, recording `sca_performed:false` (SCA was not performed — the charge carried no verification token), the card's last four digits, and the charge reference, so a fallback-authorised saved-card charge is always distinguishable from an SCA-authorised one ([[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] and [[#Appendix A: SCA & the Approve-in-App model|Appendix A]]).
|
||||
|
||||
### The relay model
|
||||
|
||||
Delivery today is the relay. In dev/test builds the plaintext code appears in the server log under a `[2FA]` marker, with the user ID and the code on separate lines so a single log record cannot trivially pair the two. The operator reads the customer's log line and relays it, or uses the not-yet-wired email/SMS channel once it lands.
|
||||
@@ -1132,7 +1136,7 @@ The Today page is the operational dashboard: the current and next appointments,
|
||||
|
||||
### The admin audit log
|
||||
|
||||
Every money-touching admin action writes a row to `admin_audit_log`: who did it, what kind of action it was, which customer it touched, and a details object. Today the audited actions are the four that matter most: **2fa_code_mint** (the admin relayed a verification code to a customer, with whether the code was fresh or reused), **saved_card_charge** (the admin charged a customer's saved card), **till_saved_card_charge** (the same, at the till), and **balance_check** (the admin inspected a customer's gift-card balance).
|
||||
Every money-touching admin action writes a row to `admin_audit_log`: who did it, what kind of action it was, which customer it touched, and a details object. Today the audited actions are the five that matter most: **2fa_code_mint** (the admin relayed a verification code to a customer, with whether the code was fresh or reused), **2fa_fallback_charge** (a saved-card charge authorised by the 2FA backup because SCA was unavailable — the row records `sca_performed:false`, the card's last four digits, and the charge reference), **saved_card_charge** (the admin charged a customer's saved card), **till_saved_card_charge** (the same, at the till), and **balance_check** (the admin inspected a customer's gift-card balance).
|
||||
|
||||
The writes are best-effort and non-fatal. Each audit insert runs in its own transaction, so a failed audit write rolls back only the audit write and can never abort a completed charge or a money movement. The audit log is deliberately the one part of the money path that is allowed to fail silently, because protecting the charge matters more than protecting the record of the charge. If the audit write fails, the money is still safe and the operator is still accountable through the payment row itself.
|
||||
|
||||
@@ -1279,18 +1283,18 @@ That is the decision in one line: **Square 3DS2 SCA is the primary authorisation
|
||||
|
||||
### The customer journey ("approve in your banking app")
|
||||
|
||||
1. The customer checks out with a saved card (online, or at the till on the owner's device).
|
||||
1. The customer checks out with a saved card online — their own booking flow or account page, the customer-initiated path this appendix documents.
|
||||
2. The app runs Square's buyer-verification step. The customer's bank presents the 3DS2 challenge — a push notification or in-app approval in their banking app.
|
||||
3. The customer approves. Square returns a verification token; the backend attaches it to the charge and the payment completes.
|
||||
4. If the bank approves, that charge is SCA-authenticated: the bank, not the salon, carries the fraud liability, and the whole transaction is PSR 2017-compliant without the salon doing anything else.
|
||||
|
||||
The flow is deliberately **customer-initiated** (CIT), not merchant-initiated (MIT). A merchant-initiated charge (the cardholder not present, e.g. a subscription or a scheduled recurring take) follows a different Square classification and a different SCA exemption. Crussell's saved-card charges are always customer-initiated — the owner and the customer are present together — so the CIT path is the one this appendix documents. (Chapter 14's "Using a card" already records the customer-initiated marker on every saved-card charge.)
|
||||
The flow is deliberately **customer-initiated** (CIT), not merchant-initiated (MIT). A merchant-initiated charge (the cardholder not present, e.g. a subscription or a scheduled recurring take) follows a different Square classification and a different SCA exemption. Crussell's *online* saved-card charges are customer-initiated — the customer is at the keyboard in their own booking flow or account page — so the CIT path is the one this appendix documents. The two admin surfaces are merchant-initiated instead: the till saved-card path and the admin booking "charge saved card" action both send `customer_initiated=false`, which Square reads as MIT — SCA-exempt, with no liability shift. (Chapter 14's "Using a card" records the CIT/MIT marker on every saved-card charge.)
|
||||
|
||||
### The saved-card flow: CIT vs MIT, verification tokens, and what Square says when verification is missing
|
||||
|
||||
The saved-card branch of the payment flow (`CreateTerminalPayment`'s `saved_card` path, the booking and tip flows, and the gift-card saved-card path) resolves the card's `ccof:` token, attaches the owning Square customer profile (Chapter 14), marks the charge customer-initiated, and — when SCA is the operative authorisation — carries a `verification_token` obtained from Square's buyer verification. The backend validates the token's shape before it is forwarded (`ValidateVerificationToken`) and passes it straight through to the Square request; it never evaluates the token itself, because the token's meaning is Square's and the bank's.
|
||||
The saved-card branch of the payment flow (`CreateTerminalPayment`'s `saved_card` path, the booking and tip flows, and the gift-card saved-card path) resolves the card's `ccof:` token, attaches the owning Square customer profile (Chapter 14), sets the CIT/MIT marker described above, and — when SCA is the operative authorisation — carries a `verification_token` obtained from Square's buyer verification. This is the **tokenize-before-first-charge** flow: the frontend runs the buyer-verification step *before* the first charge attempt against the card, so a saved card is never charged as a naked `ccof:` without SCA. The backend validates the token's shape before it is forwarded (`ValidateVerificationToken`) and passes it straight through to the Square request; it never evaluates the token itself, because the token's meaning is Square's and the bank's.
|
||||
|
||||
When a charge is declined because the buyer could not be verified, Square answers with a specific structured code: **`CARD_DECLINED_VERIFICATION_REQUIRED`** (alongside the sibling codes `VERIFICATION_TOKEN_EXPIRED`, `VERIFICATION_TOKEN_INVALID`, `CVV_VERIFICATION_REQUIRED`, `ADDRESS_VERIFICATION_REQUIRED`, `MISSING_VERIFICATION_TOKEN`). The backend's error classification treats all of these as **definitive** — the same request can never succeed by retrying it; the buyer must re-verify or the card be re-[tokenized](https://en.wikipedia.org/wiki/Tokenization_(data_security)) (Chapter 9 explains why definitive failures are never retried by the sweeps). The dev mock mirrors the behaviour through its `SimulateVerificationRequired` toggle, so the SCA-required failure mode is exercisable in development.
|
||||
When a charge is declined because the buyer could not be verified, Square answers with a specific structured code: **`CARD_DECLINED_VERIFICATION_REQUIRED`**, alongside the sibling SCA-challenge codes `VERIFICATION_TOKEN_EXPIRED`, `VERIFICATION_TOKEN_INVALID`, and `MISSING_VERIFICATION_TOKEN`. Those four are the complete SCA-challenge set — CVV and address re-entry codes such as `CVV_VERIFICATION_REQUIRED` are deliberately not part of it, because they mean re-entering card data, not a 3DS challenge. The backend's error classification treats all four as **definitive** — the same request can never succeed by retrying it; the buyer must re-verify or the card be re-[tokenized](https://en.wikipedia.org/wiki/Tokenization_(data_security)) (Chapter 9 explains why definitive failures are never retried by the sweeps). The dev mock mirrors the behaviour through its `SimulateSavedCardVerificationRequired` toggle, so the SCA-required failure mode for saved-card charges is exercisable in development.
|
||||
|
||||
### The 2FA fallback policy: backup-only, when it fires, and the audit trail
|
||||
|
||||
@@ -1305,6 +1309,7 @@ And because it is now a fallback on a regulated path, it carries a **strict audi
|
||||
|
||||
- Every **admin** mint-or-reuse of a code for a customer writes an `admin_audit_log` row (`2fa_code_mint`, with whether the code was fresh or reused and its remaining lifetime) — `POST /api/admin/users/{id}/2fa/code`. The admin relay mints against the **customer**, so the audit row names who authorised whom.
|
||||
- Every **admin** saved-card charge writes its own audit row: `saved_card_charge` for the online path and `till_saved_card_charge` for the till (`insertAdminAuditCharge`, described in Chapter 17). The two rows together let the owner reconstruct, for any fallback-authorised charge, who minted the code, who charged the card, and with whose authorisation.
|
||||
- Every charge actually **authorised by the 2FA fallback** (SCA unavailable) writes an additional `2fa_fallback_charge` row (`insertTwoFAFallbackAudit`): `sca_performed:false`, `fallback_reason:"verification_unavailable"`, the card's last four digits, and the charge reference — so the operator can tell a fallback-authorised charge apart from an SCA-authorised one at a glance.
|
||||
- A customer's own code requests (`POST /api/user/2fa/code`) are rate-limited per user and logged like every other 2FA delivery.
|
||||
|
||||
The rule for the operator: **when a customer's bank cannot do SCA, the fallback code flow is the supported path — but every step of it is recorded, and the plaintext code must reach the customer through the configured delivery channel, never a guess.**
|
||||
|
||||
Reference in New Issue
Block a user