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:
Vendored
+22
@@ -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
|
||||
}
|
||||
Vendored
+22
-17
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -158,16 +158,16 @@ Anonymous rate cap: max 50 reservations per IP in 10 minutes.
|
||||
|
||||
## 2. Payments
|
||||
|
||||
Multi-method payment system accepting Square (card terminal & online), cash, gift cards, and saved cards. Supports deposits, full/partial payments, tips on completed bookings, and refunds with notice-period tiers.
|
||||
Multi-method payment system accepting Square (card terminal & online), cash, gift cards, and saved cards. Supports deposits, full/partial payments, tips on completed bookings, and refunds with notice-period tiers. Online card payments — new **or saved** — are authenticated by Square PSD2 SCA (buyer verification / approve-in-app) as the primary authorisation, with the homegrown 2FA code gate as the backup when a customer's bank cannot run SCA ([[#2.11 SCA & the 2FA Backup]]).
|
||||
|
||||
**Related:** [[Booking System|1. Booking System]] (deposits), [[Gift Cards|4. Gift Cards]] (pay by gift card), [[Admin Dashboard|5. Admin Dashboard]] (till purchases)
|
||||
|
||||
### 2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK)
|
||||
**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. Saving a card also provisions a Square customer profile (P14), reused for subsequent saves. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`), which renders a plain HTML card form and mints the same `cnon:` tokens the backend dev mock accepts — a full as-if-live walkthrough with zero credentials; without credentials or mock mode, new-card entry is gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips.
|
||||
**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. Every online charge is authenticated by Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`): the Web Payments SDK returns a **verification token**, the backend validates and passes it through to Square, and the customer approves in their banking app. This applies to saved-card (customer-initiated, CIT) charges as well as new cards — the charge is marked `customer_details.customer_initiated=true` so Square classifies it for SCA and liability shift. If the buyer cannot be verified, Square declines with `CARD_DECLINED_VERIFICATION_REQUIRED` (a definitive error — the buyer must re-verify; the dev mock mirrors it via its verification toggle). When a customer's bank cannot run SCA, the homegrown **2FA code gate is the backup authorisation** ([[#2.11 SCA & the 2FA Backup]]). Saving a card also provisions a Square customer profile (P14), reused for subsequent saves. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`), which renders a plain HTML card form and mints the same `cnon:` tokens the backend dev mock accepts — a full as-if-live walkthrough with zero credentials; without credentials or mock mode, new-card entry is gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips.
|
||||
|
||||
**Layman summary:** "Pay online with your card — just like any online shop."
|
||||
**Layman summary:** "Pay online with your card — just like any online shop. Your bank may ask you to approve the payment in your banking app."
|
||||
|
||||
**Related:** [[Saved Cards|2.5 Saved Cards]], [[Double-Payment Prevention|2.9 Double-Payment Prevention]]
|
||||
**Related:** [[Saved Cards|2.5 Saved Cards]], [[Double-Payment Prevention|2.9 Double-Payment Prevention]], [[SCA & the 2FA Backup|2.11 SCA & the 2FA Backup]]
|
||||
|
||||
### 2.2 Square Terminal (In-Person Card)
|
||||
**What it does:** Admin initiates a card payment on the Square Terminal. Customer taps or inserts their card at the terminal. Admin polls for completion.
|
||||
@@ -191,11 +191,11 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif
|
||||
**Related:** [[Gift Cards|4. Gift Cards]], [[VAT Calculation|2.10 VAT Calculation]]
|
||||
|
||||
### 2.5 Saved Cards
|
||||
**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). Saving a card also provisions a Square customer profile (P14) — `square_customer_id` is stored on the row and reused for subsequent saves. The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (a Web Payments SDK `cnon:` nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile`. When frontend Square credentials are unset and mock mode is off (local dev), add-card shows the `CardEntryUnavailable` notice; with `VITE_SQUARE_ENVIRONMENT=mock` it uses the frontend mock form instead (saved mock cards appear as `ccof:mock_*` rows in the dev DB).
|
||||
**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). Saving a card also provisions a Square customer profile (P14) — `square_customer_id` is stored on the row and reused for subsequent saves. The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (a Web Payments SDK `cnon:` nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile`. Charging a saved card is authenticated by Square PSD2 SCA as the primary authorisation (a customer-initiated stored-credential charge carries a verification token; see [[#2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK)|2.1]]); when a customer's bank cannot run SCA, the 2FA code gate (customer-keyed, single-use, fail-closed) is the backup — see [[#2.11 SCA & the 2FA Backup]]. Adding a card is itself 2FA-gated wherever the backup gate is enforced. When frontend Square credentials are unset and mock mode is off (local dev), add-card shows the `CardEntryUnavailable` notice; with `VITE_SQUARE_ENVIRONMENT=mock` it uses the frontend mock form instead (saved mock cards appear as `ccof:mock_*` rows in the dev DB).
|
||||
|
||||
**Layman summary:** "Save your card for next time — one-click payment."
|
||||
|
||||
**Related:** [[GDPR & Compliance|9. GDPR & Compliance]] (financial data retention), [[Frontend Architecture|15. Frontend Architecture]] (Cards tab)
|
||||
**Related:** [[GDPR & Compliance|9. GDPR & Compliance]] (financial data retention), [[Frontend Architecture|15. Frontend Architecture]] (Cards tab), [[SCA & the 2FA Backup|2.11 SCA & the 2FA Backup]]
|
||||
|
||||
### 2.6 Tips
|
||||
**What it does:** Customers can add a tip to a completed booking. Available as percentage presets (10%/15%/20%) or custom amount. Cash tip via "keep change as tip" checkbox.
|
||||
@@ -232,6 +232,13 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif
|
||||
|
||||
**Related:** [[VAT Treatment (SPV vs MPV)|4.7 VAT Treatment]], [[Business Settings|5.6 Business Settings]]
|
||||
|
||||
### 2.11 SCA & the 2FA Backup
|
||||
**What it does:** Online card payments are authenticated by Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`). For new cards, the Web Payments SDK issues a verification token at card entry. For **saved cards**, the charge is a customer-initiated transaction (CIT — the charge carries `customer_details.customer_initiated=true`), so PSR 2017 applies and Square's buyer verification is the **primary authorisation**: the customer approves in their banking app, the verification token is passed through to Square, and chargeback liability shifts to the card scheme. If the buyer cannot be verified, Square declines with `CARD_DECLINED_VERIFICATION_REQUIRED`, a definitive error: the customer must re-verify or the card be re-tokenized (a same-request retry never succeeds). The homegrown **2FA gate is the backup**, used only when SCA is unavailable (e.g. the customer's bank does not support in-app approval). It is customer-keyed (the code verifies against the card owner, never the admin session), single-use (one code authorises one charge; a failed charge re-mints), fail-closed (`REQUIRE_2FA` defaults ON outside dev/mock environments), and fully audited: every admin mint-or-reuse writes an `admin_audit_log` row (`2fa_code_mint`), and every admin saved-card charge writes its own row (`saved_card_charge` / `till_saved_card_charge`). Delivery: email/SMS is the intended channel (method chosen at setup), not yet wired (P6); until then, production codes are delivered via the opt-in `[2FA]` server-log relay (`TWO_FACTOR_ALLOW_LOG_DELIVERY=true`), and production issuance fails closed (503) without it.
|
||||
|
||||
**Layman summary:** "Paying online is approved by your bank through your banking app. If your bank can't do that, the salon uses a one-time code instead — and every use is logged."
|
||||
|
||||
**Related:** [[Online Card Payment|2.1 Online Card Payment]], [[Saved Cards|2.5 Saved Cards]], [[Authentication & Security|8. Authentication & Security]], [[GDPR & Compliance|9. GDPR & Compliance]] (admin audit log)
|
||||
|
||||
---
|
||||
|
||||
## 3. Availability & Scheduling
|
||||
@@ -367,7 +374,7 @@ Physical and digital gift cards with multi-method purchase, 24-month rolling exp
|
||||
**Related:** [[Admin Dashboard|5. Admin Dashboard]] (Gift Card Management), [[Admin Audit Trail|4.9 Admin Audit Trail]]
|
||||
|
||||
### 4.7 VAT Treatment (SPV vs MPV)
|
||||
**What it does:** Gift cards can be configured as Single-Purpose Vouchers (VAT charged at purchase) or Multi-Purpose Vouchers (VAT charged at redemption). Default is SPV.
|
||||
**What it does:** Gift cards are Single-Purpose Vouchers (VAT charged at purchase) — a salon-only gift card is an SPV under HMRC law, so a stored Multi-Purpose Voucher (MPV) setting is overridden to SPV at read time. Default and only effective type is SPV.
|
||||
|
||||
**Layman summary:** "VAT is handled differently depending on the gift card type — charged at purchase or at use."
|
||||
|
||||
@@ -452,7 +459,7 @@ The central management hub for salon operations — managing users, bookings, se
|
||||
**Related:** [[Gift Cards|4. Gift Cards]], [[Till Purchases (POS)|5.9 Till Purchases]], [[Expired Balance Recovery|4.6 Expired Balance Recovery]]
|
||||
|
||||
### 5.6 Business Settings
|
||||
**What it does:** Configure business name, address, VAT rate, gift card expiry months, and voucher type (SPV/MPV).
|
||||
**What it does:** Configure business name, address, VAT rate, gift card expiry months, and voucher type (SPV; a stored MPV setting is overridden to SPV at read time).
|
||||
|
||||
**Layman summary:** "Salon settings — VAT, gift cards, and contact info."
|
||||
|
||||
@@ -573,7 +580,7 @@ The staff's main daily dashboard for managing appointments in real time.
|
||||
|
||||
## 8. Authentication & Security
|
||||
|
||||
JWT-based authentication with refresh token rotation, role-based access control, progressive rate limiting, and account lockout.
|
||||
JWT-based authentication with refresh token rotation, role-based access control, progressive rate limiting, account lockout, and the 2FA code backup for saved-card payments ([[#2.11 SCA & the 2FA Backup]]).
|
||||
|
||||
**Related:** [[Frontend Architecture|15. Frontend Architecture]] (auth store), [[GDPR & Compliance|9. GDPR & Compliance]] (data export includes session data)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Square integration has two build-tagged implementations:
|
||||
|
||||
Saved cards stored in `user_saved_cards` with soft delete (`retained_until` for 7-year UK compliance). Refunds tracked in `refunds` table — partial or full. Square webhooks at `/webhooks/square` (registered on the router root, proxied exact-match by nginx — not under `/api`) are HMAC-verified **fail-closed** (503 without the signing key, 403 on bad signature) and deduplicated by `event_id`: a fast-path in-memory cache plus a `square_webhook_events` DB row committed **after** dispatch, so delivery is at-least-once and Square retries on any failure. Events dispatch to state-mutating handlers that reconcile `payments`, `till_sales`, `refunds`, and `disputes` — a lost dispute marks the payment failed and a `critical_payment_log` admin notification is always raised, even when the disputed payment is not tracked locally (no sweep fallback exists for disputes). The background sweeps remain as the eventual backstop.
|
||||
|
||||
**2FA on online card payments:** a two-factor authorization feature that acts as a **merchant-level authorization gate on saved-card payments — NOT PSD2 SCA**. Square buyer verification via `tokenizeWithVerification` is the SCA mechanism, wired for new-card charges; the gate is retained as an additional fraud control until Square buyer verification is wired for saved-card charges. Charging a **saved card** requires the user to have 2FA enabled when it is enforced (enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced — and disabled only by `REQUIRE_2FA=false` (case-insensitive, also `0`/`off`/`no`); new-card/nonce charges are not gated). The 6-digit code is delivered via the server log (`[2FA]` prefix) in ALL modes — the operator reads it and relays it to the customer — standing in for real email/SMS delivery until that infrastructure lands (P6). In unenforced/dev mode the setup response also returns the code, so the flow is testable without grepping backend logs; there is no email/SMS transport yet. UI: Account → Two-Factor Authentication. Details in the [[Technical Manual]].
|
||||
**SCA & 2FA on online card payments:** Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`) is the **primary authorisation** for online card payments — **both new-card and saved-card** customer-initiated charges. For a saved card (a stored `ccof:` credential) the charge is a PSR 2017-regulated CIT; Square's verification token satisfies SCA and shifts chargeback liability to the card scheme, and the customer approves in their banking app ("approve-in-app"). The homegrown **2FA gate is now the backup**, firing only when SCA is unavailable (e.g. the customer's bank does not support in-app approval). Charging a **saved card** on the fallback path requires the user to have 2FA enabled when it is enforced (enforcement is **fail-closed**: ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced — and disabled only by `REQUIRE_2FA=false` (case-insensitive, also `0`/`off`/`no`); new-card/nonce charges are not gated). The fallback carries a strict audit trail: every admin mint-or-reuse is logged (`2fa_code_mint`, fresh-or-reused + remaining lifetime) and every admin saved-card charge writes its own audit row (`saved_card_charge` / `till_saved_card_charge`). The intended 2FA delivery channel is **email/SMS** (the method chosen at setup), **not yet wired** (P6). Until it lands, the 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) **only** when the operator explicitly opts in with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` — production issuance otherwise fails closed (503) so no user can complete 2FA setup, and every enforced fallback saved-card payment 403s. In unenforced/dev mode the setup response also returns the code, so the flow is testable without grepping backend logs; there is no email/SMS transport yet. UI: Account → Two-Factor Authentication. Details in the [[Technical Manual]].
|
||||
|
||||
Fees column on `payments` stores actual Square deductions. **`square_deposits` (and the `generate_square_deposit_id()` function) were dead schema with zero Go references, a placeholder for Square bank reconciliation against Mettle; they were dropped from `init-scripts/init-script.sql` in the fresh-DB recreate (backlog T1 closed). Mettle/FreeAgent integration is a planned upcoming body of work.**
|
||||
|
||||
@@ -55,7 +55,7 @@ Expiry is 24 months from last use (not from purchase). Each use resets the timer
|
||||
|
||||
Accounts idle 2+ years (no balance) or 5+ years (with balance) are anonymized. Balances before deletion move to `gift_card_expired_balances`. `CleanupIdleAccounts()` runs on availability fetch.
|
||||
|
||||
VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) by default — VAT charged at purchase, not redemption. Configurable to Multi-Purpose Voucher (MPV) in business settings. Gift card purchases now insert a pending payment record with VAT applied before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record rather than losing the payment. Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds, `sweep-stale-pending-payments` fails stale pending payments and till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour.
|
||||
VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) under HMRC law — VAT charged at purchase, not redemption. A stored Multi-Purpose Voucher (MPV) setting is overridden to SPV at read time, because a salon-only gift card is an SPV by definition (HMRC VAT Notice 700/7). Gift card purchases now insert a pending payment record with VAT applied before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record rather than losing the payment. Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds, `sweep-stale-pending-payments` fails stale pending payments and till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour.
|
||||
|
||||
### Scheduling
|
||||
|
||||
@@ -133,7 +133,7 @@ Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `a
|
||||
- **No error tracking** — Sentry DSN not configured, `log.Printf()` only
|
||||
- **No automated DB backups** — no `pg_dump` cron or point-in-time recovery
|
||||
- **No API documentation** — no OpenAPI/Swagger spec
|
||||
- **L3 progressive rate limiting** — per-IP dual-window (30 req/5s burst + 120 req/60s sustained) on login/register. Account lockout after 5 failures (progressive 15min→2h).
|
||||
- **L3 progressive rate limiting** — per-IP dual-window (30 req/5s burst + 120 req/60s sustained) on login/register. Account lockout after 5 failures (15min, escalating to 30min at 7+ failures).
|
||||
- **A11y checks via Svelte 5 compiler** — ESLint a11y plugin rules removed; compiler built-in checks used instead
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -284,7 +284,7 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
|
||||
| GET | `/api/services/popular` | Optional | 120/min | List active services sorted by booking popularity (last 6mo), then price desc |
|
||||
| GET | `/api/services/eligible-for/{user_id}` | Admin | 120/min | Services filtered by user's age/patch test |
|
||||
| POST | `/api/register` | None | 10/min | Create user account (optional `referralCode` field) |
|
||||
| POST | `/api/login` | None | ProgressiveRateLimit + RateLimit(10, 1min) | Authenticate, receive JWT + refreshToken. Account lockout after 5 failures (15min→30min→1h→2h). |
|
||||
| POST | `/api/login` | None | ProgressiveRateLimit + RateLimit(10, 1min) | Authenticate, receive JWT + refreshToken. Account lockout after 5 failures (15min, escalating to 30min at 7+ failures). |
|
||||
| POST | `/api/verify/generate` | None | — | Generate email verification or password reset code |
|
||||
| POST | `/api/verify/check` | None | — | Verify code |
|
||||
| GET | `/api/health` | None | — | Health check (DB, S3, Square, frontend status) |
|
||||
@@ -638,8 +638,8 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
|
||||
- **Card-machine checkout lifecycle:** a checkout created but never committed to the DB is cancelled on request failure, so a tracking failure cannot orphan a live terminal charge. The `sweep-stale-terminal-checkouts` background sweep cancels any card-machine checkout still pending at Square after an hour.
|
||||
|
||||
**VAT Treatment:**
|
||||
- SPV: VAT charged at purchase, not at redemption (default)
|
||||
- MPV: VAT charged at redemption (configurable via `business_settings.voucher_type`)
|
||||
- SPV: VAT charged at purchase, not at redemption (default and only effective type — a stored MPV is overridden to SPV at read time)
|
||||
- MPV: not available for this business — a salon-only gift card is an SPV under HMRC VAT Notice 700/7
|
||||
- `apply_vat_to_till_sale()` function handles VAT calculation for till sales
|
||||
|
||||
**Decision:** 24-month rolling expiry (not fixed) matches the CMA's 24-month industry standard and avoids an unfair-contract-term challenge under the Consumer Rights Act 2015. The `gift_card_expired_balances` table stores only account ID + amount (no PII) — indefinite retention by design, with no claim deadline.
|
||||
@@ -823,20 +823,25 @@ validTransitions := map[string]map[string]bool{
|
||||
|
||||
---
|
||||
|
||||
### Two-Factor Authentication (2FA) — merchant-level authorization gate (not PSD2 SCA)
|
||||
### Two-Factor Authentication (2FA) — backup authorisation for saved-card charges (SCA-primary)
|
||||
|
||||
**What it is:** a two-factor authorization feature that acts as a **merchant-level authorization gate on saved-card payments**. It is **NOT PSD2 SCA**: Square buyer verification via `tokenizeWithVerification` is the SCA mechanism, wired for new-card charges. The gate is retained as an additional fraud control until Square buyer verification is wired for saved-card charges. Enabling it is optional per-user; when enforcement is active, a user who has **not** enabled 2FA is blocked (403 JSON, parseable via `extractErrorMessage`) from saved-card online payment paths.
|
||||
**What it is:** a two-factor authorization feature that acts as the **backup authorisation on saved-card payments**. The **primary** authorisation is Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`), which now covers **both new-card and saved-card** charges: a customer-initiated charge against a stored credential carries Square's verification token, satisfying PSR 2017 and shifting fraud liability to the card scheme. The 2FA gate fires **only when SCA is unavailable** — the concrete case being a customer whose bank does not support the in-app approval flow — and is then the last line standing on that charge. Enabling it is optional per-user; when enforcement is active, a user who has **not** enabled 2FA is blocked (403 JSON, parseable via `extractErrorMessage`) from the saved-card paths that fall back to it.
|
||||
|
||||
**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.
|
||||
- **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.)
|
||||
|
||||
**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:** dev/test builds always write the plaintext code to the server log with a `[2FA]` prefix (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). Production builds **NEVER** log the code unless the operator explicitly opts in with `TWO_FACTOR_ALLOW_LOG_DELIVERY=true`; without it, code issuance is refused (503 / `errTwoFADeliveryUnavailable`) so no user can complete setup or disable 2FA, and every enforced saved-card payment 403s with no way forward. This is the fake delivery channel until real email/SMS infrastructure replaces that log line (P6); there is no email/SMS transport yet. 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); a fresh-code delivery resets that counter (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, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. 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.
|
||||
**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.
|
||||
|
||||
**Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`. UI: Account → Two-Factor Authentication.
|
||||
**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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1236,10 +1241,10 @@ Lockout state is stored in `users.failed_attempts` and `users.locked_until` colu
|
||||
- `vat_registration_number` — VAT number
|
||||
- `is_vat_registered` — boolean
|
||||
- `gift_card_expiry_months` — default 12 (configurable, but actual expiry logic uses 24 months)
|
||||
- `voucher_type` — `SPV` (default) or `MPV`
|
||||
- `voucher_type` — `SPV` (default); a stored `MPV` is accepted for backward compatibility but overridden to `SPV` at read time
|
||||
|
||||
**Validation:**
|
||||
- `voucher_type` must be `SPV` or `MPV`
|
||||
- `voucher_type` must be `SPV` or `MPV` (an `MPV` value is accepted but treated as `SPV` — a salon-only gift card is an SPV under HMRC VAT Notice 700/7)
|
||||
- `gift_card_expiry_months` must be a positive integer
|
||||
- `vat_registration_number` must be a valid UK VAT number: `GB` followed by 9 digits (standard) or 12 digits (branch). Previously allowed up to 20 arbitrary characters.
|
||||
- `business_email` must be 254 characters or fewer
|
||||
@@ -1348,7 +1353,7 @@ Items that must be closed before a production go-live. This is a living list; ad
|
||||
|
||||
- **Set `SUPPORT_EMAIL`.** Every consumer-facing legal doc ([[Terms & Conditions - Overall App]], [[Privacy Policy]], [[Gift Card Terms & Conditions]], and the `/terms`, `/privacy-policy`, `/cancellation-policy` routes) currently uses the `{{SUPPORT_EMAIL}}` placeholder for the support address. The real address must be substituted in **all** of those places before launch — a placeholder in a live policy is a consumer-law exposure.
|
||||
- **Legal review of the DRAFT-bannered legal docs.** The T&Cs, Privacy Policy, Gift Card Terms, and the policy routes are still drafts for go-live review; have the wording checked by a solicitor before launch.
|
||||
- **Wire real email/SMS or keep the `[2FA]` log relay.** 2FA codes are delivered via the server log until email/SMS lands (see the Two-Factor Authentication section in this manual); confirm the delivery channel before launch. In a production build the relay is **explicitly opt-in**: set `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` to deliver codes via the `[2FA]` log line, otherwise code issuance fails closed (503) and no user can complete 2FA setup or disable — every enforced saved-card online payment will 403. This is the **only** production 2FA delivery channel until email/SMS (P6) is wired, so it must be a deliberate decision at launch (with restricted log access), not a silent default.
|
||||
- **Wire email/SMS or keep the `[2FA]` log relay for the 2FA backup.** SCA (Square buyer verification) is the primary authorisation for saved-card charges; the 2FA gate fires only when a customer's bank cannot run SCA. Its codes are delivered via the server log until email/SMS lands (P6) — see the Two-Factor Authentication section in this manual. In a production build the relay is **explicitly opt-in**: set `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` to deliver codes via the `[2FA]` log line, otherwise code issuance fails closed (503) and no user can complete 2FA setup or disable — every enforced fallback saved-card payment will 403. This is the **only** production 2FA delivery channel until email/SMS (P6) is wired, so it must be a deliberate decision at launch (with restricted log access), not a silent default.
|
||||
- **Set `SNAPSHOT_ENC_KEY`.** `square_request_snapshot` rows contain buyer PII (email + `ccof:` card tokens). Without `SNAPSHOT_ENC_KEY` (base64-encoded 32-byte AES-256 key, `openssl rand -base64 32`), non-mock deployments store those rows **PLAINTEXT at rest** with only a one-time CRITICAL startup log (see `checkSnapshotEncKey`, `backend/main.go`). Money-safety first: the process does **not** fail at startup, so the misconfiguration is otherwise silent — set the key before go-live.
|
||||
- **Set `TRUST_PROXY_HEADERS=true`.** The backend is deployed behind nginx and/or Cloudflare, which overwrite `X-Real-IP`/`CF-Connecting-IP` with the real client IP. `TRUST_PROXY_HEADERS` defaults to false; without it every per-IP rate-limit key collapses onto the proxy's IP and any one client can exhaust the shared per-IP budget for everyone (and per-IP limiter protection is effectively bypassed). Keep it false only when the backend is origin-exposed. The var ships via `.env` (`env_file` in `compose.yml`) — `compose.yml` deliberately never sets it, the operator decides per deployment.
|
||||
- **Treatment/safety notes retention — operator assertion (documented residual risk).** The privacy-policy route promises notes are "retained in a form that cannot be traced back to you" (de-identified at account erasure). This is a **business decision, not a technical guarantee**: notes are free-text `TEXT` (no backend PII validation, UI-capped at 1,000,000 chars) and are kept on the anonymised booking row after `anonymize_user` wipes the surrounding record. The operator asserts notes never contain direct identifiers. The residual risk is that a note entered with a name/phone/address could still re-identify the customer after erasure — see the Admin Manual procedure ("never enter direct identifiers in notes") and the `anonymize_user` RETENTION POLICY comment in `init-scripts/init-script.sql`.
|
||||
@@ -1648,9 +1653,9 @@ Saved cards use `retained_until` instead of `deleted_at`. This is because UK fin
|
||||
|
||||
The API uses pence (int64) for all monetary values to avoid floating-point precision issues. The database stores pounds as `NUMERIC(10,2)` for SQL-level precision. The conversion happens at the API boundary: pence → pounds on read, pounds → pence on write.
|
||||
|
||||
### Why gift cards are SPVs by default?
|
||||
### Why gift cards are SPVs?
|
||||
|
||||
Under UK VAT law, most salon gift cards are Single-Purpose Vouchers (SPVs) because they can only be redeemed for the salon's own services. VAT is charged at the point of purchase. This is the default behavior. Multi-Purpose Vouchers (MPVs) — where VAT is charged at redemption — are configurable for future flexibility.
|
||||
Under UK VAT law, salon gift cards are Single-Purpose Vouchers (SPVs) because they can only be redeemed for the salon's own services. VAT is charged at the point of purchase. This is the default and the only effective type: a stored Multi-Purpose Voucher (MPV) setting is overridden to SPV at read time, because a salon-only gift card is an SPV by definition (HMRC VAT Notice 700/7). Charging VAT at redemption for such a card would defer the output tax past the point of supply.
|
||||
|
||||
### Why idle account cleanup uses two thresholds?
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{}
|
||||
Reference in New Issue
Block a user