docs: README + obsidian parity with SCA-only posture, flood caps, lockout tiers; dev-script secret bootstrap, stale-backend kill, patch-test backdate
- README: payments/2FA sections rewritten for the SCA-only posture (no TWO_FACTOR_FALLBACK, tokenize-result wire contract, 402 refusal), deposit carve-out clarified, gift-card 12-hex codes + 14-day cancellation, flood-cap insert sites enumerated, escalating lockout tiers documented, ICO registration note, updated test counts (2,555 backend + 129 frontend). - local-dev-2.sh: fail-closed dev secret bootstrap (auto-generates JWT_SECRET_KEY / TWO_FACTOR_PEPPER into the gitignored .env), kills stale backends holding :8080 before the tmux reset, passes RUSTFS_ENDPOINT/GO_TESTING=1 to the dev backend, and backdates seeded patch tests 60 days so past gel bookings pass the 24h notice gate. - Obsidian manuals (Technical/Admin/User/Feature Catalog/Overview/Gift Card T&C/Privacy/T&C/Testing Architecture/payments and money processes + p14 plan + workspace state) updated to the post-round-2 state.
This commit is contained in:
Vendored
+12
-12
@@ -4,21 +4,21 @@
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "d45436e02729bff7",
|
||||
"id": "c422dfbf59aa72a3",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "7b4ed20d72674471",
|
||||
"id": "8c695507a545b704",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "markdown",
|
||||
"state": {
|
||||
"file": "Crussell/payments and money processes.md",
|
||||
"file": "Crussell/Feature Catalog.md",
|
||||
"mode": "source",
|
||||
"source": false
|
||||
},
|
||||
"icon": "lucide-file",
|
||||
"title": "payments and money processes"
|
||||
"title": "Feature Catalog"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -80,8 +80,7 @@
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "2750d7726f904ef3",
|
||||
@@ -97,7 +96,7 @@
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "Crussell/payments and money processes.md",
|
||||
"file": "Crussell/Feature Catalog.md",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
@@ -154,8 +153,7 @@
|
||||
"title": "Outline"
|
||||
}
|
||||
}
|
||||
],
|
||||
"currentTab": 3
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
@@ -173,13 +171,15 @@
|
||||
"bases:Create new base": false
|
||||
}
|
||||
},
|
||||
"active": "7b4ed20d72674471",
|
||||
"active": "8c695507a545b704",
|
||||
"lastOpenFiles": [
|
||||
"Untitled.canvas",
|
||||
"Crussell/payments and money processes.md",
|
||||
"Crussell/Feature Catalog.md",
|
||||
"Crussell/Future Work - Gap Backlog.md",
|
||||
"Double-Payment Prevention.md",
|
||||
"Untitled.canvas",
|
||||
"Crussell/Overview.md",
|
||||
"Crussell/User Manual.md",
|
||||
"Crussell/Future Work - Gap Backlog.md",
|
||||
"Crussell/Technical Manual.md",
|
||||
"Crussell/Loyalty & Discount System Reference.md",
|
||||
"Crussell/Admin Manual.md",
|
||||
|
||||
@@ -119,7 +119,7 @@ You can also adjust the price of individual services if needed — for example,
|
||||
|
||||
**Gift Card** —
|
||||
1. Click **Gift Card**
|
||||
2. Enter the 12-digit gift card ID (it formats automatically as you type: XXXX XXXX XXXX)
|
||||
2. Enter the 12-hex-character gift card ID (it formats automatically as you type: XXXX XXXX XXXX)
|
||||
3. If the gift card has a balance, the system shows the remaining amount
|
||||
4. Click **Apply Gift Card** to record the payment
|
||||
5. If the gift card balance covers part of the total, the remaining amount can be paid with another method
|
||||
@@ -355,7 +355,7 @@ When a customer calls to book over the phone:
|
||||
4. The system shows their patch test records and age — services they're not eligible for are greyed out
|
||||
5. Select the services they want — you can also add **custom services** (one-off services not in the public list) alongside regular services
|
||||
6. Pick a date and time
|
||||
7. The system holds the slot for 1 hour (call-in reservation)
|
||||
7. The system holds the slot for 15 minutes (call-in reservation)
|
||||
8. Fill in notes if needed
|
||||
9. Confirm the booking
|
||||
|
||||
@@ -367,11 +367,11 @@ When a customer walks in without a prior appointment:
|
||||
2. Click **Walk-In Booking**
|
||||
3. Select the services they want
|
||||
4. The system shows available slots for today
|
||||
5. Pick a slot — the system holds it for 5 minutes (short hold because the customer is here now)
|
||||
5. Pick a slot — the system holds it for 15 minutes (the hold is short so the customer is confirmed promptly)
|
||||
6. Fill in customer details (or select an existing customer)
|
||||
7. Confirm the booking
|
||||
|
||||
**Walk-in vs Call-in:** Walk-ins use a 5-minute reservation hold (short, because the customer is already here). Call-ins use a 1-hour hold (longer, because the customer is booking over the phone and may need time to confirm).
|
||||
**Walk-in vs Call-in:** Walk-ins and call-ins both use a 15-minute reservation hold while the booking is being completed. The hold releases automatically after 15 minutes or when the booking is confirmed.
|
||||
|
||||
---
|
||||
|
||||
@@ -585,10 +585,12 @@ When you cancel a booking (We Cancelled) or the customer cancels (Client Cancell
|
||||
|
||||
| Notice Period | What Gets Refunded |
|
||||
|---|---|
|
||||
| 72+ hours before the appointment | **Full refund** — 100% of everything paid |
|
||||
| More than 72 hours before the appointment | **Full refund** — 100% of everything paid |
|
||||
| 24-72 hours before the appointment | **Partial refund** — you keep a protected deposit (up to 50% of the total). Everything paid above that is refunded |
|
||||
| Less than 24 hours | **No refund** — all payments are retained |
|
||||
|
||||
**Deposit-lapsed eviction refunds in full:** when a `pending_release` booking is evicted because another customer claimed the slot, the system refunds **everything** the customer paid (the eviction runs with `forceFullRefund` — the slot was lost through no fault of the customer, so no cancellation fee applies). The refund follows the same original-method routing as any other refund.
|
||||
|
||||
**Override with forgiveness:** When cancelling from the admin panel, you can check:
|
||||
- **Forgive fees** — refunds 100% regardless of notice period (overrides deposit protection)
|
||||
- **Forgive no-show** — no no-show/deposit penalty recorded
|
||||
@@ -676,7 +678,7 @@ Check the booking status. If they cancelled with less than 24 hours' notice and
|
||||
### "A customer wants to know how much refund they'll get"
|
||||
|
||||
The refund tiers are:
|
||||
- **72+ hours notice:** Full refund of everything paid
|
||||
- **More than 72 hours notice:** Full refund of everything paid
|
||||
- **24-72 hours notice:** Partial refund — the salon keeps a protected deposit (up to 50% of the total)
|
||||
- **Less than 24 hours:** No refund
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ Three booking flows for creating appointments, each with its own entry point and
|
||||
| Admin call-in | 15 minutes | `RESERVATION:admin:callin:<customerID>:<nanotimestamp>` |
|
||||
| Edit request | 24 hours | `RESERVATION:edit_request:<bookingID>` |
|
||||
|
||||
Anonymous rate cap: max 50 reservations per IP in 10 minutes.
|
||||
Anonymous rate cap: max 50 anonymous reservations in any 10-minute window (counted globally across all `RESERVATION:anon:%` rows, not per IP).
|
||||
|
||||
**Layman summary:** "Different types of holds have different time limits — logged-in users get an hour, walk-in customers get 15 minutes."
|
||||
|
||||
@@ -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. 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]]).
|
||||
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 exclusively by Square PSD2 SCA (buyer verification / approve-in-app); on genuine `sca-unavailable` a saved-card charge is refused 402 `verification_required` and the payment does not go through (the customer can try again later; at the till, the customer is told they can pay online later instead) — there is no homegrown 2FA fallback for card charges ([[#2.11 SCA & the SCA-Only Posture]]).
|
||||
|
||||
**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. 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.
|
||||
**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 runs the buyer-verification flow and the resulting tokenize-result is sent as the charge source — `new_card_token`, which the backend passes to Square as `source_id` (the legacy `ccof:` + `verification_token` shape is still accepted but is no longer the primary contract) — 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). On genuine `sca-unavailable` the saved-card charge is **refused 402 `verification_required`** and the payment does not go through (the customer can try again later) — the homegrown 2FA code gate and the versioned consent notice were **removed** ([[#2.11 SCA & the SCA-Only Posture]]). 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. 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]], [[SCA & the 2FA Backup|2.11 SCA & the 2FA Backup]]
|
||||
**Related:** [[Saved Cards|2.5 Saved Cards]], [[Double-Payment Prevention|2.9 Double-Payment Prevention]], [[SCA & the SCA-Only Posture|2.11 SCA & the SCA-Only Posture]]
|
||||
|
||||
### 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.
|
||||
@@ -184,18 +184,18 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif
|
||||
**Related:** [[Tips|2.6 Tips]], [[Till Purchases (POS)|5.9 Till Purchases]]
|
||||
|
||||
### 2.4 Gift Card Payment
|
||||
**What it does:** Customers pay using a 12-digit gift card code or their account balance (balance from redeemed gift cards). VAT treatment depends on whether the card is SPV or MPV.
|
||||
**What it does:** Customers pay using a 12-hex-character gift card code or their account balance (balance from redeemed gift cards). VAT treatment depends on whether the card is SPV or MPV.
|
||||
|
||||
**Layman summary:** "Use a gift card to pay — either enter the code or use your online balance."
|
||||
|
||||
**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`. 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).
|
||||
**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 (a customer-initiated stored-credential charge carries the SCA tokenize-result as its source — `new_card_token` → Square `source_id`; see [[#2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK)|2.1]]); on genuine `sca-unavailable` the charge is refused 402 `verification_required` and the payment does not go through (the customer can try again later) — there is no 2FA fallback ([[#2.11 SCA & the SCA-Only Posture]]). Adding a card is itself SCA-gated wherever enforcement is on: the save must carry a genuine SCA tokenize-result, and a token-less or forged-token save is refused 402 `verification_required`. 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), [[SCA & the 2FA Backup|2.11 SCA & the 2FA Backup]]
|
||||
**Related:** [[GDPR & Compliance|9. GDPR & Compliance]] (financial data retention), [[Frontend Architecture|15. Frontend Architecture]] (Cards tab), [[SCA & the SCA-Only Posture|2.11 SCA & the SCA-Only Posture]]
|
||||
|
||||
### 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,10 +232,10 @@ 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.
|
||||
### 2.11 SCA & the SCA-Only Posture
|
||||
**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 authorisation: the customer approves in their banking app, the tokenize-result is sent as the charge source (`new_card_token` → Square `source_id`), 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). On genuine `sca-unavailable` the charge is **refused 402 `verification_required`** and the payment does not go through (the customer can try again later; at the till, the customer is told they can pay online later instead) — there is **no homegrown 2FA fallback for card charges** (the `TWO_FACTOR_FALLBACK` switch and the C6 versioned consent notice — `consent_version`/`consent_accepted`/403 `consent_required`/`2fa_fallback_charge` — were removed entirely; PSR 2017 reg 100 makes SCA mandatory and non-waivable). The dev Square mock simulates SCA (`SimulateSavedCardVerificationRequired` + `cnon:sca-...` tokenize-results), giving development full parity with the SCA-only production posture. Homegrown 2FA is retained for **admin and account verification only** — setup, disable, and delete-account re-authentication — never for card-charge authorisation. Admin saved-card charges are audited (`saved_card_charge` / `till_saved_card_charge`). Delivery: email/SMS is the intended 2FA 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."
|
||||
**Layman summary:** "Paying online is approved by your bank through your banking app. If your bank can't complete that approval, the payment can't be processed and does not go through — you can try again later (and if you're paying at the salon, you may be asked to pay online later instead). The salon never uses a one-time code to authorise card payments."
|
||||
|
||||
**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)
|
||||
|
||||
@@ -580,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, account lockout, and the 2FA code backup for saved-card payments ([[#2.11 SCA & the 2FA Backup]]).
|
||||
JWT-based authentication with refresh token rotation, role-based access control, progressive rate limiting, account lockout, and homegrown 2FA for admin/account verification only — never for card payments, which are SCA-only ([[#2.11 SCA & the SCA-Only Posture]]).
|
||||
|
||||
**Related:** [[Frontend Architecture|15. Frontend Architecture]] (auth store), [[GDPR & Compliance|9. GDPR & Compliance]] (data export includes session data)
|
||||
|
||||
@@ -618,7 +618,7 @@ JWT-based authentication with refresh token rotation, role-based access control,
|
||||
**Related:** [[Account Lockout|8.5 Account Lockout]]
|
||||
|
||||
### 8.5 Account Lockout
|
||||
**What it does:** After 5 failed login attempts, the account is locked for a progressively longer period (15min → 30min → 1h → 2h).
|
||||
**What it does:** After 5 failed login attempts, the account is locked — 15 minutes at 5 failures, 30 minutes at 7+ failures, 60 minutes at 10+ (the ceiling; per-account, keyed on `users.failed_attempts`/`users.locked_until` — an attacker who keeps guessing makes the lock LONGER). Locked responses are byte-identical 401 "invalid credentials".
|
||||
|
||||
**Layman summary:** "Too many wrong passwords? You'll be locked out for a while."
|
||||
|
||||
@@ -649,10 +649,12 @@ JWT-based authentication with refresh token rotation, role-based access control,
|
||||
|
||||
Full compliance with UK GDPR, including Article 15 data export, right to erasure, and data retention policies.
|
||||
|
||||
**ICO registration is an operator responsibility:** the data controller (the sole-trader salon) must register with the Information Commissioner's Office (ICO) and pay the data-protection fee unless exempt, before processing personal data at scale. This is an operator task — nothing in the app registers the business (see the Technical Manual's Pre-Launch Checklist and the Privacy Policy).
|
||||
|
||||
**Related:** [[Authentication & Security|8. Authentication & Security]], [[Background Jobs|13. Background Jobs]] (anonymization, financial cleanup), [[Notifications|11. Notifications]] (user_id nullification)
|
||||
|
||||
### 9.1 GDPR Data Export (Article 15)
|
||||
**What it does:** Customers can download all their personal data as a 23-section JSON file and PDF. Export runs in the background with 12-hour caching. Includes: profile, bookings, payments, patch tests, referrals (referred by + referred users), referral discounts, notification preferences, saved cards, refunds, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, forgiven no-shows, gift card balance, gift card transactions, gift cards, admin audit log, login history, name history, plus export metadata. Verification codes are explicitly excluded as authentication tokens.
|
||||
**What it does:** Customers can download all their personal data as a 23-section JSON file and PDF. Export runs in the background with 12-hour caching. Includes: profile, bookings, payments, patch tests, referrals (referred by + referred users), referral discounts, notification preferences, saved cards, refunds, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, forgiven no-shows, gift card balance, gift card transactions, gift cards, admin audit log, login history, name history, and disputes — plus an `export_metadata` block (exported_at, exported_by, user_id, format_version) that accompanies the export rather than being a data section. Verification codes are explicitly excluded as authentication tokens.
|
||||
|
||||
**Layman summary:** "Download everything we know about you — in one click."
|
||||
|
||||
@@ -680,7 +682,7 @@ Full compliance with UK GDPR, including Article 15 data export, right to erasure
|
||||
**Related:** [[Background Jobs|13. Background Jobs]] (cleanup-expired-financial-records)
|
||||
|
||||
### 9.5 Privacy Policy & Terms
|
||||
**What it does:** Full privacy policy, terms of service, cancellation policy, and gift card terms & conditions available as user-facing pages.
|
||||
**What it does:** Full privacy policy, terms of service, cancellation policy, and gift card terms & conditions available as user-facing pages: `/privacy-policy`, `/terms`, `/cancellation-policy`, `/gift-card-terms`, plus `/gdpr` (the Article 15 data-export report viewer). The policy routes no longer carry DRAFT banners; the cancellation-policy page is the implemented refund tiers verbatim. The `/gift-card-terms` route is linked from the site footer. **In-flow link TODO:** the gift-card purchase surfaces do not yet link the gift-card terms inline — the customer-facing "Buy a Gift Card" flow (account gift-card purchase) and the admin `GiftCardsManagement.svelte` purchase modal (owned by the frontend workstream) should each add a "Gift Card Terms" link next to the purchase/top-up forms (the redemption-confirm dialog already links `/terms`). Until then the footer link covers discovery.
|
||||
|
||||
**Layman summary:** "Legal documents — privacy, terms, and policies."
|
||||
|
||||
@@ -851,7 +853,7 @@ The platform infrastructure — Docker Compose stack, CI/CD, local development e
|
||||
|
||||
## 13. Background Jobs (Cron Scheduler)
|
||||
|
||||
A centralized cron scheduler that runs 23 maintenance jobs for cleanup, transitions, and data management.
|
||||
A centralized cron scheduler that runs 26 maintenance jobs for cleanup, transitions, and data management.
|
||||
|
||||
**Related:** [[Availability & Scheduling|3. Scheduling]] (hours apply), [[GDPR & Compliance|9. GDPR & Compliance]] (cleanup), [[Gift Cards|4. Gift Cards]] (expiry/cleanup), [[Payments|2. Payments]] (idempotency cleanup)
|
||||
|
||||
@@ -877,6 +879,7 @@ A centralized cron scheduler that runs 23 maintenance jobs for cleanup, transiti
|
||||
- **cleanup-revoked-jtis**: Clean expired revoked JWT entries
|
||||
- **cleanup-stale-login-entries**: Clean old login attempt records
|
||||
- **transition-discount-campaigns**: Advance campaign lifecycle (draft→active, active→completed)
|
||||
- **retry-square-erasures**: Retry pending Square card/customer deletions from the GDPR account-deletion outbox (hourly safety net for the async erasure path)
|
||||
|
||||
**Related:** [[Idempotency Keys|1.12 Idempotency Keys]], [[Campaign Lifecycle|6.4 Campaign Lifecycle]], [[Account Lockout|8.5 Account Lockout]]
|
||||
|
||||
@@ -884,6 +887,8 @@ A centralized cron scheduler that runs 23 maintenance jobs for cleanup, transiti
|
||||
- **00:05** — `apply-default-hours`: Apply pending scheduled hours changes
|
||||
- **02:00** — `cleanup-verification-codes`: Purge expired verification codes
|
||||
- **02:00** — `cleanup-refresh-tokens`: Purge expired refresh tokens
|
||||
- **02:30** — `sweep-square-webhook-events`: Purge Square webhook events past the retention window (daily)
|
||||
- **02:45** — `scan-critical-payment-logs`: Surface unresolved money events (stale pending payments/till sales, refunds at the retry cap) as admin notifications
|
||||
- **03:00** — `anonymize-stale-guest-accounts`: GDPR anonymize guests >6mo inactive
|
||||
- **03:30** — `cleanup-idle-accounts`: Clean idle gift card accounts (2yr/5yr)
|
||||
- **04:00** — `cleanup-expired-financial-records`: Purge records beyond 7yr retention
|
||||
|
||||
@@ -35,7 +35,7 @@ These are things that work fine in dev (with mocks) but need real implementation
|
||||
| P10 | **No automated database backups** | M (1d) | Infrastructure | PostgreSQL volume is persistent in Docker but no `pg_dump` cron, no point-in-time recovery. | Standard production DB setup task. |
|
||||
| P12 | **Square sandbox smoke test (pre-go-live gate)** | S-M (1d, once credentials available) | E2E | **BLOCKED — no real Square credentials available.** Must exercise the real API path end-to-end: new-card tokenization → payment → saved card → refund → reconcile, against Square's sandbox. Also verifies the M-8 open question (is `card.customer_id` enforced as Required?). | The dev mock cannot exercise Square's real wire contract (key-length limits, `device_options`, refund statuses, error codes). This is the sole remaining item before the production flip. See `plans/p11-square-web-payments-sdk.md` Remaining Items. |
|
||||
| P13 | **Reconcile deterministically-keyed saved-card charges** | S (2-3h) | Backend | ✅ **DONE (Aug 2026 payment hardening)** — `deriveBookingPaymentIdempotencyKey` (`handlers/payments/handlers.go`) now sequences repeatable types (`partial`) and rotates the deterministic fallback key past refunded completed rows, so refund-then-repay and equal-amount repeat charges no longer collapse. An un-refunded completed row still keeps its key, so the double-charge protection holds. See `plans/p11-square-web-payments-sdk.md` R3. | Closed by the payment-hardening review. |
|
||||
| P14 | **Square customer provisioning & consent** | S-M (1-2d) | Backend + Frontend + Docs | **IMPLEMENTED (Aug 2026)** — lazy customer provisioning on card-save, `square_customer_id` persisted + forwarded to Square as `card.customer_id`/CreatePayment `CustomerID`, one-off/guest no-customer, `/privacy-policy` route + consent pop-over, SCA verificationDetails wired across all charge flows. **Remaining:** P12 sandbox verification that Square enforces `customer_id`, and final privacy-policy copy review (route ships DRAFT-bannered). See `plans/p14-square-customer-provisioning-consent.md`. | Closed out of the deep post-implementation review (Aug 2026). |
|
||||
| P14 | **Square customer provisioning & consent** | S-M (1-2d) | Backend + Frontend + Docs | **IMPLEMENTED (Aug 2026)** — lazy customer provisioning on card-save, `square_customer_id` persisted + forwarded to Square as `card.customer_id`/CreatePayment `CustomerID`, one-off/guest no-customer, `/privacy-policy` route + consent pop-over, SCA verificationDetails wired across all charge flows. **Remaining:** P12 sandbox verification that Square enforces `customer_id`, and final privacy-policy copy review (the route no longer ships DRAFT-bannered; the `{{SUPPORT_EMAIL}}` placeholder substitution and owner/solicitor sign-off remain). See `plans/p14-square-customer-provisioning-consent.md`. | Closed out of the deep post-implementation review (Aug 2026). |
|
||||
| P15 | **Accounting integration (Mettle bank feed + FreeAgent bookkeeping export)** | M (2-3d) | Backend | **Planned upcoming body of work.** No code yet. The `square_deposits` schema (backlog T1) was the placeholder for Square batch deposit reconciliation against Mettle. | Mettle bank feed: match Square batch deposits against bank statements. FreeAgent: bookkeeping export (VAT return / P&L data) feeding the existing HMRC MTD SQL functions (see M1/M9). |
|
||||
|
||||
---
|
||||
@@ -55,8 +55,9 @@ These are missing functionality that prevents daily operations, legal compliance
|
||||
| M8 | **Business settings management UI** | M (1-2d) | Frontend | `GET/PUT /api/admin/settings` endpoints exist. No admin page — staff use curl or SQL. |
|
||||
| M9 | **CSV/Excel export for bookings/payments** | M (1d) | Backend | No endpoint for accounting software export. SQL functions exist but not wired. |
|
||||
| M10 | **CurrentAppointment action stubs** | M (1d) | Frontend | Extend and Cancel buttons on Today page are dead. Edit/TakePayment/Reschedule are already wired. |
|
||||
| M11 | **`/terms` and `/privacy` routes are draft only stubs** | S (1h) | Frontend | Login and account pages link to these — needs work. |
|
||||
| M11 | **`/terms` and `/privacy-policy` routes — final legal copy review** | S (1h) | Frontend | The routes are now substantive pages (booking/deposit/cancellation policy, refund tiers matching the code, SCA-only card-authorisation disclosure, gift-card terms at `/gift-card-terms`, ICO registration) and no longer carry DRAFT banners. What remains is the **final legal review** and substituting the `{{SUPPORT_EMAIL}}` placeholder. |
|
||||
| M12 | **Admin notifications list/acknowledge untested** | S (2-3h) | Backend | 2 tests skipped as WIP (`today_test.go:940,946`). Notification endpoints have zero coverage. |
|
||||
| M13 | **ICO registration (ops)** | S (30min, owner) | Ops | The sole-trader controller must register with the ICO and pay the data-protection fee unless exempt, before processing personal data at scale. Operator task — see the Technical Manual Pre-Launch Checklist and the Privacy Policy. |
|
||||
|
||||
---
|
||||
|
||||
@@ -79,7 +80,7 @@ These improve the experience or add features, but the business can operate witho
|
||||
| S11 | **Dark mode** | M (1-2d) | Frontend | Tailwind supports it. No toggle. |
|
||||
| S12 | **PWA support** | L (3-5d) | Frontend | No service worker, manifest, or offline support. |
|
||||
| S13 | **Recurring bookings** | L (3-5d) | Full-stack | No weekly/monthly booking support. |
|
||||
| S14 | **Gift card self-service portal** | M (1d) | Frontend | Users see balance but can't redeem without admin. |
|
||||
| S14 | ~~**Gift card self-service portal**~~ | — | Frontend | **✅ DONE (Aug 2026)** — customers redeem gift cards to their account balance themselves from Account → Gift Cards (confirmation dialog, FOR UPDATE single-shot redeem); the remaining self-service item is the in-flow Gift Card Terms link (documented in the Feature Catalog §9.5). |
|
||||
| S15 | **Admin audit trail for account anonymization** | S (1h) | Backend | No `user_anonymized` notification when admin deletes a user. |
|
||||
| S16 | **User notification of slot eviction** | S (2h) | Backend | When a booking is evicted (deposit not paid, slot reclaimed), the user is never notified. 3 TODO sites reference this. |
|
||||
| S17 | **Password reset should clear lockout state** | S (1h) | Backend | TODO in `auth/local.go:427` — currently `failed_attempts`/`locked_until` aren't cleared on password reset. |
|
||||
@@ -115,7 +116,7 @@ These don't add features but reduce maintenance cost and risk.
|
||||
- ~~**Square payments: wire prod client alongside dev mock (P1)** — `internal/square/square_http_client.go` implements the real REST client (payments, terminal checkouts, refunds, cards, list-refunds). Prod client (`internal/square/square.go`) and dev `devProdClient` (`square_dev.go`) both call real Square when `SQUARE_ENVIRONMENT=sandbox|production`; `mock` uses the in-memory client. The health endpoint reports `"mock"`/`"ok"` accordingly (was `"not_implemented"`).~~
|
||||
- ~~**Square Web Payments SDK: re-enable new-card entry with nonce-based flow (P11)** — `SquareCardInput.svelte` tokenizes cards to `cnon:` nonces via the Web Payments SDK (env-gated on `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`); all 8 flows re-enabled (tips ×3, booking payment, deposit, Buy a Gift Card, account Add Card, till `online_square`); `CardEntryUnavailable` kept only as the no-credentials fallback. See `plans/p11-square-web-payments-sdk.md`.~~
|
||||
- ~~**Square webhook signature verification — enforce always (M6)** — the webhook handler is now **fail-closed**: rejects with 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset and 403 when the signature header is missing/invalid (`handlers/webhooks/square.go`).~~
|
||||
- ~~**Fix README job count (T7)** — README updated to 25 maintenance jobs.~~
|
||||
- ~~**Fix README job count (T7)** — README updated to 26 maintenance jobs.~~
|
||||
- ~~**Fix `devProdClient` rune-arithmetic in test (T13)** — `rune('0'+idx)` replaced with `fmt.Sprintf("concurrent-key-%d", idx)`.~~
|
||||
|
||||
## Previously Completed Items (July 2026 backlog)
|
||||
@@ -124,7 +125,7 @@ These don't add features but reduce maintenance cost and risk.
|
||||
|
||||
## Previously Completed Items (June 2026 backlog)
|
||||
|
||||
- ~~Reservation/cleanup background cron~~ — All 20 jobs migrated to centralized scheduler
|
||||
- ~~Reservation/cleanup background cron~~ — All 20 jobs migrated to centralized scheduler (June 2026 count; the scheduler now runs 26 jobs)
|
||||
- ~~XSS input sanitization (partial)~~ — CSP added, error messages sanitized, ICS injection fixed
|
||||
- ~~Per-user rate limiting (partial)~~ — L3 ProgressiveRateLimit added for login/register
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Gift Card Terms & Conditions
|
||||
|
||||
**Last Updated:** August 2026
|
||||
**Status:** DRAFT — Local development (not yet in production)
|
||||
**Status:** DRAFT — Local development (not yet in production). The content tracks the implemented system (24-month rolling expiry, SPV VAT treatment, 14-day cancellation, dormant recovery); it still needs final legal review and the `{{SUPPORT_EMAIL}}` substitution before go-live. The user-facing version is served at `/gift-card-terms` on the Platform.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,11 +21,11 @@ Gift cards can be purchased:
|
||||
- **In-store:** Cash, card, or other accepted payment methods
|
||||
- **As gifts:** Purchased for another person (recipient receives card code)
|
||||
|
||||
### 3.2 Denominations
|
||||
### 2.2 Denominations
|
||||
- **Online purchases:** £10, £20, or £50 (the fixed amounts offered on the Platform).
|
||||
- **In-store purchases and top-ups:** any amount over £0, set at the salon (e.g. £1 and upwards).
|
||||
|
||||
### 3.3 VAT Treatment
|
||||
### 2.3 VAT Treatment
|
||||
- Gift cards are treated as **Single-Purpose Vouchers (SPVs)** under UK VAT law (the treatment configured in the app).
|
||||
- The salon is **not currently VAT registered**, so **no VAT is charged** on gift-card purchases or on payments today.
|
||||
- If and when the salon registers for VAT, VAT will be charged at the point of gift-card purchase (the SPV treatment), not at redemption — paying with a gift card will then attract no additional VAT (already collected at purchase).
|
||||
@@ -92,6 +92,7 @@ You can redeem a gift card in two ways:
|
||||
- Gift cards cannot be used to purchase other gift cards.
|
||||
- Gift cards are non-transferable after redemption to account.
|
||||
- Lost/stolen cards: We cannot replace unless we have record of purchase.
|
||||
- Except for the 14-day right to cancel online purchases (section 7 below) and any refund owed under our cancellation policy or consumer law, gift cards and gift-card balances are **non-refundable** and not redeemable for cash.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ Three booking flows, each with its own entry point and reservation TTL:
|
||||
| Flow | Who | Entry | Reservation TTL |
|
||||
|------|-----|-------|-----------------|
|
||||
| **Self-Service** | Customer | `/book` → BookingFlow wizard → `POST /api/bookings` | 1h (logged-in) / 10min (anonymous) |
|
||||
| **Walk-In** | Admin | Admin panel → WalkInBooking → `POST /api/admin/bookings/reserve` → WalkInCreateModal | 5min |
|
||||
| **Call-In** | Admin | Admin panel → CallInBooking → `POST /api/admin/bookings/reserve` → BookingCreateModal | 1h |
|
||||
| **Walk-In** | Admin | Admin panel → WalkInBooking → `POST /api/admin/bookings/reserve` → WalkInCreateModal | 15min |
|
||||
| **Call-In** | Admin | Admin panel → CallInBooking → `POST /api/admin/bookings/reserve` → BookingCreateModal | 15min |
|
||||
|
||||
Slot reservations stored as `time_blocker` entries with `RESERVATION:*` descriptions — no separate reservation table. They automatically participate in availability calculations. Anonymous reservation cap: 50 per 10-minute rolling window (429 if exceeded).
|
||||
Slot reservations stored as `time_blocker` entries with `RESERVATION:*` descriptions — no separate reservation table. They automatically participate in availability calculations. Anonymous reservation cap: 50 per 10-minute rolling window (429 if exceeded; counted globally across all `RESERVATION:anon:%` rows, not per IP).
|
||||
|
||||
Overlap checks now use `FOR UPDATE` row locks inside transactions — the overlap query runs inside `Begin`/`Commit` to prevent race conditions. Closing-hours validation extracted into reusable helpers: `checkClosingHours()` validates end-time against closing, `getClosingTimeForDate()` resolves closing time from either current `working_hours` or a pending staged default hours change. A shared `repo.go` provides common DB query helpers across booking handlers.
|
||||
|
||||
@@ -28,7 +28,7 @@ Idempotency keys (`idempotency_key VARCHAR(64) UNIQUE`) on bookings prevent dupl
|
||||
|
||||
### Payments
|
||||
|
||||
Multi-method payment modal for admin: Card (Square Terminal), Cash (with change calculation + "keep change as tip"), Gift Card (12-digit ID or account balance). User-facing payment modal for online deposits, partial payments, full payments, balance payments, and tips on completed bookings.
|
||||
Multi-method payment modal for admin: Card (Square Terminal), Cash (with change calculation + "keep change as tip"), Gift Card (12-hex-character ID or account balance). User-facing payment modal for online deposits, partial payments, full payments, balance payments, and tips on completed bookings.
|
||||
|
||||
Square integration has two build-tagged implementations:
|
||||
- **Dev** (`//go:build dev`): In-memory mock (`internal/square/square_dev.go`) that mirrors production PCI-DSS behaviour: accepts only `cnon:`/`ccof:` tokens (raw PANs rejected), dedups by idempotency key, rescues keyed replays by source token, classifies refund outcomes (already-processed vs declined), and exposes opt-in fault-injection toggles (`ShouldFail`, `FailAfterCommit`, `SimulateCardTokenUsed`). No real money leaves the process.
|
||||
@@ -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.
|
||||
|
||||
**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]].
|
||||
**SCA-only on online card payments:** Online card payments — **new-card and saved-card** — are authorised **exclusively** by Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`). For a saved card (a stored `ccof:` credential) the charge is a PSR 2017-regulated CIT; the tokenize-result is sent as the charge source (`new_card_token` → Square `source_id`), satisfies SCA, and shifts chargeback liability to the card scheme — the customer approves in their banking app ("approve-in-app"). On genuine `sca-unavailable` a saved-card charge is **refused 402 `verification_required`** and the payment does not go through (the customer can try again later; at the till, the customer is told they can pay online later instead). There is **no homegrown 2FA fallback for card charges** (the `TWO_FACTOR_FALLBACK` switch and C6 consent notice were removed); 2FA is retained for **admin and account verification only** — 2FA setup, disable, and delete-account re-authentication. 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), disabled only by `REQUIRE_2FA=false` (case-insensitive, also `0`/`off`/`no`). The dev Square mock simulates SCA (`SimulateSavedCardVerificationRequired` + `cnon:sca-...` tokenize-results), so development has full parity with the SCA-only production posture. Admin saved-card charges are audited (`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 or disable (card charges are unaffected — they are SCA-only). 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.**
|
||||
|
||||
@@ -51,9 +51,9 @@ Three card types:
|
||||
|
||||
Every action on a card is recorded in `gift_card_transactions` — purchase, topup, redeem, expire — with reference tracking to till sales and API calls.
|
||||
|
||||
Expiry is 24 months from last use (not from purchase). Each use resets the timer. Expired balances move to `gift_card_expired_balances` — only account ID + amount stored (no PII), recoverable by admin with audit trail. `CleanupExpiredGiftCards()` runs on every availability fetch.
|
||||
Expiry is 24 months from last use (not from purchase). Each use resets the timer. Expired balances move to `gift_card_expired_balances` — only account ID + amount stored (no PII), recoverable by admin with audit trail. `CleanupExpiredGiftCards()` runs on the centralised cron scheduler (daily at 5am).
|
||||
|
||||
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.
|
||||
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 the centralised cron scheduler (daily at 3:30am).
|
||||
|
||||
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.
|
||||
|
||||
@@ -89,7 +89,7 @@ Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `a
|
||||
|
||||
### Compliance
|
||||
|
||||
**GDPR Article 15**: Full data export via `/gdpr` frontend. Async Go endpoint (`GET /api/user/gdpr-export`) with 12h in-memory cache and background generation (navigation away doesn't cancel). 23-section JSON export: user profile, bookings with overrides, payments, refunds, saved cards, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, forgiven no-shows, patch tests, referrals, referral discounts, notification preferences, gift_card_balance, gift_card_transactions, gift_cards, admin_audit_log, login_audit, refresh_tokens, name_history, export metadata. **Verification codes excluded** (authentication tokens are not personal data under GDPR Art 15). Frontend: skeleton loading, 2s polling, styled report cards/tables, PDF export (print CSS hides navbar + verification banner), raw JSON download.
|
||||
**GDPR Article 15**: Full data export via `/gdpr` frontend. Async Go endpoint (`GET /api/user/gdpr-export`) with 12h in-memory cache and background generation (navigation away doesn't cancel). 23-section JSON export: user profile, bookings with overrides, payments, refunds, saved cards, social logins, loyalty redemptions, booking discounts, edit requests, affiliate payouts, forgiven no-shows, patch tests, referrals, referral discounts, notification preferences, gift_card_balance, gift_card_transactions, gift_cards, admin_audit_log, login_audit, refresh_tokens, name_history, and disputes — plus an `export_metadata` block (exported_at, exported_by, user_id, format_version) that is metadata accompanying the export, not a data section. **Verification codes excluded** (authentication tokens are not personal data under GDPR Art 15). Frontend: skeleton loading, 2s polling, styled report cards/tables, PDF export (print CSS hides navbar + verification banner), raw JSON download.
|
||||
|
||||
**Account deletion**: Registered users → `anonymize_user()` SQL function extended with child table PII scrubbing (social logins deleted, saved cards soft-deleted with PCI data cleared, verification codes expired, time blocker reservations scrubbed including `RESERVATION:edit_request:%` entries, edit request notes nulled, notification preferences deleted). External system scrubbing: S3 profile picture, Square saved cards. Guests → `delete_guest_user()` for full removal.
|
||||
|
||||
@@ -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 (15min, escalating to 30min at 7+ failures).
|
||||
- **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+ and 60min at 10+ — an attacker who keeps guessing makes the lock LONGER, capped at one hour).
|
||||
- **A11y checks via Svelte 5 compiler** — ESLint a11y plugin rules removed; compiler built-in checks used instead
|
||||
|
||||
## Prerequisites
|
||||
@@ -220,7 +220,7 @@ npm run dev # Dev server with HMR
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go test -tags "test,dev" ./... # 2,498 backend test functions passed (4 skipped) + 69 frontend vitest cases, as of 15 Aug 2026
|
||||
go test -tags "test,dev" ./... # 2,555 backend test functions compiled (under test,dev tags) + 129 frontend vitest cases, as of 15 Aug 2026
|
||||
go test -tags "test,dev" -v -run TestName ./... # Single test
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Last Updated:** August 2026
|
||||
**Status:** DRAFT — Local development (not yet in production). **§2.2 (Saved Cards & Square) and §3 (International Transfers) are drafted for go-live; the remaining placeholder sections still need to be made 'real' before go-live.**
|
||||
**Status:** DRAFT — Local development (not yet in production). The content is substantially complete and tracks the implemented system (saved cards & Square in §2.2, SCA-only card authorisation in §2.4, transfers in §3, retention in §4); it still needs final legal review and the `{{SUPPORT_EMAIL}}` substitution before go-live.
|
||||
|
||||
---
|
||||
|
||||
@@ -16,6 +16,8 @@ Crussell Salon
|
||||
Edinburgh, Scotland
|
||||
Email: `{{SUPPORT_EMAIL}}` *(placeholder — the real support address must be substituted before launch)*
|
||||
|
||||
**ICO registration (operator responsibility):** as a data controller, the salon must register with the Information Commissioner's Office (ICO) and pay the data-protection fee unless an exemption applies. This is the operator's responsibility — nothing in the Platform registers the business. See [ico.org.uk](https://ico.org.uk) for the fee and exemptions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data We Collect
|
||||
@@ -66,6 +68,18 @@ We collect health-related information with your **explicit consent**:
|
||||
**Legal basis:** UK GDPR Article 9(2)(a) — Explicit consent
|
||||
**Retention:** 7 years (insurance requirement); patch-test records are kept unlinked to you if your account is deleted, and allergy/access information held in your treatment notes is retained de-identified (see Retention section above)
|
||||
|
||||
### 2.4 Secure Card Authorisation (SCA Only)
|
||||
|
||||
Online card payments, including saved-card payments, are authorised exclusively through your bank's in-app approval step (Strong Customer Authentication, SCA / 3-D Secure), carried out by Square PSD2 SCA. When you pay online, your bank may ask you to approve the payment in your banking app. No saved-card payment is taken without this bank-level authentication.
|
||||
|
||||
If your bank cannot complete the SCA step, the payment cannot be processed and is refused. For online payments, this means the relevant deposit, early payment or gift-card purchase does not go through. If you are paying in person at the salon and your bank cannot complete SCA, we may ask you to pay online later instead. We do not use a one-time verification code or any other in-house fallback to authorise card payments.
|
||||
|
||||
**Lawful basis:** the SCA check is carried out under contract performance (Article 6(1)(b)) and our legitimate interest in fraud prevention (Article 6(1)(f)). No consent-based processing is used for card authorisation.
|
||||
|
||||
### 2.5 Request Snapshots (Payment Replay Records)
|
||||
|
||||
To rescue a payment that is stuck in a pending state, the Platform stores the exact payment request for replay. In sandbox/production deployments these snapshots are encrypted at rest (AES-256-GCM) under a deployment-provided key (`SNAPSHOT_ENC_KEY`). **Deployment requirement:** if the key is not set, snapshots are stored in plaintext at rest (a startup warning is logged) — the operator must set `SNAPSHOT_ENC_KEY` before go-live so buyer email and card-token data in these records is encrypted.
|
||||
|
||||
---
|
||||
|
||||
## 3. International Transfers
|
||||
@@ -89,9 +103,11 @@ Our payment processor, **Square**, is based in the United States. When you pay b
|
||||
| **Active account data** | Account active + 2 years | Legitimate interest |
|
||||
| **Inactive accounts (no balance)** | 2 years idle | GDPR storage limitation |
|
||||
| **Inactive accounts (with balance)** | 5 years idle | Scottish prescriptive period |
|
||||
| **Financial records** | 6 years (HMRC accounting requirement) + 1 year buffer (7 years total) | HMRC accounting requirement (6 years); 1-year buffer for dispute resolution |
|
||||
| **Financial records** | 7 years (HMRC record-keeping requirement) | HMRC requirement (7 years) |
|
||||
| **Saved-card references (Square)** | Until user deletes card or account is deleted (Square-side) | Contract performance (Art 6(1)(b)); card-network card-on-file rules |
|
||||
| **Allergy/health records** | 7 years | Insurance requirement |
|
||||
| **Guest booking PII** | 6 months after the appointment, then anonymized | GDPR storage limitation |
|
||||
| **Gift cards** | 24-month rolling expiry (from last use) for the card; account balances do not expire | Consumer-protection fairness; rolling-expiry terms |
|
||||
| **Dormant balances** | Indefinite (Account ID only) | Recovery mechanism |
|
||||
| **Marketing preferences** | Until withdrawn | Consent |
|
||||
|
||||
@@ -104,7 +120,7 @@ Data-retention consent is **opt-in**: it is never pre-ticked or assumed, and def
|
||||
2. If balance exists, transferred to dormant balance system.
|
||||
3. Account ID sent to you via email.
|
||||
4. Personal data anonymized (name, email, phone replaced with placeholders).
|
||||
5. Financial records retained 6 years (HMRC accounting requirement) plus a 1-year buffer, then aggregated at 7 years.
|
||||
5. Financial records retained 7 years (HMRC record-keeping requirement), then aggregated.
|
||||
6. Allergy records retained 7 years (insurance) then deleted.
|
||||
|
||||
**Saved cards:** Deleting your account also removes your saved-card references from our system and disables the corresponding card tokens at Square (see §2.2). Card transaction records for payments already made are retained per the HMRC schedule above.
|
||||
@@ -129,4 +145,6 @@ Under UK GDPR, you have the right to:
|
||||
- **Object** to processing (Article 21)
|
||||
- **Withdraw Consent** (Article 7(3))
|
||||
|
||||
To exercise these rights, contact `{{SUPPORT_EMAIL}}` *(placeholder — set before launch)*. You also have the right to complain to the Information Commissioner's Office (ICO) at any time.
|
||||
To exercise these rights, contact `{{SUPPORT_EMAIL}}` *(placeholder — set before launch)*. You also have the right to complain to the Information Commissioner's Office (ICO) at any time — via [ico.org.uk](https://ico.org.uk) or by writing to the ICO, Wycliffe House, Water Lane, Wilmslow, Cheshire SK9 5AF. If you have concerns, we would ask you to contact us first so we can try to resolve them.
|
||||
|
||||
**ICO registration:** the salon must register with the ICO as a data controller before go-live (operator responsibility).
|
||||
|
||||
@@ -144,7 +144,7 @@ The DAV service has a completely separate database connection from the rest of t
|
||||
| `/demo` | demo/+page.svelte | Demo mode |
|
||||
| `/tip` | tip/+page.svelte | Tip payment page (shared TipPayment component) |
|
||||
| `/pay-tip/[id]` | pay-tip/[id]/+page.svelte | Tip payment page — percentage-based or custom |
|
||||
| `/privacy-policy` | privacy-policy/+page.svelte | Privacy policy page (DRAFT-bannered, `?format=pdf` print path) |
|
||||
| `/privacy-policy` | privacy-policy/+page.svelte | Privacy policy page (`?format=pdf` print path) |
|
||||
| `/terms` | terms/+page.svelte | Terms & conditions page |
|
||||
| `/gdpr` | gdpr/+page.svelte | GDPR data export — skeleton loading, polling, styled reports, PDF export, JSON download |
|
||||
| `/admin/notifications` | admin/notifications/+page.svelte | Admin notifications with priority sorting, acknowledge flow |
|
||||
@@ -186,7 +186,7 @@ src/lib/components/
|
||||
│ ├── TimeSlotList.svelte # Scrollable time slot list (admin booking)
|
||||
│ └── SelectedTimeSummary.svelte # Selected date/time display
|
||||
├── payments/
|
||||
│ ├── PaymentModal.svelte # Admin payment — multi-method: Card (Terminal), Cash (change), Gift Card (12-digit ID), account balance, service price overrides, tip presets
|
||||
│ ├── PaymentModal.svelte # Admin payment — multi-method: Card (Terminal), Cash (change), Gift Card (12-hex ID), account balance, service price overrides, tip presets
|
||||
│ ├── UserPaymentModal.svelte # User payment — deposit, partial, full, balance (no tip)
|
||||
│ ├── SquareCardInput.svelte # Square Web Payments SDK card iframe → cnon: nonce tokenization
|
||||
│ ├── CardSelection.svelte # Saved-card list + "Use a new card" toggle (reusable card picker)
|
||||
@@ -286,7 +286,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, escalating to 30min at 7+ failures). |
|
||||
| POST | `/api/login` | None | ProgressiveRateLimit + RateLimit(10, 1min) | Authenticate, receive JWT + refreshToken. Account lockout after 5 failures (15min, escalating to 30min at 7+ and 60min at 10+ 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) |
|
||||
@@ -557,29 +557,31 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
|
||||
|
||||
**How it works:** Reservations are stored as `time_blocker` entries with `RESERVATION:*` descriptions. When a user selects a slot, it's temporarily blocked to prevent double-booking.
|
||||
|
||||
**4 TTL Types:**
|
||||
**Reservation TTLs (by type):**
|
||||
|
||||
| Type | TTL | Description Pattern |
|
||||
|------|-----|---------------------|
|
||||
| Logged-in user | 1 hour | `RESERVATION:user:{userID}:{timestamp}` |
|
||||
| Anonymous user | 10 minutes | `RESERVATION:anon:{ipHash}:{timestamp}` |
|
||||
| Admin walk-in | 5 minutes (15min cleanup) | `RESERVATION:admin:walkin:{customerID}:{timestamp}` |
|
||||
| Admin call-in | 1 hour (15min cleanup) | `RESERVATION:admin:callin:{customerID}:{timestamp}` |
|
||||
| Edit request | 24 hours | `RESERVATION:edit_request:{timestamp}` |
|
||||
| Admin walk-in | 15 minutes | `RESERVATION:admin:walkin:{customerID}:{timestamp}` |
|
||||
| Admin call-in | 15 minutes | `RESERVATION:admin:callin:{customerID}:{timestamp}` |
|
||||
| Edit request | 24 hours | `RESERVATION:edit_request:{bookingID}` |
|
||||
|
||||
**Anonymous Cap:** 50 reservations per 10-minute rolling window. Returns 429 if exceeded.
|
||||
The admin TTL is 15 minutes for **both** walk-in and call-in (`admin_reserve.go:75` defaults `TTLMinutes` to 15; the frontend sends `ttl_minutes: 15` from `WalkInBooking.svelte` and `BookingCreateModal.svelte`). There is no separate 5-minute walk-in or 1-hour call-in hold.
|
||||
|
||||
**Anonymous Cap:** 50 anonymous reservations per 10-minute rolling window (counted globally across all `RESERVATION:anon:%` rows, not per IP). Returns 429 if exceeded.
|
||||
|
||||
**Self-Block Fix:** `GetTimeBlockersInRange` excludes `RESERVATION:*` entries so overlap checks don't reject the user's own reservation.
|
||||
|
||||
**Cleanup:** `CleanupOldReservations()` runs on availability fetch. Deletes expired entries by type:
|
||||
**Cleanup:** `CleanupOldReservations()` runs on the centralised `jobs` scheduler (every 5 minutes). Deletes expired entries by type:
|
||||
- User reservations: > 1 hour old
|
||||
- Anonymous: > 10 minutes old
|
||||
- Admin walk-in/call-in: > 15 minutes old
|
||||
- Edit request reservations: > 24 hours old
|
||||
|
||||
**Financial cleanup:** `CleanupExpiredFinancialRecords()` runs on the same availability fetch. Aggregates expired payments/refunds into monthly stats and deletes granular records past their retention threshold.
|
||||
**Financial cleanup:** `CleanupExpiredFinancialRecords()` runs on the same centralised `jobs` scheduler (daily at 4am). Aggregates expired payments/refunds into monthly stats and deletes granular records past their retention threshold.
|
||||
|
||||
**Why designed this way:** Storing reservations in `time_blockers` means they automatically participate in availability calculations — no separate reservation table needed. TTL-based cleanup runs on the centralised `jobs` scheduler (cron-based, see below).
|
||||
**Why designed this way:** Storing reservations in `time_blockers` means they automatically participate in availability calculations — no separate reservation table needed. TTL-based cleanup runs on the centralised `jobs` scheduler (cron-based, see below), not inline on the availability fetch.
|
||||
|
||||
---
|
||||
|
||||
@@ -681,7 +683,7 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main.
|
||||
|
||||
**Payment split:** When a card payment arrives before the booking start time, `buildSplitRecords` (in `handlers/payments/handlers.go`) automatically splits a single Square charge into up to 3 payment records:
|
||||
|
||||
- **Deposit portion:** The first 50% of the booking total (minus any already deposited) is **always** carved out as `payment_type='deposit'`, regardless of payment size. A £25 payment on a £100 booking produces `[deposit=25]`; a £65 payment produces `[deposit=50, partial=15]`.
|
||||
- **Deposit portion:** The first up-to-50% of the booking total (minus anything already deposited) is carved out as `payment_type='deposit'`, so long as deposit room remains — once the booking is 50%+ paid, no further deposit record is created. A £25 payment on a £100 booking produces `[deposit=25]`; a £65 payment produces `[deposit=50, partial=15]`.
|
||||
|
||||
- **Balance portion:** The remainder first covers whatever is still owed on the booking. The label is `'balance'` (if previous payments exist and this completes it), `'full'` (if this single portion covers the entire remaining balance) or `'partial'` (if the booking still has a balance after this payment).
|
||||
|
||||
@@ -691,7 +693,7 @@ 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.
|
||||
**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's source is Square's verification tokenize-result from the frontend's proactive buyer verification (tokenize-before-first-charge — a saved card is never charged as a naked `ccof:`). **Wire contract (SCA rework):** the frontend sends the tokenize-result token as `new_card_token` (the backend passes it to Square as `source_id`), alongside the saved-card reference — not as a separate `verification_token` (`resolveChargeSource`, `charge_helpers.go`; the legacy `ccof:` + `verification_token` shape is still accepted for backward compatibility). 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. Saved-card charges are authorised **exclusively** by Square SCA — there is **no homegrown 2FA fallback** for card charges: a token-less charge is refused 402 `verification_required` (see the Two-Factor Authentication section), and 2FA is retained only for admin/account verification.
|
||||
|
||||
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).
|
||||
@@ -772,15 +774,15 @@ validTransitions := map[string]map[string]bool{
|
||||
|
||||
| Tier | Condition | Refund |
|
||||
|------|-----------|--------|
|
||||
| Full refund | >= 72h notice | 100% of amount paid |
|
||||
| Full refund | more than 72h notice (strictly; exactly 72h is partial) | 100% of amount paid |
|
||||
| Partial refund | 24-72h notice | Protected deposit kept (up to 50%), rest refunded |
|
||||
| No refund | < 24h notice (no-show) | Nothing refunded |
|
||||
| Admin forgiveness | `forgive_fees = true` | 100% refunded, overrides deposit protection |
|
||||
|
||||
**Deposit Protection Logic:**
|
||||
- The **protected deposit** is `min(amount_paid, max(total * protected_deposit_max_pct, amount_paid * required_deposit_pct))` where `protected_deposit_max_pct = 50%` and `required_deposit_pct = 20%`.
|
||||
- The **protected deposit** is `min(totalPrePaid, subtotal × ProtectedDepositMaxPct)` where `ProtectedDepositMaxPct = 50%` (`refunds.go:289`). It is capped by the booking subtotal (50%) *and* by what was actually paid, so a customer who only paid a deposit can never have more retained than they handed over.
|
||||
- In the 24-72h window: you always get back everything above the protected deposit.
|
||||
- Under 24h (no-show): the protected deposit is the maximum that can be retained. Any amount paid beyond the protected deposit is refunded.
|
||||
- Under 24h (or a no-show): **nothing is refunded** — all pre-payments are retained (`refunds.go:315-318`). There is no partial refund under 24h.
|
||||
|
||||
**Refund status semantics (APPROVED):** Square's refund statuses map to local rows through the single shared `SquareRefundStatusToLocal` (`errors.go`): COMPLETED → `completed` (terminal), FAILED/REJECTED → `failed` (terminal), PENDING/CANCELED/unknown → non-terminal (the row stays `pending` for the sweep). APPROVED is the deliberate exception: the shared mapping nominally resolves it to `completed`, the synchronous refund handlers' explicit override, because a blocking APPROVED result is final on that path. But the webhook (event-driven) treats it as NON-terminal and leaves the row `pending` with the `square_refund_id` recorded, so a later FAILED/REJECTED/CANCELED event can still demote it. COMPLETED is the only status that flips a local row to `completed`. The over-refund guard counts `completed` and in-flight `pending` refunds as money already returned (a completed refund must never be demoted out of the guard once money moved); `failed` refunds are excluded so a declined refund never blocks a retry.
|
||||
|
||||
@@ -829,29 +831,29 @@ validTransitions := map[string]map[string]bool{
|
||||
|
||||
---
|
||||
|
||||
### Two-Factor Authentication (2FA) — backup authorisation for saved-card charges (SCA-primary)
|
||||
### Two-Factor Authentication (2FA) — admin & account verification only (SCA-only saved-card posture)
|
||||
|
||||
**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.
|
||||
**What it is:** a two-factor account-verification feature used for **admin and account verification only** — 2FA setup, disable, and delete-account re-authentication — **never** to authorise a card charge. Saved-card online payments are authorised **exclusively** by Square **PSD2 SCA** (buyer verification via `tokenizeWithVerification`). A customer-initiated stored-credential charge is a PSR 2017-regulated transaction: the customer's bank runs its own 3DS2 challenge in the banking app, and the resulting tokenize-result is sent as the charge **source** (`new_card_token`, which the backend passes to Square as `source_id`) alongside the saved-card reference — not as a separate `verification_token` (the legacy `ccof:` + `verification_token` shape is still accepted for backward compatibility). Square's verification token satisfies SCA and shifts chargeback liability to the card scheme. There is **no homegrown 2FA fallback for card charges**: PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges, and a merchant-side 2FA check with no bank involvement cannot legally substitute for it (authorising a token-less charge via 2FA would leave the merchant liable for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6) compensation regardless of consent). The `TWO_FACTOR_FALLBACK` switch and the C6 versioned-consent notice were **removed entirely**. A saved-card charge carrying **no** Square verification token is **refused outright** — 402 `verification_required` — and the payment does not go through (the customer can try again later; at the till, the customer is told they can pay online later instead). The dev Square mock simulates SCA (`SimulateSavedCardVerificationRequired` + `cnon:sca-...` tokenize-results), so development has full parity with the SCA-only production posture.
|
||||
|
||||
**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.
|
||||
- **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.)
|
||||
- **Mint cooldown survives the gate verify (Round 2 Loop B finding 6):** a successful gate `Check` does **not** clear the per-user mint-cooldown stamp (`LastMintAt`); the charge may still fail and `reissueTwoFACodeAfterFailedCharge` throttles against it. The stamp is cleared only at terminal success (`twofa.ConsumePendingCode`). A fresh-charge failure re-issues a code; when the re-issue is refused or skipped by the cooldown, a **per-issue-capped** `critical_payment_log` admin alert (`alertReissueFail`, one unacknowledged row per stranded user, atomic `INSERT ... WHERE NOT EXISTS`) is raised so the operator knows to mint a code manually.
|
||||
- **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 (`main.go` logs this posture at startup).
|
||||
- **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 no longer fronts any card charge, the exposure is confined to the account-verification surface.
|
||||
- **Mint cooldown survives a verify (Round 2 Loop B finding 6):** a successful `Check` does **not** clear the per-user mint-cooldown stamp (`LastMintAt`); the stamp is cleared only at terminal success (`twofa.ConsumePendingCode`). When a re-issue is refused or skipped by the cooldown, a **per-issue-capped** `critical_payment_log` admin alert (`alertReissueFail`, one unacknowledged row per stranded user, atomic `INSERT ... WHERE NOT EXISTS`) is raised so the operator knows to mint a code manually.
|
||||
- **StateFor saturation is immutable and fail-closed (Round 2 Loop B finding 3):** when the in-memory attempt map is at capacity (`MaxTrackedAttempts`), `StateFor` returns the shared `saturatedLockedState` for every untracked user, a permanently-locked state (brute force impossible), never a fresh per-request budget. It is immutable: `LastMintAt` writes via `SetLastMintAtLocked` are no-ops (one user's mint must not throttle everyone) and `ClearMintCooldownForUser` is a no-op on it. Eviction never drops an in-window record with a non-zero attempt counter, which would reset a genuine user's counter and grant a fresh guessing budget. The map drains as locked-out windows lapse.
|
||||
|
||||
**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).
|
||||
**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 2FA setup or disable — 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.
|
||||
**The saved-card gate (SCA-only):** `requireTwoFactorForCardAccess` / `requireTwoFactorForCardAccessWithTokenValidation` (`handlers/payments/twofa.go`) has one job: **require SCA**. In an enforced environment a saved-card charge must carry a Square verification token (or be a tokenize-result source) — a token-less charge is refused 402 `verification_required` up front (`writeVerificationRequiredResponse`, `errors.go`), and **no 2FA code can authorise it** (the homegrown fallback was removed; `fallbackUsed` is always false). A charge that carries a token skips the gate entirely: the issuer already did SCA, so the gate is never consulted. On the customer-initiated online paths the frontend runs Square's buyer verification proactively (tokenize-before-first-charge), so the charge's source is the fresh tokenize-result (SCA performed) and the gate is skipped. 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. New-card (nonce) charges are not gated: the nonce itself carries Square's SCA verification. The `enforceSCAFallbackConsent` function is retained as a compile-compatible **NO-OP** (it always returns true) — the C6 consent enforcement was removed, so no 403 `consent_required` response is ever written and the `consent_version`/`consent_accepted` request fields are no longer enforced. The frontend's `ScaFallbackConsentDialog.svelte` is now a **refusal notice only**: on genuine `sca-unavailable` it shows the payment could not be processed — the online surfaces use `SCA_REFUSAL_MESSAGE_ONLINE` (the deposit, payment or purchase did not go through and can be tried again later), while only `TillPurchases` passes `SCA_REFUSAL_MESSAGE_TILL` (the customer is told they can pay online later instead) — with a single OK button that closes the flow cleanly; it never offers a verification-code alternative. 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).
|
||||
**Audit requirement (account-verification 2FA 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. 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.
|
||||
- **No `2fa_fallback_charge` rows are ever written.** The `insertTwoFAFallbackAudit` helper still exists for call-site compatibility, but every call site is guarded by `if twoFAFallbackUsed`, and the gate always returns `fallbackUsed=false` — the branches are unreachable (asserted by `twofa_test.go` and `terminal_sca_test.go`).
|
||||
- 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.
|
||||
**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 code for account verification; 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -918,7 +920,7 @@ validTransitions := map[string]map[string]bool{
|
||||
- `GET /api/admin/notifications/unread-count` — Returns `{"count": N}` for the bell icon.
|
||||
- `POST /api/admin/notifications/{id}/acknowledge` — Sets `acknowledged_at = NOW()`. Idempotent (404 if already acknowledged).
|
||||
|
||||
**Critical-log flood cap (Round 2 Loop B):** the money-critical admin-notification reasons, `critical_payment_log` and the `refresh_token_reuse` theft alert, share a global flood cap: at most `adminnotify.MaxUnacknowledgedCriticalLogs` = **100** unacknowledged rows per reason. The cap lives centrally in `internal/adminnotify` and is folded **atomically** into the INSERT at every insert site (the webhook's dispute/booking/unknown-event/orphan-replay `critical_payment_log` inserts, the JWT `refresh_token_reuse` insert, and the account-erasure Square-erasure insert), closing the count-then-insert race so concurrent events cannot overshoot together. Once the unacknowledged queue for a reason reaches the cap, further inserts are suppressed and a log line tells the operator what happened and how to re-arm: **acknowledge outstanding notifications**. Acknowledging rows via `POST /api/admin/notifications/{id}/acknowledge` drops the count below the cap and inserts resume. The 2FA reissue-fail alert is the deliberate exception: it is capped **per issue** (one unacknowledged row per stranded user) rather than globally, so one customer's alert is never suppressed by other users' rows filling the shared bucket.
|
||||
**Critical-log flood cap (Round 2 Loop B):** the money-critical admin-notification reasons, `critical_payment_log` and the `refresh_token_reuse` theft alert, share a global flood cap: at most `adminnotify.MaxUnacknowledgedCriticalLogs` = **100** unacknowledged rows per reason. The cap lives centrally in `internal/adminnotify` and is folded **atomically** into the INSERT at **every** insert site — the webhook's dispute/booking/unknown-event/orphan-replay `critical_payment_log` inserts, the JWT `refresh_token_reuse` insert, the account-erasure Square-erasure insert, the time-blockers cleanup insert, the `scan-critical-payment-logs` job, and the payment-sweep path (`insertCriticalPaymentNotification`, `handlers/payments/sweep.go`) — closing the count-then-insert race so concurrent events cannot overshoot together. Once the unacknowledged queue for a reason reaches the cap, further inserts are suppressed and a log line tells the operator what happened and how to re-arm: **acknowledge outstanding notifications**. Acknowledging rows via `POST /api/admin/notifications/{id}/acknowledge` drops the count below the cap and inserts resume. The 2FA reissue-fail alert is the deliberate exception: it is capped **per issue** (one unacknowledged row per stranded user) rather than globally, so one customer's alert is never suppressed by other users' rows filling the shared bucket.
|
||||
|
||||
**Priority order** (SQL CASE WHEN):
|
||||
|
||||
@@ -1207,11 +1209,12 @@ This replaces the old in-memory map (pre-June 2026 security pass). The DB-backed
|
||||
3. `PUT /api/user/password` — logs the change (full session revocation is a TODO — the current system only revokes the specific JTI, not all user sessions)
|
||||
|
||||
**Account Lockout:**
|
||||
After 5 failed login attempts, the account is locked with progressive durations:
|
||||
Per-account (keyed on `users.failed_attempts` / `users.locked_until`, deliberately not per-IP). After 5 failed login attempts the account is locked, with the lockout length set at lock time:
|
||||
- 5 failures → 15 minute lockout
|
||||
- 7 failures → 30 minute lockout
|
||||
- 10 failures → 1 hour lockout
|
||||
- 20 failures → 2 hour lockout
|
||||
- 10 failures → 60 minute lockout (the ceiling — it never exceeds an hour)
|
||||
|
||||
An attacker who keeps guessing past each unlock makes the lock **LONGER** (the escalation is the point): the lock only grows with each unlock-burning failure. Locked responses are byte-identical 401 "invalid credentials", and a locked account still burns one constant-time bcrypt compare to avoid user-enumeration timing.
|
||||
|
||||
Lockout state is stored in `users.failed_attempts` and `users.locked_until` columns. Successful login resets both.
|
||||
|
||||
@@ -1340,7 +1343,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
|
||||
|
||||
### Test Coverage
|
||||
|
||||
**2,498 backend test functions compiled** (4 skipped, 0 failures) plus **69 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.
|
||||
**2,555 backend test functions compiled** (under `test,dev` tags) plus **129 frontend vitest cases** (92 plain `it(` calls + 37 `it.each` rows in `square.test.ts`) — 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 |
|
||||
|---------|--------------|
|
||||
@@ -1364,8 +1367,9 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
|
||||
Items that must be closed before a production go-live. This is a living list; add to it as gaps surface.
|
||||
|
||||
- **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 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.
|
||||
- **Register with the ICO (operator responsibility).** The data controller — the sole-trader salon — must register with the Information Commissioner's Office (ICO) and pay the data-protection fee unless an exemption applies, before processing personal data in production. This is an **operator task, not a code task**: nothing in the app registers you. See the ICO website (`ico.org.uk`) for the fee and exemptions. The Privacy Policy states this registration is the operator's responsibility.
|
||||
- **Legal review of the consumer-facing legal docs.** The T&Cs, Privacy Policy, Gift Card Terms, and the policy routes no longer carry DRAFT banners, but the wording should still be checked by a solicitor before launch.
|
||||
- **Wire email/SMS or keep the `[2FA]` log relay for account-verification 2FA.** Saved-card charges are authorised **exclusively** by Square SCA (no homegrown 2FA fallback); homegrown 2FA is retained for **admin/account verification only** (setup, disable, delete-account re-authentication). 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. 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`.
|
||||
@@ -1543,7 +1547,7 @@ sequenceDiagram
|
||||
B-->>F: 202 + "generating"
|
||||
F->>C: Show skeleton loading
|
||||
B->>DB: Call export_all_user_data()
|
||||
DB-->>B: 16-section JSON
|
||||
DB-->>B: 23-section JSON
|
||||
B->>B: Store in cache (12h TTL)
|
||||
F->>B: Poll every 2 seconds
|
||||
B-->>F: 200 + data
|
||||
@@ -1885,7 +1889,7 @@ FROM bookings b LEFT JOIN payments p ON p.booking_id = b.id WHERE b.user_id = $1
|
||||
|
||||
**How it works:** All background data maintenance jobs are registered in `RegisterAll()` (`cleanup.go`) with cron expressions. Each job runs in its own goroutine on schedule. Jobs with `Concurrency: 1` skip a tick if the previous run is still in-flight. Graceful shutdown via `sched.Shutdown()` which cancels the base context and waits for in-flight jobs to complete.
|
||||
|
||||
**Job catalogue** (all defined in `backend/internal/jobs/cleanup.go`):
|
||||
**Job catalogue** (26 jobs, all defined in `backend/internal/jobs/cleanup.go`):
|
||||
|
||||
| Frequency | Jobs | Cron |
|
||||
|-----------|------|------|
|
||||
@@ -1893,8 +1897,11 @@ FROM bookings b LEFT JOIN payments p ON p.booking_id = b.id WHERE b.user_id = $1
|
||||
| Every 5 min | Reservation cleanup, expired deposits, rate limiter cleanup, GDPR cache cleanup, **refund sweep** (`sweep-pending-square-refunds`), **stale-pending payment/till-sale sweep** (`sweep-stale-pending-payments`, offset +1 min) | `*/5 * * * *` |
|
||||
| Every 15 min | **Stale terminal checkout sweep** (`sweep-stale-terminal-checkouts`) — cancels card-machine checkouts still pending at Square after an hour | `*/15 * * * *` |
|
||||
| Hourly | Loyalty redemptions, idempotency keys, revoked JTIs, stale login entries, discount campaign auto-transition | `0 * * * *` |
|
||||
| Hourly at :17 | **Square erasure retry** (`retry-square-erasures`) — retries pending Square card/customer deletions from the GDPR account-erasure outbox | `17 * * * *` |
|
||||
| Daily 7am / 7:30am | Unpaid booking notifications (1-week overdue at 7am, 1-month overdue at 7:30am) | `0 7 * * *` / `30 7 * * *` |
|
||||
| Daily 2am | Expired verification codes, expired/revoked refresh tokens | `0 2 * * *` |
|
||||
| Daily 2:30am | **Webhook-event retention sweep** (`sweep-square-webhook-events`) — purges `square_webhook_events` dedup rows past their 90-day retention window | `30 2 * * *` |
|
||||
| Daily 2:45am | **Critical-payment log scan** (`scan-critical-payment-logs`) — surfaces unresolved money events (stale pending payments/till-sales, refunds at the retry cap) as admin notifications | `45 2 * * *` |
|
||||
| Daily 3am | Stale guest account anonymization | `0 3 * * *` |
|
||||
| Daily 3:30am | Idle account cleanup | `30 3 * * *` |
|
||||
| Daily 4am | Financial record aggregation + deletion | `0 4 * * *` |
|
||||
|
||||
@@ -37,7 +37,7 @@ You may request account deletion at any time. Upon deletion:
|
||||
|
||||
**If your account has no balance:**
|
||||
- All personal data will be anonymized or deleted.
|
||||
- Booking history will be retained for 6 years (HMRC requirement) plus a 1-year buffer, then aggregated at 7 years.
|
||||
- Booking history will be retained for 7 years (HMRC record-keeping requirement), then aggregated.
|
||||
- You will lose access to loyalty stamps, referral codes, and booking history.
|
||||
|
||||
**If your account has a balance:**
|
||||
@@ -106,7 +106,9 @@ Warnings include your Account ID for future balance recovery. Email delivery is
|
||||
### 3.2 Payment Processing
|
||||
- Card payments processed securely via Square.
|
||||
- We do not store full card details.
|
||||
- Refunds processed to original payment method within 5-10 business days.
|
||||
- **Strong Customer Authentication (SCA / 3-D Secure):** online card payments, including saved-card payments, are authenticated by Square PSD2 SCA — your bank may ask you to approve the payment in your banking app. If your bank cannot complete the secure authentication, the payment cannot be processed: for online payments the relevant deposit, early payment or gift-card purchase does not go through. If you are paying in person at the salon and your bank cannot complete SCA, we may ask you to pay online later instead. We do not use a one-time verification code or any other in-house fallback to authorise card payments. See our Privacy Policy §2.4.
|
||||
- **Chargebacks:** if you dispute a payment with your card issuer, we will respond with the booking and payment records we hold and, where the payment was SCA-authenticated, the authentication evidence. We comply with the card-scheme dispute process and may be required to refund a disputed payment if the scheme decides against us.
|
||||
- Refunds processed to the original payment method within 3-10 working days (processing times vary by card issuer).
|
||||
- **Saving a card for next time** stores a tokenised reference with our payment provider, Square (see the checkbox in the payment flow and our [[Privacy Policy]] §2.2). You can remove saved cards any time from your account. Cards are only stored when you explicitly tick "save this card".
|
||||
|
||||
### 3.3 Split Payments
|
||||
@@ -123,6 +125,13 @@ Card tips are collected through Square alongside your payment and are recorded s
|
||||
- **Record-keeping:** tip amounts, dates, and payment methods are kept with our payment records for as long as our financial records are retained.
|
||||
- **If staff are ever engaged:** tips collected by card (and the same principles apply to cash) will be allocated in full to the worker(s) who earned them within one month of the pay period, with records kept, as required by the Employment (Allocation of Tips) Act 2023 and its statutory Code of Practice (in force from 1 October 2024). Most of the Act's obligations do not bite while the owner is the only worker; this statement makes the position explicit for whenever that changes.
|
||||
|
||||
### 3.5 Liability
|
||||
|
||||
- Nothing in these Terms excludes or limits any rights you have under consumer law — including your statutory rights under the Consumer Rights Act 2015 that services be provided with reasonable care and skill, and that services match their description.
|
||||
- We are not liable for losses that were not a foreseeable consequence of a breach of these Terms, or for losses caused by events outside our reasonable control.
|
||||
- To the fullest extent permitted by law, our total liability arising out of or in connection with these Terms is limited to the total amount you have paid us for the service concerned.
|
||||
- We are not liable for the acts or omissions of third parties we rely on to provide the Platform (for example payment processors such as Square), except as required by law.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gift Cards & Account Balances
|
||||
@@ -144,6 +153,10 @@ For detailed gift card terms, see [[Gift Card Terms & Conditions]].
|
||||
- The salon is **not currently VAT registered**, so no VAT is charged on gift cards today. If and when the salon registers for VAT, VAT will be charged at the point of gift card purchase (SPV treatment), **not** at redemption.
|
||||
- When you pay with a gift card balance, no additional VAT is charged (already collected at purchase, if applicable).
|
||||
|
||||
### 4.4 Non-Refundable Except Where the Law Requires
|
||||
- Except for the 14-day right to cancel online purchases (section 5 below), and except as required by consumer law or by an express refund we offer under our cancellation policy, gift cards and gift-card balances are **non-refundable** — they are not redeemable for cash (unless required by law) and cannot be exchanged for money.
|
||||
- Where consumer law gives you a right of refund (for example the 14-day distance-purchase right), that right is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## 5. Distance Contracts & Right to Cancel
|
||||
@@ -163,3 +176,28 @@ Purchases made on our Platform (rather than face-to-face in the salon) are **dis
|
||||
- **HMRC Corporation Tax records:** **6 years** from the end of the financial year (HMRC CH14600 / Companies Act 2006 s.388). We aggregate detailed records after **7 years** to maintain a safe buffer.
|
||||
- **Scottish Contract Claims prescriptive period:** **5 years** (Prescription and Limitation (Scotland) Act 1973 s.6). Accounts with remaining balances must remain active for at least **5 years** to allow claims.
|
||||
- **GDPR Storage Limitation:** **2 years** inactivity default for accounts with no balance (legitimate interest in relationship ends).
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptable Use
|
||||
|
||||
By using the Platform you agree not to:
|
||||
- Use the Platform for any unlawful purpose, or in a way that infringes anyone else's rights.
|
||||
- Attempt to access another user's account, or to interfere with the operation of the Platform (including by automated scripts, bots, or other means).
|
||||
- Misuse the booking system (for example by repeatedly reserving slots with no intention of completing a booking, or by booking under false details).
|
||||
- Use the Platform to send spam, offensive content, or content that misleads others.
|
||||
|
||||
We may suspend or refuse service where we reasonably believe these rules are being breached, while respecting your legal rights.
|
||||
|
||||
## 7. Complaints & Dispute Resolution
|
||||
|
||||
- If you are unhappy with any part of our service, please contact us first at `{{SUPPORT_EMAIL}}` *(placeholder — set before launch)* — we will do our best to resolve your complaint fairly.
|
||||
- You can get free, impartial consumer advice from [consumeradvice.scot](https://consumeradvice.scot) (advice.scot), and you can escalate a complaint to your local Trading Standards office.
|
||||
- Claims up to £5,000 can be pursued through the Scottish courts' Simple Procedure.
|
||||
- Online dispute resolution: the European Commission's Online Dispute Resolution platform does not apply to UK-only services.
|
||||
|
||||
## 8. Governing Law
|
||||
|
||||
These Terms are governed by the laws of **Scotland**. Any dispute arising out of or in connection with these Terms is subject to the **exclusive jurisdiction of the Scottish courts**. Nothing in this clause limits your statutory rights as a consumer.
|
||||
|
||||
*This is a summary of consumer protection law of a general nature, not legal advice; please verify the position with a solicitor before going live.*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Testing Architecture & DB Management
|
||||
|
||||
**Last Updated:** August 2026 (v6 — coverage 50.4%→65.0%, 2,333 tests compiled, 4 skipped)
|
||||
**Last Updated:** August 2026 (v7 — coverage 50.4%→65.0%, 2,555 tests compiled under test,dev tags, plus 129 frontend vitest cases)
|
||||
|
||||
---
|
||||
|
||||
@@ -501,8 +501,8 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Quick check (`-count=1`) | **~2min** |
|
||||
| Packages | 25 tested, 0 failures |
|
||||
| Tests | 2,333 compiled under test,dev tags (as of 14 Aug 2026) |
|
||||
| Packages | 27 with test functions (33 total under test,dev tags), 0 failures |
|
||||
| Tests | 2,555 compiled under test,dev tags + 129 frontend vitest cases (as of 15 Aug 2026) |
|
||||
|
||||
New test additions in this batch:
|
||||
| Test | Coverage |
|
||||
@@ -521,7 +521,7 @@ New test additions in this batch:
|
||||
| `TestCancelReservation_DoesNotTouchAnonReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:anon:%` (defensive — the WHERE clause only matches `RESERVATION:user:%`) |
|
||||
| `TestCancelReservation_DoesNotTouchAdminReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:admin:%`. Pairs with the admin-side test that verifies admin cancel ignores `RESERVATION:user:%`. Proves the two endpoints are properly partitioned. |
|
||||
|
||||
**Total tests:** 2,333 compiled across all packages (4 skipped) — as of 14 Aug 2026. 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, removal of 10 dead test functions flagged by staticcheck U1000, and the Square payments test-gap round (terminal CreateCheckout-failure, GetCheckoutStatus reference_id mismatch, deadline wire shape, loyalty lock contention 409, GDPR saved-card scrubbing, ValidateAmount/isTokenLike/lock helpers direct units, buildSplitRecords tip overflow).
|
||||
**Total tests:** 2,555 `func Test` compiled under `test,dev` tags across all packages, plus 129 frontend vitest cases (92 plain `it(` + 37 `it.each` rows) — as of 15 Aug 2026. 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, removal of 10 dead test functions flagged by staticcheck U1000, and the Square payments test-gap round (terminal CreateCheckout-failure, GetCheckoutStatus reference_id mismatch, deadline wire shape, loyalty lock contention 409, GDPR saved-card scrubbing, ValidateAmount/isTokenLike/lock helpers direct units, buildSplitRecords tip overflow).
|
||||
|
||||
### What Drives Test Time
|
||||
|
||||
@@ -638,7 +638,7 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use
|
||||
|
||||
### Q: What's the total test count?
|
||||
|
||||
2,333 tests compiled across all packages (4 skipped), as of 14 Aug 2026. 0 failures.
|
||||
2,555 `func Test` compiled under `test,dev` tags across all packages, plus 129 frontend vitest cases (92 plain `it(` + 37 `it.each` rows in `square.test.ts`), as of 15 Aug 2026. 0 failures.
|
||||
|
||||
**Notable new tests:** Centralised job scheduler tests (3 — RegisterAll count, schedules, handler signatures), scheduled-cleanup handler tests (21 — NotifyUnpaidOneWeek/Month, TransitionDiscountCampaigns, CleanupExpiredVerificationCodes/RefreshTokens), GDPR export cache cleanup (4), stale login entry cleanup (4), rate limiter cleanup tests (6), rate limiter production behavior tests (6). Duplicate completion guard (idempotent second `"completed"` call), daily stamp cap (two completions same day → 1 stamp), invalid status transitions (no-show→completed rejected with 400), sequential edit (two edits in sequence), timezone independence (UTC in, UTC out — no shift), past-booking no-show guard (past confirmed booking cancelled → `client_cancelled`, not `no_show`). New closing_time tests (3), content-type middleware tests (2), clock package tests, expanded admin reserve overlap tests, expanded gift card buy flow tests with VAT, and full admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity).
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ Customers can see their gift card details in the **Gift Cards** tab:
|
||||
- Gift cards have a **24-month expiry** from the last use — each time they use it, the expiry resets
|
||||
- **Redeem to balance**: They can move the full gift card value to their account balance. Once redeemed, the funds are held in their account and can be used across multiple bookings. A confirmation dialog explains how this works and what data is stored (just the amount and their account ID — no personal information)
|
||||
- Account balances (from redeemed gift cards) are shown as available credit when they pay online or at the till
|
||||
- Customers can also cancel an online-purchased gift card within 14 days from their account (Gift Card Terms link in the site footer)
|
||||
|
||||
**What to tell the customer:** "Your gift card lasts 24 months from the last time you use it. If you want to use it across multiple bookings, you can redeem it to your account balance. That way it doesn't expire and you can use it bit by bit."
|
||||
|
||||
@@ -197,7 +198,7 @@ Customers can see their gift card details in the **Gift Cards** tab:
|
||||
|
||||
Customers can pay online in several ways:
|
||||
|
||||
**A note on saved cards:** Online payments made with a **saved card** may ask for a one-time two-factor verification code if the salon has 2FA switched on. If a customer wants to use a saved card, they can set up 2FA in advance on their **Account** page (Two-Factor Authentication section). New-card payments don't need it.
|
||||
**A note on saved cards:** Online payments made with a **saved card** are authenticated by your bank's in-app approval (Square SCA) — you approve the payment in your banking app. If your bank cannot complete that secure authentication, the payment **can't be processed** and is refused — the payment does not go through and you can try again later. We don't use a verification code or any other fallback to authorise card payments. Two-factor authentication (2FA) on your account is for account security only (like verifying your account), not for paying with a card. New-card payments carry the same bank-level authentication through the card form.
|
||||
|
||||
### Paying a Deposit
|
||||
|
||||
@@ -243,7 +244,7 @@ If they've paid anything towards the booking, the system calculates a refund bas
|
||||
|
||||
| Notice Period | What They Get Back |
|
||||
|---|---|
|
||||
| **72+ hours before the appointment** | **Full refund** — 100% of everything paid |
|
||||
| **More than 72 hours before the appointment** | **Full refund** — 100% of everything paid |
|
||||
| **24-72 hours before the appointment** | **Partial refund** — the salon keeps a protected deposit (up to 50% of the total). Everything paid above that is refunded |
|
||||
| **Less than 24 hours** | **No refund** — all payments are retained |
|
||||
|
||||
@@ -257,7 +258,7 @@ This is separate from the refund. Even if no money was paid (so no refund applie
|
||||
No penalty. The booking is cancelled with no consequences.
|
||||
|
||||
**Cancelling less than 24 hours before the appointment:**
|
||||
This counts as a late cancellation. The customer receives **3 deposit obligations**, which means they need to pay deposits before they can book again.
|
||||
This counts as a late cancellation (a no-show). If the customer now has **2 or more no-show strikes within the last 6 months** (strikes that haven't been forgiven), their account gets **3 deposit obligations**, which means they need to pay deposits before they can book again.
|
||||
|
||||
**If the booking is still pending (not yet confirmed by the salon):**
|
||||
The booking is simply deleted — no penalty.
|
||||
@@ -292,9 +293,9 @@ If a customer needs to change their appointment time:
|
||||
|
||||
There are two separate things customers call "deposits" — the **no-show penalty** (3-strike system for last-minute cancellations) and the **financial deposit** (money paid toward the booking to hold the slot).
|
||||
|
||||
### 1. No-Show Penalty (3-Strike System)
|
||||
### 1. No-Show Penalty (Deposit-Obligation System)
|
||||
|
||||
If a customer cancels with less than 24 hours' notice (without the salon's forgiveness), they get **3 deposit obligations** on their account.
|
||||
If a customer cancels with less than 24 hours' notice (without the salon's forgiveness) and this brings them to **2 or more no-show strikes within the last 6 months**, they get **3 deposit obligations** on their account. A single late cancellation on its own does not add obligations — the count is based on how many no-shows they have accumulated in the rolling 6-month window.
|
||||
|
||||
While they have deposit obligations:
|
||||
- They can only book appointments that are **at least 36 hours away**
|
||||
@@ -319,17 +320,17 @@ Some bookings need a **payment of at least 20% of the total** before the 24-hour
|
||||
|
||||
| What happens | No-Show Penalty | Money Already Paid |
|
||||
|---|---|---|
|
||||
| Cancel ≥ 72h before | None | **Full refund** |
|
||||
| Cancel 24-72h before | None | **Partial refund** — salon keeps up to 50% of the total |
|
||||
| Cancel < 24h before | 3 obligations | **No refund** |
|
||||
| Cancel more than 72h before | None | **Full refund** |
|
||||
| Cancel 24-72h before | None | **Partial refund** — salon keeps up to 50% of the total (capped at what was paid), the rest is refunded |
|
||||
| Cancel < 24h before | Counts as a no-show; 3 obligations once 2+ strikes in 6 months | **No refund** |
|
||||
| Cancel < 24h (salon forgives) | None | Depends — may get full refund |
|
||||
| Pending booking cancelled | None | No money paid |
|
||||
| Deposit deadline passed, unpaid | — | Slot becomes available to others |
|
||||
| Someone else takes the slot | — | Original booking cancelled |
|
||||
| Someone else takes the slot | — | **Full refund of everything paid** (deposit-lapsed eviction) |
|
||||
| Pay after deadline, before slot taken | — | Slot secured |
|
||||
| Complete appointment and pay | Count goes down by 1 | — |
|
||||
|
||||
**What to tell the customer:** "If you cancel with lots of notice, you'll get your money back. The closer it gets to your appointment, the less you'll get back. If you haven't paid at least 20% before the 24-hour mark, someone else could book your slot."
|
||||
**What to tell the customer:** "If you cancel with lots of notice, you'll get your money back. The closer it gets to your appointment, the less you'll get back. If you haven't paid at least 20% before the 24-hour mark, someone else could book your slot — and if they do, we refund everything you paid."
|
||||
|
||||
---
|
||||
|
||||
@@ -431,9 +432,9 @@ Yes — on their Account page, under the Gift Cards tab. It shows their balance
|
||||
|
||||
### "Can I redeem my gift card online?"
|
||||
|
||||
Not independently. They can see their balance online, but to redeem a gift card to their account balance, they need to ask the salon to do it.
|
||||
Yes — on their Account page, under **Gift Cards**, they can redeem a card to their account balance themselves. Redeeming moves the full card value into their account balance (which doesn't expire), and the card is consumed. The redeem button has a confirmation step explaining what's stored (amount + account ID only). The account gift-card area also lets them cancel an online-purchased card within 14 days where the cooling-off right applies.
|
||||
|
||||
**What to tell them:** "You can see your balance online, but to redeem it to your account, you need to ask us to do it. Just let us know when you come in."
|
||||
**What to tell them:** "Log in and go to your Account page, then Gift Cards. You can redeem a card to your balance yourself, and see the 14-day cancellation option if you bought the card online."
|
||||
|
||||
### "What happens if my gift card expires?"
|
||||
|
||||
@@ -461,17 +462,17 @@ Yes — they can request a reschedule from their Account page. The salon reviews
|
||||
|
||||
### "How do I cancel my booking?"
|
||||
|
||||
From their Account page, find the booking and select the cancel option. If they cancel less than 24 hours before the appointment without forgiveness, they get 3 deposit obligations.
|
||||
From their Account page, find the booking and select the cancel option. If they cancel less than 24 hours before the appointment without forgiveness, it counts as a no-show — if that brings them to 2+ no-show strikes in 6 months, they'll need to pay deposits on future bookings.
|
||||
|
||||
**What to tell them:** "Go to your Account page, find the booking, and click Cancel. If you cancel with less than 24 hours' notice, you'll need to pay deposits on your next 3 bookings."
|
||||
**What to tell them:** "Go to your Account page, find the booking, and click Cancel. If you cancel with less than 24 hours' notice, it counts as a no-show — too many no-shows in six months and you'll need to pay deposits to book again."
|
||||
|
||||
### "I paid a deposit but need to cancel — what happens?"
|
||||
|
||||
The refund depends on **how much notice** they give:
|
||||
|
||||
- **72+ hours notice:** Full refund — 100% back
|
||||
- **More than 72 hours notice:** Full refund — 100% back
|
||||
- **24-72 hours notice:** Partial refund — the salon keeps a protected deposit (up to 50% of the total), the rest is refunded
|
||||
- **Less than 24 hours:** No refund — all payments retained, plus 3 deposit obligations added
|
||||
- **Less than 24 hours:** No refund — all payments retained, plus a no-show strike (3 deposit obligations once 2+ strikes in 6 months)
|
||||
|
||||
The cancellation dialog on their Account page shows the exact refund breakdown before they confirm.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ The whole money domain runs on a small set of invariants, summarised in the quic
|
||||
- **Idempotency.** Deterministic request-derived keys (or client UUIDs) dedup retries against both the database and Square. ([[#Chapter 2: The Pence Convention & How Money Is Stored|Chapter 2]], [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]])
|
||||
- **Reconciliation.** Three background sweeps plus a Square webhook handler close the roughly 24-hour idempotency-key window. ([[#Chapter 7: The Reconciliation Engine, Three Background Sweeps|Chapter 7]], [[#Chapter 8: The Reconciliation Engine, Square Webhooks|Chapter 8]])
|
||||
- **Deposits, tips, refunds.** A 20% deposit protects a slot, a tip is gratuity for work already done, and refunds follow notice tiers back to the original payment method. ([[#Chapter 3: Deposits, Balances & the Three Payment Types|Chapter 3]], [[#Chapter 5: Tips (Pre-Start vs Post-Start Rules)|Chapter 5]], [[#Chapter 6: Refunds (Tiers, Original-Method Routing, Cancellation vs No-Show)|Chapter 6]])
|
||||
- **SCA and 2FA.** Saved-card charges are authenticated by Square 3DS2 SCA (approve-in-app) as the primary authorisation; the customer-keyed 2FA gate is the backup when a bank cannot run SCA, with a strict audit trail. ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]], [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]])
|
||||
- **SCA only.** Saved-card online charges are authorised exclusively by Square 3DS2 SCA (approve-in-app); on genuine `sca-unavailable` the charge is refused 402 `verification_required` and the payment does not go through (the customer can try again later) — there is no homegrown 2FA fallback for card charges. ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]], [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]])
|
||||
|
||||
---
|
||||
## Chapter 1: Why Payments Look the Way They Do (Introduction & Map)
|
||||
@@ -62,7 +62,7 @@ Every later chapter slots into one of five layers:
|
||||
1. **The money engine (ledger).** The core records and math: payment records, deposit/balance/full splits, remaining-balance calculations, refund records, loyalty and discount accounting, VAT. This is the brain.
|
||||
2. **Square integration.** The real HTTP client for production and the realistic in-memory mock for development. Token-only, idempotency keys, request snapshots stored for replay. The mock mirrors production *exactly* so failures caught in dev are real failures, not mock quirks (R1).
|
||||
3. **Reconciliation.** Three background sweeps plus a Square webhook handler. These close the gap between "Square took the money" and "we know Square took the money", so a lost HTTP response or a dropped webhook can never become an untracked charge, a double charge, or an over-refund.
|
||||
4. **Money instruments.** Gift cards, till sales, saved cards with Square customer profiles, and the 2FA charge gate that protects saved-card money.
|
||||
4. **Money instruments.** Gift cards, till sales, saved cards with Square customer profiles, and the SCA-only saved-card gate that protects saved-card money.
|
||||
5. **Trust, auth & compliance.** [JWT](https://en.wikipedia.org/wiki/JSON_Web_Token) access tokens with opaque refresh tokens, rate limiting, GDPR export and erasure, and the admin audit log.
|
||||
|
||||
The route groups in `backend/main.go` are the feature spine: each family of endpoints hangs off a handler in one of these layers. When you trace an endpoint, ask which layer it serves and which invariants it obeys.
|
||||
@@ -95,7 +95,7 @@ Money is not an island. The deposit system reaches into scheduling: a slot's ava
|
||||
|
||||
### Why this chapter matters
|
||||
|
||||
Every later chapter leans on the map and the invariants. When Chapter 3 says "the 50% carve guarantees a deposit record exists for refund protection", it is using the split-record machinery of the money engine (layer 1). When Chapter 7 says "the sweep replays the exact request body under the same idempotency key", it is relying on the Square client (layer 2) and the reconciliation engine (layer 3). When Chapter 15 says "the charge gate verifies against the card owner", it is enforcing trust (layer 5). Hold the five layers and the seven invariants in mind, and every design decision in this document becomes an answer to the same question: which failure mode is this guard buying us against?
|
||||
Every later chapter leans on the map and the invariants. When Chapter 3 says "the 50% carve guarantees a deposit record exists for refund protection", it is using the split-record machinery of the money engine (layer 1). When Chapter 7 says "the sweep replays the exact request body under the same idempotency key", it is relying on the Square client (layer 2) and the reconciliation engine (layer 3). When Chapter 15 says "the saved-card gate requires SCA", it is enforcing trust (layer 5). Hold the five layers and the seven invariants in mind, and every design decision in this document becomes an answer to the same question: which failure mode is this guard buying us against?
|
||||
|
||||
**See also:** the [[#Design Decisions at a Glance|rationale ledger]] at the end, which keys every decision to the chapter that argues it.
|
||||
|
||||
@@ -183,7 +183,7 @@ When a booking that requires a deposit is created, the customer has until 24 hou
|
||||
|
||||
- Booking is `confirmed` (or `pending` awaiting approval). Slot is protected.
|
||||
- Deadline passes with less than 20% paid. The booking moves to **`pending_release`**: still the customer's booking, but the slot is now vulnerable. Anyone else can claim it.
|
||||
- Another booking overlaps the slot. The `pending_release` booking is **evicted** to **`deposit_lapsed`**, a terminal state: the slot is gone.
|
||||
- Another booking overlaps the slot. The `pending_release` booking is **evicted** to **`deposit_lapsed`**, a terminal state: the slot is gone. **The eviction refunds everything the customer paid** — the eviction runs `ProcessCancellationRefundTx` with `forceFullRefund=true` (`bookings.go`), because the salon took the slot from the customer; the customer is never left holding money for a booking that no longer exists. The cancellation-policy page states this.
|
||||
- A payment after the deadline but *before* eviction, bringing total paid to 20% or more, **promotes the booking back to `confirmed`**. The slot is re-secured.
|
||||
|
||||
Why the two-step ladder instead of a hard "unpaid at deadline means cancelled"? A hard cancel would punish a customer whose payment is a few minutes late, and would create angry support calls for a single-person salon. The grace window lets a late-but-earnest customer keep their slot, while eviction guarantees the salon never holds an empty slot hostage against a paying customer.
|
||||
@@ -203,7 +203,7 @@ Here is the part that surprises people. When a customer pays a booking before th
|
||||
|
||||
A few concrete examples on a £100 booking: a £25 payment produces `[deposit = 25]`; a £65 payment produces `[deposit = 50, partial = 15]`; a £125 payment produces `[deposit = 50, balance = 50, tip = 25]`. All split records share the same Square payment ID, each carries a derived `-split-N` idempotency key, and only the primary record carries the Square fees.
|
||||
|
||||
Why carve at all? The refund system. Cancellation refunds are computed from notice tiers (full refund ≥72h out, protected deposit up to 50% of the total in the middle window, nothing under 24h). That middle tier needs to know exactly how much money counts as "protected deposit". The 50% carve guarantees a deposit record exists for refund protection, even if the customer paid in one lump "full" charge (R7). Without it, a customer who paid the full £100 the day before their appointment would have a single "full" record and no defensible way to compute what the salon keeps.
|
||||
Why carve at all? The refund system. Cancellation refunds are computed from notice tiers (full refund strictly more than 72 hours out — exactly 72 hours falls into the partial tier; protected deposit up to 50% of the total in the middle window, nothing under 24h). That middle tier needs to know exactly how much money counts as "protected deposit". The 50% carve guarantees a deposit record exists for refund protection, even if the customer paid in one lump "full" charge (R7). Without it, a customer who paid the full £100 the day before their appointment would have a single "full" record and no defensible way to compute what the salon keeps.
|
||||
|
||||
After the appointment starts, no split is applied: the charge records as a single payment with its original type, because there is no longer a deposit-protection window. Any overflow becomes a tip record, but only with the customer's explicit confirmation (the pre-start/post-start overflow rules are in [[#Chapter 5: Tips (Pre-Start vs Post-Start Rules)]], M4/B12).
|
||||
|
||||
@@ -271,8 +271,8 @@ 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, 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.
|
||||
- **A new card.** The Square Web Payments form runs entirely in the browser, and Square returns a short-lived token plus the result of the buyer-verification step (the SCA step) that Crussell sends as the charge source. 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, authenticated by Square 3DS2 SCA as the authorisation — the customer approves the charge in their banking app and the resulting tokenize-result is sent as the charge source (`new_card_token` → Square `source_id`) ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]). The frontend runs the buyer-verification step **proactively** before the first charge; on genuine `sca-unavailable` the charge is refused 402 `verification_required` and the payment does not go through (the customer can try again later) — there is **no** 2FA code fallback (see [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]). A stolen saved card is useless unless the thief can also pass the cardholder's verification — the bank's challenge.
|
||||
|
||||
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 — 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]]).
|
||||
- **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 same SCA-only gate applies ([[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]): the frontend runs the buyer-verification challenge and the charge carries the tokenize-result token; a token-less charge is refused 402 `verification_required` — the customer pays online later.
|
||||
|
||||
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.
|
||||
|
||||
@@ -401,7 +401,7 @@ Tip rows carry zero fees and a dedicated payment type, so the full amount lands
|
||||
|
||||
### Re-trying a tip safely
|
||||
|
||||
Tips use the same idempotency and locking machinery as every payment ([[#Chapter 4: Paying for a Booking (User & Admin Journeys)]]), with one twist: when no client key is supplied, the server derives one from the booking, amount, card, and a running count of **completed** tips on that booking. Because the count only moves when a tip completes, a lost-response retry re-derives the *same* key and re-attempts rather than charging twice, while two genuinely different equal-amount tips never collapse. The saved-card path carries the same two-factor gate as payments, with a fresh code issued if the charge fails after the old one was consumed.
|
||||
Tips use the same idempotency and locking machinery as every payment ([[#Chapter 4: Paying for a Booking (User & Admin Journeys)]]), with one twist: when no client key is supplied, the server derives one from the booking, amount, card, and a running count of **completed** tips on that booking. Because the count only moves when a tip completes, a lost-response retry re-derives the *same* key and re-attempts rather than charging twice, while two genuinely different equal-amount tips never collapse. The saved-card path carries the same SCA-only gate as payments: the frontend runs the buyer-verification challenge and the charge carries the tokenize-result token; a token-less charge is refused 402 `verification_required` ([[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]).
|
||||
|
||||
### Why tips carry no VAT
|
||||
|
||||
@@ -806,7 +806,7 @@ A till sale can be settled five ways, and the split is the whole story of the ch
|
||||
- **On the house** also completes instantly. This is the giveaway path, a gift card created with no payment. It carries no VAT and no Square charge, and it lets the owner hand out promotional credit without faking a payment.
|
||||
- **Card machine** creates a Square Terminal checkout, which the customer completes on the physical terminal while the app **polls** for its status.
|
||||
- **Online card** charges a `cnon:` nonce from the Square Web Payments SDK, the same token-only path the booking flow uses. No raw PAN ever touches the backend.
|
||||
- **Saved card** charges the customer's card-on-file token and is the only **2FA-gated** tender: charging someone else's saved card requires the card owner's one-time code, customer-keyed and audited (see [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]]).
|
||||
- **Saved card** charges the customer's card-on-file token and is the only **SCA-gated** tender: the frontend runs Square's buyer-verification challenge before the charge, the tokenize-result is sent as the charge source (`new_card_token` → Square `source_id`), and a token-less charge is refused 402 `verification_required` (see [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]]).
|
||||
|
||||
Cash and on-the-house sales write their row as `completed` straight away. The three Square-backed methods write a **`pending` row first, commit, then call Square**, exactly like the booking payments in [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]]. A sale stranded by a lost response can be retried with the same key and completed when Square confirms, instead of becoming a silent double charge.
|
||||
|
||||
@@ -832,13 +832,13 @@ Ambiguous failures (network errors, timeouts, Square 5xx) do *not* claw back. Th
|
||||
|
||||
A card-machine sale creates a Square checkout and records its ID on the `pending` row. Polling reuses that stored checkout ID; it never mints a second one, because a fresh checkout would orphan the original into an untracked charge. If a request fails before commit, the checkout is cancelled at Square as best effort, so a never-polled checkout cannot complete invisibly. And the status poll refuses to report a live state for a sale the sweep has already failed, surfacing a stale checkout that later resolves for manual reconciliation instead of confusing the operator.
|
||||
|
||||
### 2FA and the audit trail
|
||||
### SCA and the audit trail
|
||||
|
||||
Charging a saved card at the till inherits the full 2FA gate from [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]]: the code is verified against the **card owner's** account, never the admin's, and a fresh charge consumes it at the gate so two concurrent charges cannot both pass with one code; on terminal success the code is burned, or re-issued after a failed charge. The till-specific part is the audit trail: the action lands in the admin audit log as a `till_saved_card_charge` with the sale ID, amount, card last four, and Square payment ID. The admin relay can mint the code for the customer, but every mint is audited, so the owner can always reconstruct who charged what card and with whose authority.
|
||||
Charging a saved card at the till inherits the full SCA-only gate from [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]]: the frontend runs the buyer-verification challenge before the charge is submitted, so the saved card is never charged as a naked `ccof:` — a token-less charge is refused 402 `verification_required`. The till-specific part is the audit trail: the action lands in the admin audit log as a `till_saved_card_charge` with the sale ID, amount, card last four, and Square payment ID. The admin 2FA relay (`POST /api/admin/users/{id}/2fa/code`) is retained for account verification only — it never mints a card-charge authorisation — and every mint is audited, so the owner can always reconstruct who charged what card.
|
||||
|
||||
### Why the design works
|
||||
|
||||
The till has no "tendered" or "change" fields, and that is intentional. In a single-employee salon, change is handed by hand at the counter; modelling it invites rounding bugs for no benefit. The design instead spends its effort where the money is actually at risk: pending states, deterministic keys, method-switch guards, reconcile-or-reject before cash, and the clawback. Each guard stops a failed or lost card charge from turning into a free gift card, a double charge, or a double payment. The till shares its invariants with [[#Chapter 10: Gift Cards, Buy, Redeem, Top-Up, Transfer, 14-Day Cancel, Expiry, Caps|Chapter 10]] (gift-card funding and the £250 cap), [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps|Chapter 7]] (the sweep and clawback), and [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] (the 2FA gate), and the daily operating routine sits in [[Admin Manual]].
|
||||
The till has no "tendered" or "change" fields, and that is intentional. In a single-employee salon, change is handed by hand at the counter; modelling it invites rounding bugs for no benefit. The design instead spends its effort where the money is actually at risk: pending states, deterministic keys, method-switch guards, reconcile-or-reject before cash, and the clawback. Each guard stops a failed or lost card charge from turning into a free gift card, a double charge, or a double payment. The till shares its invariants with [[#Chapter 10: Gift Cards, Buy, Redeem, Top-Up, Transfer, 14-Day Cancel, Expiry, Caps|Chapter 10]] (gift-card funding and the £250 cap), [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps|Chapter 7]] (the sweep and clawback), and [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] (the SCA-only saved-card gate), and the daily operating routine sits in [[Admin Manual]].
|
||||
|
||||
**See also:** [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] (the booking-payment sibling of this screen) and [[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] (the same tender types from the operator's seat).
|
||||
|
||||
@@ -947,13 +947,13 @@ Provisioning is lazy in the strongest sense. A one-off guest payment, or any pay
|
||||
|
||||
The save itself is an upsert keyed per user on the Square card ID. That choice exists for a money-safety reason: if the response to a save is lost and the client retries, Square re-tokenizes the same card under the same deterministic key and returns the same token, and a plain insert would collide. The upsert turns that retry into a no-op that returns the already-saved card. The conflict is scoped per user, so a card tokenized for one user can never mutate another user's row, even if two people saved the same physical card.
|
||||
|
||||
Adding a card is itself 2FA-gated wherever enforcement is on, and it accepts only a `cnon:` nonce. The 2FA gate closes the side door problem: if saving a card were authenticated by nothing more than the session, an attacker who already owned the session could add their own card for later abuse. The gate is described fully in [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]].
|
||||
Adding a card is itself SCA-gated wherever enforcement is on, and it accepts only a `cnon:` nonce. The gate closes the side door problem: if saving a card were authenticated by nothing more than the session, an attacker who already owned the session could add their own card for later abuse. In an enforced deployment a save must carry a genuine SCA tokenize-result (the STORE-intent SCA performed at tokenization) — a token-less or forged-token save is refused 402 `verification_required`, so no 2FA code can authorise a save either. The gate is described fully in [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]].
|
||||
|
||||
### Using a card
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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 the SCA tokenize-result as its charge source (the `new_card_token` → Square `source_id` wire contract; SCA performed) as its 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. Either way, the SCA-only gate described in the next chapter sits on every saved-card charge: a token-less charge is refused 402 `verification_required`, never authorised by a 2FA code.
|
||||
|
||||
### Deleting a card: disable before delete
|
||||
|
||||
@@ -965,65 +965,53 @@ Every column on the saved-card row earns its place. Brand, last four and expiry
|
||||
|
||||
### The admin surface
|
||||
|
||||
The admin can list a customer's saved cards (defense-in-depth: the route re-checks admin status in the handler even though it is mounted behind admin middleware), charge one of them at the till with the customer's current 2FA code, and every such charge lands in the admin audit log with the card owner's identity. The saved-card surface is thus auditable end to end: who saved it, who charged it, and with whose authorisation. [[Feature Catalog]] §2.5, the [[Technical Manual]]'s schema-decision note on soft-delete, and [[Privacy Policy]] §2.2 (the user-facing contract) complete the picture.
|
||||
The admin can list a customer's saved cards (defense-in-depth: the route re-checks admin status in the handler even though it is mounted behind admin middleware), charge one of them at the till — with the SCA buyer-verification challenge run by the frontend and the tokenize-result sent as the charge source — and every such charge lands in the admin audit log with the card owner's identity. The saved-card surface is thus auditable end to end: who saved it, who charged it, and with whose authorisation. [[Feature Catalog]] §2.5, the [[Technical Manual]]'s schema-decision note on soft-delete, and [[Privacy Policy]] §2.2 (the user-facing contract) complete the picture.
|
||||
|
||||
**See also:** [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] (saved-card charges in the booking flow), [[#Chapter 11: The Till, Point-of-Sale Sales|Chapter 11]] (saved-card charges at the till), and [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] (the gate on every saved-card charge).
|
||||
**See also:** [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] (saved-card charges in the booking flow), [[#Chapter 11: The Till, Point-of-Sale Sales|Chapter 11]] (saved-card charges at the till), and [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] (the SCA-only gate on every saved-card charge).
|
||||
|
||||
## Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture
|
||||
|
||||
### What it is
|
||||
|
||||
Two-factor authentication here is a **backup authorisation gate on saved-card payments**. When SCA is unavailable, no saved card can be charged online, and none can be saved, without the owner presenting a fresh, single-use six-digit code in addition to their session. The one-person salon trades the last mile of convenience for a hard boundary in front of its most dangerous money path: a stored credential that spends without re-entering card details.
|
||||
Saved-card online payments are authorised **exclusively** by **Square PSD2 Strong Customer Authentication** (SCA). A customer-initiated stored-credential charge is a PSR 2017-regulated transaction: the customer's bank runs its own 3DS2 challenge in the banking app, the Web Payments SDK returns a tokenize-result that the backend passes to Square as the charge source, and that result is Square's answer to "is the payer really the cardholder" ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]). There is **no homegrown 2FA fallback**: PSR 2017 reg 100 makes SCA mandatory and **non-waivable** for customer-initiated stored-credential charges, and a merchant-side 2FA check with no bank involvement cannot legally substitute for it — authorising a token-less charge via 2FA would leave the merchant liable for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6) compensation to the processor, and customer consent does not cure that scheme liability. The `TWO_FACTOR_FALLBACK` switch and the C6 consent notice were **removed entirely**; a token-less saved-card charge is refused 402 `verification_required` and the payment does not go through (the customer can try again later; at the till, the customer is told they can pay online later instead). The dev Square mock simulates SCA (`SimulateSavedCardVerificationRequired` + `cnon:sca-...` tokenize-results), so development has full parity with the SCA-only production posture.
|
||||
|
||||
It is worth being precise about what this gate *is*. The primary authorisation for saved-card charges is **Square PSD2 Strong Customer Authentication**, a regulatory mechanism performed by the card scheme and Square: the customer's bank runs its own 3DS2 challenge in the banking app, the Web Payments SDK issues a verification token that the backend passes through to the payment, and that token is Square's answer to "is the payer really the cardholder" ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]). The homegrown 2FA gate fires **only when that buyer verification cannot complete** — the concrete case being a customer whose bank does not support the in-app approval flow — and then stands as the last line of authorisation on the charge. It is kept distinct from Square's flow so the compliance narrative stays honest: a merchant-side check with no bank involvement cannot satisfy PSR 2017 on its own.
|
||||
Homegrown 2FA remains for **admin and account verification only** — 2FA setup, disable, and delete-account re-authentication — **never** for authorising a card charge. It is kept distinct from Square's flow so the compliance narrative stays honest: a merchant-side check with no bank involvement cannot satisfy PSR 2017 on its own.
|
||||
|
||||
### Fail-closed enforcement
|
||||
|
||||
The gate defaults to **on**. Any deployment whose environment is not explicitly the dev/mock stack enforces 2FA; an empty or unknown value is treated as production, so a mistyped variable can never silently disarm it. It can only be switched off by an explicit `REQUIRE_2FA=false` (or an explicit mock environment). This is the fail-closed principle applied at its strictest: for a fraud gate, the dangerous failure is the one nobody notices, so the default leans toward blocking money until someone consciously chooses otherwise.
|
||||
The 2FA enforcement gate defaults to **on**. Any deployment whose environment is not explicitly the dev/mock stack enforces it; an empty or unknown value is treated as production, so a mistyped variable can never silently disarm it. It can only be switched off by an explicit `REQUIRE_2FA=false` (or an explicit mock environment). This is the fail-closed principle applied at its strictest: for a security gate, the dangerous failure is the one nobody notices, so the default leans toward blocking money until someone consciously chooses otherwise.
|
||||
|
||||
### Setting up and the code lifecycle
|
||||
|
||||
A customer enables 2FA by choosing a method (email or SMS), after which a six-digit code is generated. Only its digest is stored, never the plaintext, and it expires after ten minutes. The digest is keyed by a server-side pepper (`TWO_FACTOR_PEPPER`) using an HMAC, for a specific reason: the code space is only one million values, so an unsalted digest in a leaked database or log could be brute-forced offline in moments. The pepper makes that attack require the server's secret too. In production the pepper is mandatory and code issuance fails closed without it. In dev/test builds the system falls back to the legacy unsalted digest with a one-time warning, so local work is never blocked.
|
||||
|
||||
Once the customer verifies the code, 2FA is enabled. Disabling is symmetric and deliberately defensive: in an enforced environment, disabling requires *another* verification code, so a password-only attacker cannot simply turn the protection off. An enabled user can also request a fresh code at any time; that flow makes the charge-time gate usable, since enabling 2FA clears the setup code and there must be a way to mint a new one.
|
||||
Once the customer verifies the code, 2FA is enabled. Disabling is symmetric and deliberately defensive: in an enforced environment, disabling requires *another* verification code, so a password-only attacker cannot simply turn the protection off. An enabled user can request a fresh code at any time; that flow serves account verification (e.g. delete-account re-authentication).
|
||||
|
||||
The brute-force defences around codes are layered. A user gets five failed attempts before the pending code is destroyed and further attempts are refused until the attempt window lapses. Minting a fresh code never resets the failed-attempt counter, and fresh mints are throttled to one per minute per user. Those two rules together close the classic loop where an attacker mints a code, burns five guesses, mints again, forever; a locked-out user must simply wait out the window.
|
||||
|
||||
The one-per-minute mint cooldown now survives the gate verify. A successful code check does *not* clear the cooldown stamp, because the charge may still fail and the re-issue path needs the stamp to throttle; only a terminal success clears it. When a fresh charge fails after consuming the customer's code, the system re-issues a fresh code, and if that re-issue is refused or skipped by the cooldown it raises a critical-payment admin alert capped per issue (one unacknowledged row per stranded customer) so the operator knows to mint a code manually. A stranded customer is an operator-facing incident, never a silent log line.
|
||||
The one-per-minute mint cooldown now survives a successful verify. A successful code check does *not* clear the cooldown stamp, because the charge may still fail and the re-issue path needs the stamp to throttle; only a terminal success clears it. When a fresh charge fails after consuming the customer's code, the system re-issues a fresh code, and if that re-issue is refused or skipped by the cooldown it raises a critical-payment admin alert capped per issue (one unacknowledged row per stranded customer) so the operator knows to mint a code manually. A stranded customer is an operator-facing incident, never a silent log line. (The re-issue path is retained for call-site compatibility — the charge gate no longer consumes codes at all, so the re-issue is unreachable from the saved-card surfaces.)
|
||||
|
||||
The in-memory attempt map is bounded and fail-closed. When it reaches capacity (every tracked user sits inside a lockout window), the shared state returned for untracked users is a *permanently-locked* one instead of a fresh per-user budget: brute force becomes impossible for everyone until a real window lapses, and it never silently re-arms a guessing budget. Under that saturated state the mint-cooldown stamp writes are no-ops, so one user's mint can never throttle every other user, and eviction never drops an in-window record with a non-zero attempt counter, because dropping it would reset a genuine user's counter and grant a fresh guessing budget.
|
||||
|
||||
### The charge-time gate
|
||||
### The saved-card gate (SCA-only)
|
||||
|
||||
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
|
||||
|
||||
The single most important property is who the code belongs to. The gate verifies the code against the **card owner's** identity, not against the session that is making the request. When the admin charges a customer's saved card at the till, the person authenticating is the *customer*, and the code must be *their* code.
|
||||
|
||||
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 saved-card gate has one job: **require SCA**. In an enforced environment a saved-card charge must carry a Square verification token (or be a tokenize-result source) — a token-less charge is refused 402 `verification_required` up front, and **no 2FA code can authorise it** (the homegrown fallback was removed; B10's setup-flag restriction is subsumed — there is no code path at all). A charge that carries a verification token skips the gate entirely: the issuer already did SCA, so the gate is never consulted. On the customer-initiated online paths 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's source is the fresh tokenize-result (SCA performed) and the gate is skipped. 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.
|
||||
|
||||
### 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.
|
||||
|
||||
Production is stricter. Without an explicit opt-in (`TWO_FACTOR_ALLOW_LOG_DELIVERY=true`), a production build refuses to issue codes when no delivery channel exists, because a code that can never reach the user would silently lock them out of the enforced gate with no way forward. The failure is loud and actionable: "2FA requires an email or SMS delivery channel; contact the salon". The opt-in exists because the operator-relays-the-code flow is genuinely useful for a single-person salon, but accepting it means accepting that anyone with backend log access can defeat the gate; that risk belongs to an explicit decision, not a default.
|
||||
Production is stricter. Without an explicit opt-in (`TWO_FACTOR_ALLOW_LOG_DELIVERY=true`), a production build refuses to issue codes when no delivery channel exists, because a code that can never reach the user would silently lock them out of account verification (setup/disable/re-authentication) with no way forward. The failure is loud and actionable: "2FA requires an email or SMS delivery channel; contact the salon". The opt-in exists because the operator-relays-the-code flow is genuinely useful for a single-person salon, but accepting it means accepting that anyone with backend log access can defeat the account 2FA gate; that risk belongs to an explicit decision, not a default.
|
||||
|
||||
### The 2FA gate and relay — flow
|
||||
### The 2FA flow for account actions
|
||||
|
||||
A code is minted for the customer, delivered through the relay, verified against the card owner at charge time, consumed atomically for exactly one charge, and re-issued if the charge then fails.
|
||||
A code is minted for the customer, delivered through the relay, and verified against the customer's identity during account verification (setup, disable, delete-account re-authentication). It is never used to authorise a card charge.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Customer
|
||||
participant A as Admin / operator
|
||||
participant B as Backend gate
|
||||
participant B as Backend
|
||||
participant S as Square
|
||||
|
||||
Note over C,B: Enrolment
|
||||
@@ -1031,29 +1019,19 @@ sequenceDiagram
|
||||
B->>B: Mint 6-digit code, store HMAC digest (10 min TTL)
|
||||
B-->>C: Deliver code (relay in prod, log in dev)
|
||||
|
||||
Note over A,S: Saved-card charge
|
||||
C->>A: Present code
|
||||
A->>B: Charge saved card + code
|
||||
B->>B: Verify against card OWNER - consume atomically
|
||||
alt Code valid
|
||||
Note over A,S: Saved-card charge (SCA-only)
|
||||
A->>B: Charge saved card + verification_token / tokenize-result
|
||||
alt Token present (SCA performed)
|
||||
B->>S: Charge ccof card
|
||||
alt Charge fails
|
||||
B-->>A: Re-issue a fresh code
|
||||
else Charge completes
|
||||
B-->>C: Payment done
|
||||
end
|
||||
else Wrong code / locked out
|
||||
B-->>A: 5 fails destroys the code - wait out the window
|
||||
B-->>A: Payment done
|
||||
else Token-less
|
||||
B-->>A: 402 verification_required (no 2FA fallback)
|
||||
end
|
||||
|
||||
Note over A,B: Admin relay mint
|
||||
A->>B: POST /api/admin/users/{id}/2fa/code (audited)
|
||||
B-->>A: Mint against the customer's record - fresh or reused
|
||||
```
|
||||
|
||||
### Why it is built this way
|
||||
|
||||
Every choice in this chapter trades convenience against fraud risk, and the fraud control wins. Fail-closed so a misconfiguration can't disarm it. Single-use so one code can't authorise two charges. Customer-keyed so no operator shortcut can authenticate a customer's card. Lockout and mint-cooldown so brute-forcing is pointless. Pepper-hashed so a leaked database doesn't leak usable codes. And the whole thing is framed honestly as the backup, not the primary: Square buyer verification ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]) carries the SCA weight for saved-card charges, and this gate carries the practical weight only where a customer's bank cannot run it. [[Technical Manual]]'s *Two-Factor Authentication* section, [[User Manual]]'s saved-card note, and [[Privacy Policy]] §2.2 document the user-facing contract.
|
||||
Every choice in this chapter trades convenience against fraud and regulatory risk, and the compliance floor wins. SCA is mandatory and non-waivable (PSR 2017 reg 100), so a merchant-side 2FA check is never a lawful substitute and the fallback is gone entirely. The remaining 2FA surface is fail-closed so a misconfiguration can't disarm it; lockout and mint-cooldown make brute-forcing pointless; pepper-hashed codes make a leaked database useless. The chapter is framed honestly: Square buyer verification ([[#Appendix A: SCA & the Approve-in-App model|Appendix A]]) carries the SCA weight for saved-card charges, and the homegrown 2FA is an account-verification control, not a charge authorisation. [[Technical Manual]]'s *Two-Factor Authentication* section, [[User Manual]]'s saved-card note, and [[Privacy Policy]] §2.2 document the user-facing contract.
|
||||
|
||||
**See also:** [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] (the gate at charge time), [[#Chapter 14: Saved Cards & Square Customer Profiles|Chapter 14]] (the cards the gate protects), [[#Chapter 16: Auth & Rate Limiting for Money (Refresh Tokens, TRUST_PROXY_HEADERS)|Chapter 16]] (per-user limiter on the 2FA surface), and [[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] (the admin relay).
|
||||
|
||||
@@ -1105,7 +1083,7 @@ sequenceDiagram
|
||||
|
||||
### Login lockout
|
||||
|
||||
Failed logins are counted per user in the database. Five failures lock the account for 15 minutes; seven or more for 30. The counter is deliberately keyed per user, not per user-and-IP: an IP component in the key would let an attacker who rotates addresses mint a fresh counter for the same account, collapsing the whole point. The "many accounts from one address" bot case is handled separately by the per-IP limiter on the login route. An in-memory concurrency guard also stops one login racing another for the same account. The 30-minute ceiling is a deliberate bounded-denial-of-service compromise: brute force is economically pointless, while a mis-typed password can never permanently lock the owner out.
|
||||
Failed logins are counted per user in the database. Five failures lock the account for 15 minutes; seven or more for 30; ten or more for 60 (the ceiling). The counter is deliberately keyed per user, not per user-and-IP: an IP component in the key would let an attacker who rotates addresses mint a fresh counter for the same account, collapsing the whole point. The "many accounts from one address" bot case is handled separately by the per-IP limiter on the login route. An in-memory concurrency guard also stops one login racing another for the same account. The 60-minute ceiling is a deliberate bounded-denial-of-service compromise: brute force is economically pointless — each unlock makes the next lock longer — while a mis-typed password can never permanently lock the owner out.
|
||||
|
||||
### Per-IP limiting and TRUST_PROXY_HEADERS
|
||||
|
||||
@@ -1137,7 +1115,7 @@ This is the chapter that reads like a staff playbook. The "admin" in a one-perso
|
||||
|
||||
The payment modal is the heart of the in-person money path. For a booking, the admin can charge the card machine, take cash, apply a gift-card balance, or charge the customer's saved card. Two rules shape the whole screen. First, a booking can only be paid while it is started or completed — the same gate as [[#Chapter 4: Paying for a Booking (User & Admin Journeys)]]. Second, every amount is recomputed against the booking's remaining balance at the moment of the charge, so an override or a discount can never push the total past what is owed. The [[Admin Manual]] *Taking Payments* section covers the clicks; the constraint underneath is that the till may only collect money that is actually owed, and only once.
|
||||
|
||||
The saved-card path runs through the 2FA gate from [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]. When the owner wants to charge a customer's saved card, the admin relay mints a code keyed to the **customer** — never the admin's session, because the gate verifies against the card owner — the customer reads it out or receives it by phone, the code authorises exactly one charge, and every mint is audited.
|
||||
The saved-card path runs through the SCA-only gate from [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]]. The frontend runs the buyer-verification challenge before the charge is submitted, and the charge carries the SCA tokenize-result token; a token-less charge is refused 402 `verification_required` and the customer pays online later. Homegrown 2FA is **not** a card-charge authorisation: the admin 2FA relay (`POST /api/admin/users/{id}/2fa/code`) is retained for account verification only, and every mint is audited.
|
||||
|
||||
### Refunds with the forgiveness toggles
|
||||
|
||||
@@ -1153,7 +1131,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. The audited actions cover the whole money surface, not just saved-card charges and the 2FA mint: **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) and **till_saved_card_charge** (the same, at the till), **balance_check** (the admin inspected a customer's gift-card balance), **admin_cash_charge** and **admin_giftcard_payment** (cash and gift-card terminal charges on a booking, written *after* the money commits so a failed charge never leaves a false audit row), **admin_booking_refund** (the cancellation-refund path), **admin_reschedule_fee_forgiven** (fee forgiveness on a reschedule), **gift_card_transfer** (value moved between two unredeemed cards), **giftcard_clawback** (a funding reversal by the till handler, the sweep, or the webhook), and the admin gift-card lifecycle actions **admin_gift_card_create** and **admin_gift_card_topup**. Guest bookings audit too, with a NULL target user, exactly like the till flow.
|
||||
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. The audited actions cover the whole money surface: **2fa_code_mint** (the admin relayed a verification code to a customer for account verification, with whether the code was fresh or reused), **saved_card_charge** (the admin charged a customer's saved card) and **till_saved_card_charge** (the same, at the till), **balance_check** (the admin inspected a customer's gift-card balance), **admin_cash_charge** and **admin_giftcard_payment** (cash and gift-card terminal charges on a booking, written *after* the money commits so a failed charge never leaves a false audit row), **admin_booking_refund** (the cancellation-refund path), **admin_reschedule_fee_forgiven** (fee forgiveness on a reschedule), **gift_card_transfer** (value moved between two unredeemed cards), **giftcard_clawback** (a funding reversal by the till handler, the sweep, or the webhook), and the admin gift-card lifecycle actions **admin_gift_card_create** and **admin_gift_card_topup**. There is **no `2fa_fallback_charge` audit row** — it was removed with the homegrown 2FA fallback (the `insertTwoFAFallbackAudit` call sites are unreachable; the SCA-only gate never sets `fallbackUsed`). Guest bookings audit too, with a NULL target user, exactly like the till flow.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1161,7 +1139,7 @@ Why is this worth building for a single-person business? Two reasons. The first
|
||||
|
||||
### Why it matters
|
||||
|
||||
This is the daily operating surface. The audit trail plus the money-screen rules (payment only for started or completed bookings, override safety, forgiveness, 2FA on saved cards) are the operational controls the owner actually lives by. [[#Chapter 18: GDPR, Data Retention & Erasure for Money Data|Chapter 18]] covers how long those records survive and what happens to them on erasure; together the two chapters are the full story of money in and money out at the counter.
|
||||
This is the daily operating surface. The audit trail plus the money-screen rules (payment only for started or completed bookings, override safety, forgiveness, SCA on saved cards) are the operational controls the owner actually lives by. [[#Chapter 18: GDPR, Data Retention & Erasure for Money Data|Chapter 18]] covers how long those records survive and what happens to them on erasure; together the two chapters are the full story of money in and money out at the counter.
|
||||
|
||||
**See also:** [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] and [[#Chapter 11: The Till, Point-of-Sale Sales|Chapter 11]] (the journeys this screen runs), [[#Chapter 6: Refunds (Tiers, Original-Method Routing, Cancellation vs No-Show)|Chapter 6]] (the tiers the forgiveness overrides), and [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] (the relay behind saved-card charges).
|
||||
|
||||
@@ -1177,7 +1155,7 @@ The export endpoint assembles the customer's data from 23 sections, covering pro
|
||||
|
||||
### Erasure: proving who is asking
|
||||
|
||||
Deleting an account is irreversible, so a session token alone must not be enough. An attacker who lifts a token through XSS or a leaked storage location could otherwise erase the account before the owner noticed. The delete flow therefore re-authenticates: the current password is re-verified, and when 2FA is enforced and enabled, a fresh one-time code is required and consumed. The same consumption rule that guards saved-card charges applies here: one code, one deletion, and a failed deletion means the customer requests a fresh code. See [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] for the code lifecycle this mirrors.
|
||||
Deleting an account is irreversible, so a session token alone must not be enough. An attacker who lifts a token through XSS or a leaked storage location could otherwise erase the account before the owner noticed. The delete flow therefore re-authenticates: the current password is re-verified, and when 2FA is enforced and enabled, a fresh one-time code is required and consumed (this is one of the account-verification surfaces 2FA is retained for — it never authorises card charges). The consumption rule is: one code, one deletion, and a failed deletion means the customer requests a fresh code. See [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] for the code lifecycle.
|
||||
|
||||
### The wipe itself
|
||||
|
||||
@@ -1268,7 +1246,7 @@ The startup checks exist so that a misconfiguration is unmissable at boot rather
|
||||
|
||||
**SQUARE_ENVIRONMENT and SQUARE_LOCATION_ID.** The running server, the sweeps, and the webhook subscription must agree on environment and location ([[#Chapter 9: Square Integration, the Real Client and the Realistic Mock]] explains the contract).
|
||||
|
||||
**TWO_FACTOR_ALLOW_LOG_DELIVERY.** Leave it off unless the operator-relays-the-code flow is deliberately accepted (the trade is [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]]). With it off, production code issuance fails closed, so no user can complete 2FA setup and enforced saved-card payments 403; the startup warning states both sides plainly.
|
||||
**TWO_FACTOR_ALLOW_LOG_DELIVERY.** Leave it off unless the operator-relays-the-code flow is deliberately accepted (the trade is [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]]). With it off, production code issuance fails closed, so no user can complete 2FA setup or disable (the account-verification surfaces 2FA is retained for). Card charges are unaffected — they are SCA-only and never require a 2FA code; the startup warning states both sides plainly.
|
||||
|
||||
### Operating principles, restated
|
||||
|
||||
@@ -1280,66 +1258,67 @@ The money-safety axioms from the earlier chapters are also the operating instruc
|
||||
|
||||
### What this appendix is
|
||||
|
||||
Every chapter in this document is written from the point of view of the money engine. This appendix is the story of one legal boundary it sits behind: the moment a customer's bank asks "is this really the cardholder?" before a saved card can be charged. Since the SCA decision, that question is answered first by **Square's [Strong Customer Authentication](https://en.wikipedia.org/wiki/Strong_customer_authentication) (SCA)**, and only when the customer's bank cannot run it, by the homegrown two-factor gate from [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]].
|
||||
Every chapter in this document is written from the point of view of the money engine. This appendix is the story of one legal boundary it sits behind: the moment a customer's bank asks "is this really the cardholder?" before a saved card can be charged. The answer is given **exclusively** by **Square's [Strong Customer Authentication](https://en.wikipedia.org/wiki/Strong_customer_authentication) (SCA)** — there is no homegrown fallback. When a customer's bank cannot run SCA, the charge is refused 402 `verification_required` and the payment does not go through (the customer can try again later). Homegrown 2FA is retained only for admin and account verification (see [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture]]), never for card charges.
|
||||
|
||||
### What 3DS2 / SCA is
|
||||
|
||||
Strong Customer Authentication is a European legal requirement introduced by the **[Payment Services Directive](https://en.wikipedia.org/wiki/Payment_Services_Directive) (EU) 2015/2366, implemented in the UK as the Payment Services Regulations 2017**. For online card payments it means the card issuer must confirm the payer is really the cardholder using **two independent factors** — typically something the customer knows (a password or PIN), something they have (the phone), and something they are (a fingerprint). The second generation of the card-scheme protocol that carries this is **[3-D Secure](https://en.wikipedia.org/wiki/3-D_Secure) 2 (3DS2)**: when a payment needs SCA, the issuing bank presents its own challenge screen — today usually a push notification or an in-app approval — and the customer approves the payment inside their banking app. That is why the customer journey reads as "approve in your banking app". The challenge is run end-to-end by the bank and the card scheme; the salon and the app never see the customer's banking credentials.
|
||||
|
||||
Square is the payment provider in this model. When a charge needs SCA, Square runs the buyer-verification flow with the Web Payments SDK on the client, obtains a **verification token** confirming the cardholder is real, and the backend passes that token through to the charge (`verification_token` on every `CreatePayment` request). The platform's genuine SCA story is therefore Square's, exactly as Chapter 9's mock does with its optional verification-required toggle.
|
||||
Square is the payment provider in this model. When a charge needs SCA, Square runs the buyer-verification flow with the Web Payments SDK on the client and returns a tokenize-result confirming the cardholder is real. Since the SCA rework, that tokenize-result is the charge **source**: the frontend sends it as `new_card_token` and the backend passes it to Square as `source_id` on every saved-card `CreatePayment` request (`resolveChargeSource`), rather than as a separate `verification_token` (the legacy `ccof:` + `verification_token` shape remains accepted). The platform's genuine SCA story is therefore Square's, exactly as Chapter 9's mock does with its optional verification-required toggle.
|
||||
|
||||
### Why SCA is now primary for saved-card charges
|
||||
### Why SCA is the only authorisation for saved-card charges
|
||||
|
||||
Two reasons, one legal and one financial.
|
||||
|
||||
**The legal reason — PSR 2017.** A customer-initiated e-commerce card payment must be authenticated with SCA. A charge against a **saved card** (a stored [card-on-file](https://en.wikipedia.org/wiki/Card_not_present) credential) is still a customer-initiated transaction whenever the cardholder is present and initiates it — Square classifies it through `customer_details.customer_initiated=true`, which the backend sends on every online charge, saved-card or new (the C3 rule in [[#Chapter 14: Saved Cards & Square Customer Profiles]]). PSR 2017 does not exempt a stored credential just because it was entered once. The homegrown two-factor gate was never a legal substitute: it is a merchant-side check with no bank involvement, so it cannot satisfy the regulation on its own.
|
||||
**The legal reason — PSR 2017.** A customer-initiated e-commerce card payment must be authenticated with SCA. A charge against a **saved card** (a stored [card-on-file](https://en.wikipedia.org/wiki/Card_not_present) credential) is still a customer-initiated transaction whenever the cardholder is present and initiates it — Square classifies it through `customer_details.customer_initiated=true`, which the backend sends on every online charge, saved-card or new (the C3 rule in [[#Chapter 14: Saved Cards & Square Customer Profiles]]). PSR 2017 does not exempt a stored credential just because it was entered once, and its reg 100 makes SCA **mandatory and non-waivable**: a merchant-side 2FA check with no bank involvement cannot legally act as an SCA substitute. Authorising a token-less charge via a homegrown 2FA code would leave the merchant liable for ECI 7 / SLI 210 chargebacks and PSR 2017 reg 77(6) compensation to the processor, and customer consent does not cure that scheme liability. That is why the `TWO_FACTOR_FALLBACK` switch and the C6 consent notice were removed entirely.
|
||||
|
||||
**The financial reason — liability shift.** Under the card-scheme rules that implement SCA, when a charge is properly authenticated (SCA performed) and the cardholder later disputes it, the **fraud liability shifts to the card scheme / issuing bank**. When SCA is skipped or cannot be performed, the liability sits with the merchant. For a single-employee salon with no back office to fight [chargebacks](https://en.wikipedia.org/wiki/Chargeback), losing liability shift on saved-card charges is an unacceptable cost; performing SCA first is the difference between "the bank eats the fraud" and "the salon does".
|
||||
|
||||
That is the decision in one line: **Square 3DS2 SCA is the primary authorisation for saved-card charges, and the homegrown 2FA gate is the backup**.
|
||||
That is the decision in one line: **Square 3DS2 SCA is the authorisation for saved-card charges, and a charge it cannot authorise is refused — never delegated to a homegrown 2FA fallback.**
|
||||
|
||||
### The customer journey ("approve in your banking app")
|
||||
|
||||
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.
|
||||
2. The app runs Square's buyer-verification step **proactively** (tokenize-before-first-charge). 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 tokenize-result; the frontend sends it as the charge **source** (`new_card_token` → the backend passes it to Square as `source_id`), 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.
|
||||
5. If the bank **cannot** run the challenge (genuine `sca-unavailable`), the charge is **refused 402 `verification_required`** — the frontend shows a short refusal notice: the payment does not go through and the customer can try again later. There is no verification-code alternative.
|
||||
|
||||
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 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. The SCA-only gate still sits on those paths: the frontend runs the buyer-verification challenge and the charge carries the tokenize-result token; a token-less charge is refused 402 `verification_required`. (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), 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.
|
||||
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 uses the tokenize-result token as the charge source. 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. **Wire contract (SCA rework):** the frontend sends the tokenize-result token as `new_card_token` alongside the saved-card reference, and the backend passes that token to Square as `source_id` (`resolveChargeSource`, `charge_helpers.go`) — the stored `ccof:` id is not sent. The legacy shape (a `ccof:` id plus a separate `verification_token`) is still accepted for backward compatibility (`ValidateVerificationToken` checks its shape before forwarding), but it is no longer the primary contract; the backend never evaluates the token's meaning itself, because that 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 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.
|
||||
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 backend's SCA-only saved-card gate refuses a token-less charge outright — HTTP 402 `verification_required`, *before* any Square call — so the charge never reaches Square without SCA (`requireTwoFactorForCardAccess`, `handlers/payments/twofa.go`; `writeVerificationRequiredResponse`, `errors.go`). 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
|
||||
### The SCA-only gate: what it requires, and the audit trail
|
||||
|
||||
The homegrown two-factor gate (Chapter 15) is no longer the primary guard on saved-card money. It is the **backup**, and it fires only when SCA is unavailable — the concrete case being a customer whose bank or issuing scheme does not support the in-app approval flow, so Square's buyer verification cannot complete. In that world, the merchant gate is the last authorisation standing, and it must therefore keep every property Chapter 15 already argues for:
|
||||
The saved-card gate has one job: **require SCA**. In an enforced environment a saved-card charge must carry a Square verification token (or be a tokenize-result source); a token-less charge is refused 402 `verification_required` up front, and **no 2FA code can authorise it** — the homegrown fallback was removed, so `fallbackUsed` is always false and there is no consent to demand (`enforceSCAFallbackConsent` is a compile-compatible no-op). Enforcement is fail-closed: on by default for any `SQUARE_ENVIRONMENT` except an explicit dev/mock value, disabled only by an explicit `REQUIRE_2FA=false`. The properties the old fallback needed still matter, but now they describe the *account-verification* surface 2FA is retained for (rationale R3, R11, R20 are updated to match):
|
||||
|
||||
- **Fail-closed.** Enforcement defaults ON; only an explicit `REQUIRE_2FA=false` or an explicit dev/mock environment disables it. A misconfigured deployment blocks rather than silently authorises.
|
||||
- **Customer-keyed.** The code verifies against the card owner's account, never the session or the admin operating the screen (rationale R3, R11).
|
||||
- **Single-use.** A fresh charge consumes the code at the gate so one code authorises exactly one charge; a failed charge re-mints a fresh one; a same-key retry verifies without consuming and burns the code at terminal success (rationale R20).
|
||||
- **Fail-closed.** Account-verification 2FA enforcement defaults ON; only an explicit `REQUIRE_2FA=false` or an explicit dev/mock environment disables it. A misconfigured deployment blocks rather than silently authorises.
|
||||
- **Customer-keyed.** Where a code is used (account verification — setup, disable, delete-account re-authentication), it verifies against the account owner, never the admin session.
|
||||
- **Single-use.** One code, one account action (e.g. one deletion); a failed action means a fresh code.
|
||||
- **Brute-force resistant.** Five failed attempts destroy the pending code; fresh mints never reset the counter; mints are throttled per user; the whole surface sits behind the per-user limiter (Chapter 16).
|
||||
|
||||
And because it is now a fallback on a regulated path, it carries a **strict audit trail**:
|
||||
The audit trail that remains is the card-charge and account-verification trail, not a fallback trail:
|
||||
|
||||
- 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.
|
||||
- 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), each with the target customer, amount, card, and Square payment id.
|
||||
- **No `2fa_fallback_charge` rows are ever written** — the `insertTwoFAFallbackAudit` call sites are unreachable (the SCA-only gate never sets `fallbackUsed`), asserted by `twofa_test.go` and `terminal_sca_test.go`.
|
||||
- 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.**
|
||||
The rule for the operator: **when a customer's bank cannot do SCA, the payment is refused and does not go through (the customer can try again later; at the till, they can be invited to pay online later instead) — there is no fallback code flow to run.**
|
||||
|
||||
### Delivery channel: email/SMS is the intent; the `[2FA]` log is the stopgap
|
||||
|
||||
The intended delivery channel for fallback 2FA codes is **email or SMS** — the customer's chosen method is already collected at setup (`two_factor_method` is `'email'` or `'sms'`). That transport is **not wired yet** (backlog P6). Until it lands, the only production delivery channel is the operator's explicit, documented-insecure opt-in to **stdout-log delivery** (`TWO_FACTOR_ALLOW_LOG_DELIVERY=true`): the plaintext code appears in the server log under a `[2FA]` marker and the operator relays it to the customer out-of-band. Production builds fail closed without the opt-in (503, `errTwoFADeliveryUnavailable`) so a code that could never reach the customer is never issued, and the user id and code are written to **separate** log lines so a single record cannot trivially pair a code with its owner. Anyone with backend log access can defeat the fallback gate, which is precisely why log delivery is an explicit opt-in and why the intended email/SMS transport must replace it before the fallback path is treated as production-hardened.
|
||||
The intended delivery channel for account-verification 2FA codes is **email or SMS** — the customer's chosen method is already collected at setup (`two_factor_method` is `'email'` or `'sms'`). That transport is **not wired yet** (backlog P6). Until it lands, the only production delivery channel is the operator's explicit, documented-insecure opt-in to **stdout-log delivery** (`TWO_FACTOR_ALLOW_LOG_DELIVERY=true`): the plaintext code appears in the server log under a `[2FA]` marker and the operator relays it to the customer out-of-band. Production builds fail closed without the opt-in (503, `errTwoFADeliveryUnavailable`) so a code that could never reach the customer is never issued, and the user id and code are written to **separate** log lines so a single record cannot trivially pair a code with its owner. Anyone with backend log access can defeat the account-verification gate, which is precisely why log delivery is an explicit opt-in and why the intended email/SMS transport must replace it. Card charges are unaffected by the delivery channel: they are SCA-only and never require a 2FA code.
|
||||
|
||||
### Why it is built this way
|
||||
|
||||
The chapter answers the same question every chapter answers: which failure mode is the guard buying us against? SCA-primary buys compliance and liability shift for the 99% of customers whose banks support in-app approval. The 2FA backup buys a working, auditable authorisation path for the remaining customers without pretending to be SCA. The audit trail buys the owner the ability to prove, later, who authorised each fallback charge. And the fail-closed, customer-keyed, single-use gate (Chapter 15) is exactly the shape a backup authorisation needs to be: it must be as strict as the primary, because it is the last line standing.
|
||||
The chapter answers the same question every chapter answers: which failure mode is the guard buying us against? SCA-only buys compliance and liability shift for every saved-card charge — PSR 2017 reg 100 makes SCA mandatory and non-waivable, so a merchant-side fallback could never lawfully substitute for it. The 402 refusal on `sca-unavailable` keeps that promise honestly: a charge that cannot be authenticated is not charged, and the customer is told the payment did not go through (they can try again later). The account-verification 2FA that remains is fail-closed, customer-keyed, and single-use, and every retained audit row lets the owner prove who did what.
|
||||
|
||||
**See also:** [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] (the online journeys), [[#Chapter 9: Square Integration, the Real Client and the Realistic Mock|Chapter 9]] (buyer verification, definitive SCA codes, the mock's verification toggle), [[#Chapter 14: Saved Cards & Square Customer Profiles|Chapter 14]] (the cards and the customer-initiated marker), [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] (the backup gate), [[#Chapter 16: Auth & Rate Limiting for Money (Refresh Tokens, TRUST_PROXY_HEADERS)|Chapter 16]] (the per-user limiter), and [[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] (the audit rows).
|
||||
**See also:** [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] (the online journeys), [[#Chapter 9: Square Integration, the Real Client and the Realistic Mock|Chapter 9]] (buyer verification, definitive SCA codes, the mock's verification toggle), [[#Chapter 14: Saved Cards & Square Customer Profiles|Chapter 14]] (the cards and the customer-initiated marker), [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] (the SCA-only gate and the retained account 2FA), [[#Chapter 16: Auth & Rate Limiting for Money (Refresh Tokens, TRUST_PROXY_HEADERS)|Chapter 16]] (the per-user limiter), and [[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] (the audit rows).
|
||||
|
||||
<!-- Optional mermaid diagram — safe for the mermaid agent to merge or drop; keep outside fenced blocks until the mermaid pass is done, or let that agent convert it.
|
||||
|
||||
@@ -1347,11 +1326,11 @@ flowchart TD
|
||||
A[Customer checks out with saved card] --> B{Square buyer verification}
|
||||
B -- SCA supported by bank --> C[Bank shows 3DS2 challenge in banking app]
|
||||
C --> D[Customer approves]
|
||||
D --> E[Verification token attached to charge]
|
||||
D --> E[Tokenize-result sent as the charge source]
|
||||
E --> F[Charge completes - liability shifted to scheme]
|
||||
B -- SCA unavailable (no in-app approval) --> G[2FA backup gate fires]
|
||||
G --> H[Code minted keyed to customer, admin mint audited]
|
||||
H --> I[Code verified, single-use, charge completes]
|
||||
B -- SCA unavailable (no in-app approval) --> G[402 verification_required refusal]
|
||||
G --> H[Payment refused - no 2FA fallback]
|
||||
H --> I[Payment refused - customer can try again later]
|
||||
-->
|
||||
|
||||
## Design Decisions at a Glance
|
||||
@@ -1362,7 +1341,7 @@ Every "why" in this document traces back to one of the rationale points below. T
|
||||
|---|---|---|---|
|
||||
| R1 | **The dev mock mirrors production exactly** | The mock is the only place a PCI violation, a fee-sign bug, a dedup bug, or a wrong status shape can be caught before production. It implements the same token-only rules, error codes, key dedup, fee math, and checkout lifecycle, plus fault-injection toggles that make production-only failure paths testable. | [[#Chapter 9: Square Integration, the Real Client and the Realistic Mock|Chapter 9]] |
|
||||
| R2 | **Tokens only, PANs never (PCI-DSS parity)** | The backend accepts only Square `cnon:` (new-card nonce) and `ccof:` (card-on-file) tokens, never raw PANs, so the app never holds or transmits card numbers and stays out of PCI-DSS scope. The mock enforces the same rule. | [[#Chapter 9: Square Integration, the Real Client and the Realistic Mock|Chapter 9]], [[#Chapter 14: Saved Cards & Square Customer Profiles|Chapter 14]] |
|
||||
| R3 | **The 2FA code is customer-keyed, not admin-keyed** | The charge gate verifies against the card owner's user ID. A code minted against the admin's session could never authorise the customer's charge, so even the admin relay mints against the customer. | [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] |
|
||||
| R3 | **The 2FA code is customer-keyed, not admin-keyed** | The account-verification gate verifies against the account owner's user ID. A code minted against the admin's session could never verify the customer's account action, so even the admin relay mints against the customer. | [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] |
|
||||
| R4 | **The sweep replays at 22 hours and blind-fails at 24 hours** | Square retains idempotency keys for about 24 hours. A 22-hour replay lands safely inside the window; replaying at exactly 24 hours risks an expired key that looks identical to "never charged". Past 24 hours a row can no longer be replayed trustworthily. | [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps|Chapter 7]] |
|
||||
| R5 | **The discriminator is the sweep's own replay time** | A replayed charge is judged by comparing its creation time against the sweep's `replayAt` instant, not against a fixed row-age window, so "the original payment" and "the sweep's own duplicate" are told apart reliably. | [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps|Chapter 7]] |
|
||||
| R6 | **Gift cards are SPVs, never MPVs** | HMRC VAT Notice 700/7: a salon-only card is a single-purpose voucher by definition, so MPV is legally unavailable. VAT is charged at purchase and the effective type is stamped on the card so redemption can never tax it twice. | [[#Chapter 10: Gift Cards, Buy, Redeem, Top-Up, Transfer, 14-Day Cancel, Expiry, Caps|Chapter 10]], [[#Chapter 13: VAT (SPV vs MPV, Single-Point-of-Taxation)|Chapter 13]] |
|
||||
@@ -1370,7 +1349,7 @@ Every "why" in this document traces back to one of the rationale points below. T
|
||||
| R8 | **Refund tiers: 72h / 24-72h / under 24h** | The tiers encode the salon's cancellation policy: full refund with plenty of notice, a protected deposit (capped at 50% of the total) in the middle window, nothing under 24 hours. The protected-deposit formula keeps the middle tier fair and predictable. | [[#Chapter 6: Refunds (Tiers, Original-Method Routing, Cancellation vs No-Show)|Chapter 6]] |
|
||||
| R9 | **24-month rolling gift-card expiry** | CMA guidance treats roughly 24 months as the fair industry standard for gift-card validity. A rolling expiry (each use resets the clock) is fairer than a fixed one and avoids an unfair-contract-term challenge under the Consumer Rights Act 2015. | [[#Chapter 10: Gift Cards, Buy, Redeem, Top-Up, Transfer, 14-Day Cancel, Expiry, Caps|Chapter 10]] |
|
||||
| R10 | **Erasure keeps de-identified notes** | Free-text notes are one indivisible medical/safety record (allergy and access data is Article 9 special-category) that cannot be split. At erasure the surrounding record is fully wiped with no re-identification map, so the retained notes are effectively anonymised, kept for safe re-treatment and legal-claims defence, and never exported. | [[#Chapter 18: GDPR, Data Retention & Erasure for Money Data|Chapter 18]] |
|
||||
| R11 | **The admin 2FA mint targets the customer** | Same as R3 from the admin side: the operator relays the code to the customer, the mint writes to the customer's record, and every mint or reuse is audited. The admin never authenticates the customer's card. | [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]], [[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] |
|
||||
| R11 | **The admin 2FA mint targets the customer** | Same as R3 from the admin side: the operator relays the code to the customer, the mint writes to the customer's record, and every mint or reuse is audited. The admin never authenticates the customer's account or card. | [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]], [[#Chapter 17: Admin Journeys: Taking Money, Refunding, Gift Cards, Audit Trail|Chapter 17]] |
|
||||
| R12 | **Refresh tokens are opaque and family-revoked** | Opaque tokens (only SHA-256 stored) mean a database leak exposes nothing usable. Single-use consumption makes replay detectable, and family revocation deletes the whole lineage and every bound access token, so a stolen token is worthless after first legitimate use. | [[#Chapter 16: Auth & Rate Limiting for Money (Refresh Tokens, TRUST_PROXY_HEADERS)|Chapter 16]] |
|
||||
| R13 | **Per-IP rate limiting sits behind TRUST_PROXY_HEADERS** | Trusting proxy headers only when configured stops one client collapsing every per-IP budget onto the proxy's IP, and stops an origin-exposed server from trusting client-supplied headers. Per-user limiters make IP rotation useless against per-account budgets. | [[#Chapter 16: Auth & Rate Limiting for Money (Refresh Tokens, TRUST_PROXY_HEADERS)|Chapter 16]] |
|
||||
| R14 | **The pence convention** | Integer pence eliminates floating-point money bugs at the API boundary; pounds-as-NUMERIC in the database preserves SQL precision; every cap and threshold is pence-based and every conversion is guarded by `math.Round`. | [[#Chapter 2: The Pence Convention & How Money Is Stored|Chapter 2]] |
|
||||
@@ -1379,7 +1358,7 @@ Every "why" in this document traces back to one of the rationale points below. T
|
||||
| R17 | **Pending-first, commit-then-charge** | A Square-backed operation writes a pending row and commits before calling Square, so a failure leaves a retryable row. Same-key retries reuse it and Square dedups, so a lost response never becomes a double charge. | [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]], [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps|Chapter 7]] |
|
||||
| R18 | **Idempotency keys are deterministic and slot-scanned** | Deterministic keys distinguish "same operation retried" from "new equal-amount operation" (partials always advance, un-refunded non-partials dedup), so retries never double-charge and genuine repeat payments never collapse. | [[#Chapter 2: The Pence Convention & How Money Is Stored|Chapter 2]], [[#Chapter 4: Paying for a Booking (User & Admin Journeys)|Chapter 4]] |
|
||||
| R19 | **Request snapshots are stored (and encrypted)** | The sweep must replay a byte-identical request under the same key for Square's dedup to return the original result, so the exact request JSON is stored. Because it holds PII, non-mock deployments encrypt it with AES-256-GCM. | [[#Chapter 7: The Reconciliation Engine, Three Background Sweeps|Chapter 7]], [[#Chapter 9: Square Integration, the Real Client and the Realistic Mock|Chapter 9]] |
|
||||
| R20 | **The charge gate consumes codes deliberately** | Fresh charges consume the code at the gate so two concurrent charges cannot both pass with one code; pending-reuse retries verify without consuming and burn the code at terminal success; a failed fresh charge re-mints a fresh code. | [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] |
|
||||
| R20 | **Account-verification codes are single-use** | One account code, one account action (setup, disable, delete-account re-authentication); a failed action means a fresh code. Saved-card charges never consume a code — they are SCA-only and the homegrown 2FA fallback was removed. | [[#Chapter 15: Two-Factor Authentication (2FA) & the SCA Posture|Chapter 15]] |
|
||||
| R21 | **The 14-day gift-card cancellation refunds to the original method** | The Consumer Contracts Regulations 2013 give a 14-day cooling-off right for distance purchases, refunded to the original payment method. The endpoint exists because no other code path could refund a gift-card purchase. | [[#Chapter 10: Gift Cards, Buy, Redeem, Top-Up, Transfer, 14-Day Cancel, Expiry, Caps|Chapter 10]] |
|
||||
| R22 | **Gift-card redemption has its own brute-force lockout** | The 12-hex code space is small enough to probe, so redeem gets a per-code 429 lockout on top of the per-user route limiter, making brute force economically pointless. | [[#Chapter 10: Gift Cards, Buy, Redeem, Top-Up, Transfer, 14-Day Cancel, Expiry, Caps|Chapter 10]] |
|
||||
| R23 | **Gift-card caps: £250 per transaction, £500 per user-day, £5,000 per admin-day** | Owner-set loss limits that bound the damage of a compromised admin session or a fraudulent customer, enforced before any write with double-count-free daily signals. | [[#Chapter 10: Gift Cards, Buy, Redeem, Top-Up, Transfer, 14-Day Cancel, Expiry, Caps|Chapter 10]] |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**Estimated effort:** S-M (1-2 days backend/frontend + privacy policy copy)
|
||||
**Backlog reference:** `Future Work - Gap Backlog.md` item P14 (added alongside this plan)
|
||||
|
||||
> **Implementation status (Aug 2026):** The code is DONE: `square_customer_id` is provisioned lazily on card-save, stored on `user_saved_cards`, and now forwarded to Square as `card.customer_id` (CreateCard) and `CustomerID` (ccof: CreatePayment). One-off/guest payments mint no customer. The `/privacy-policy` route + consent pop-over shipped. **What remains:** the P12 sandbox live check (the enforcement assumption is now VERIFIED via node-sdk issue #47 + Square's docs; see Verification) and the final privacy-policy copy review (still DRAFT-bannered).
|
||||
> **Implementation status (Aug 2026):** The code is DONE: `square_customer_id` is provisioned lazily on card-save, stored on `user_saved_cards`, and now forwarded to Square as `card.customer_id` (CreateCard) and `CustomerID` (ccof: CreatePayment). One-off/guest payments mint no customer. The `/privacy-policy` route + consent pop-over shipped. **Superseded on two points (Aug 2026 docs audit):** (1) the C6 SCA-fallback consent notice was REMOVED — PSR 2017 reg 100 makes SCA mandatory and non-waivable for customer-initiated stored-credential charges, so a homegrown 2FA/consent fallback is unlawful; the privacy-policy §2.4 "consent" content was replaced with the SCA-only refusal description, and no `2fa_fallback_charge` rows are ever written; (2) the `/privacy-policy` DRAFT banner was removed in the working tree. **What remains:** the P12 sandbox live check (the enforcement assumption is now VERIFIED via node-sdk issue #47 + Square's docs; see Verification), the `{{SUPPORT_EMAIL}}` placeholder substitution, and owner/solicitor sign-off on the final copy.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +108,7 @@ In **`CardSelection.svelte`** next to the consent checkbox (`:135-148`), wrap th
|
||||
- Same component in any other flow that renders a save-card checkbox (UserBookingModal tip modal if it uses `CardSelection`, etc.) — **single source of truth: reuse `CardSelection`** rather than duplicating labels.
|
||||
|
||||
### Step 6c — Create `/privacy-policy` route (the 'real' policy)
|
||||
- **Gate**: the pop-over ships only after the Privacy Policy placeholder is made 'real' (cleaned up + owner-approved; see Docs step 8). Until then, keep the current behaviour (no privacy pop-over / no link) or point the link at the placeholder page marked DRAFT — **decision: ship the route with the DRAFT content but label it "DRAFT — for review"**, so the consent flow is fully walkable end-to-end in dev while the copy is still being finalised.
|
||||
- **Gate**: the pop-over ships only after the Privacy Policy placeholder is made 'real' (cleaned up + owner-approved; see Docs step 8). *(Superseded: the DRAFT banner has since been removed — the route ships without it; the remaining gate is the `{{SUPPORT_EMAIL}}` substitution + owner/solicitor sign-off.)*
|
||||
- New route **`frontend/src/routes/privacy-policy/+page.svelte`**, copying the `/cancellation-policy` structure:
|
||||
- `<svelte:head>` title "Privacy Policy", `@media print` CSS, `?format=pdf` handling, `Last updated` line.
|
||||
- Content from `obsidian/Crussell/Privacy Policy.md` (incl. the new §2.2 Saved Cards & Square).
|
||||
@@ -140,7 +140,7 @@ Add a second `PolicyPopover` (Privacy Policy) next to the existing cancellation-
|
||||
8. **Account page**: display a small "cards are stored securely with our payment provider, Square" caption near the saved-cards section (optional but recommended for transparency).
|
||||
|
||||
### Docs
|
||||
8. **Privacy Policy** (`obsidian/Crussell/Privacy Policy.md`): **already drafted in the placeholder** — §2.2 "Saved Cards & Payment Provider (Square)" now contains the processor disclosure, lawful basis (Art 6(1)(b)), one-off-payment statement, retention, and Square privacy-policy link; Financial Data list + retention table + deletion process updated to match. The rest of the document remains a DRAFT placeholder and must be made 'real' (owner-approved copy, live date) before the `/privacy-policy` route ships without its DRAFT banner.
|
||||
8. **Privacy Policy** (`obsidian/Crussell/Privacy Policy.md`): **already drafted in the placeholder** — §2.2 "Saved Cards & Payment Provider (Square)" now contains the processor disclosure, lawful basis (Art 6(1)(b)), one-off-payment statement, retention, and Square privacy-policy link; Financial Data list + retention table + deletion process updated to match. *(Superseded on two points: §2.4 now describes the SCA-only refusal (the C6 consent content was removed), and the `/privacy-policy` route no longer ships DRAFT-bannered.)*
|
||||
9. **Terms & Conditions** (`Terms & Conditions - Overall App.md`): §3.2 Payment Processing now cross-references the saved-card consent + Privacy Policy §2.2. (Placeholder — same 'make real' gate as above.)
|
||||
10. **README / Technical Manual**: update the payments section to reflect customer provisioning + nonce-direct one-off charges.
|
||||
|
||||
@@ -154,7 +154,7 @@ Add a second `PolicyPopover` (Privacy Policy) next to the existing cancellation-
|
||||
| Terms §3.2 cross-ref | ✅ Added to placeholder |
|
||||
| `policyPopover.svelte` generalisation (`label`/`href` props) | ✅ **Done** — `frontend/src/lib/components/ui/policyPopover.svelte` now accepts `label`/`href` props defaulting to `/cancellation-policy` + "cancellation policy", so the existing 8 call sites are unchanged |
|
||||
| Pop-over on consent checkbox in `CardSelection.svelte` | ✅ **Done** — `frontend/src/lib/components/payments/CardSelection.svelte` renders the privacy-policy pop-over next to the card-save consent checkbox (shown when `canSaveCards && squareCardReady`) |
|
||||
| `/privacy-policy` route (HTML + PDF, mirrors `/cancellation-policy`) | ✅ **Done** — `frontend/src/routes/privacy-policy/+page.svelte` ships DRAFT-bannered (HTML + `?format=pdf` print path) |
|
||||
| `/privacy-policy` route (HTML + PDF, mirrors `/cancellation-policy`) | ✅ **Done** — `frontend/src/routes/privacy-policy/+page.svelte` ships with HTML + `?format=pdf` print path. *(The DRAFT banner it originally carried was removed in the working tree.)* |
|
||||
| Account page Policies block second pop-over | ✅ **Done** — `frontend/src/routes/account/+page.svelte` Policies block shows a second `PolicyPopover` for the privacy policy |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user