fix: review-loop hardening — identical-body replay, 2FA gates, webhook at-least-once, GDPR scrub

Follow-up to the comprehensive payment-system review. Fixes the issues the
review found in the initial integration, plus the rough edges it introduced.

Money-safety:
- Replay-by-key now replays the FULL original request verbatim from a stored
  square_request_snapshot, so a retained idempotency key returns the original
  payment instead of IDEMPOTENCY_KEY_REUSED (previously the row sat pending
  forever). IDEMPOTENCY_KEY_REUSED remains ambiguous (never proof of no charge).
- Dev mock mirrors real Square for unknown-key replays: ccof: saved-card
  sources are charged and rescued; spent cnon: nonces surface
  ErrReplayKeyNotRetained. (Fixes dev/prod parity divergence.)
- Webhook dedup row committed AFTER dispatch (at-least-once); FAILED till sales
  claw back gift-card funding; event-type strings match Square's real catalog.
- Expired-gift-card cancellation refunds set creditFailed (never a phantom
  'completed' refund); cancellation refunds lock all payment rows ascending.
- Sweep never rescue-completes a gift-card purchase without delivering the card.
- Tip no-client-key fallback is a deterministic count-based key under the
  booking advisory lock (retry-safe, distinct tips don't collapse).
- M-cap subtracts completed refunds, clamped to [0, total].

2FA (PSD2 SCA stand-in) for online saved-card payments:
- Full feature: status/setup/verify/disable endpoints, gating helper wired into
  all 7 saved-card charge paths (incl. BuyGiftCard + admin saved-card), account
  admin-tab settings UI, frontend gating across all payment surfaces.
- Enforcement is FAIL-CLOSED: on unless REQUIRE_2FA=false or an explicit
  mock/dev SQUARE_ENVIRONMENT; startup warning when off in a non-dev env.
- Verify is brute-force hardened (5-attempt lockout, timing-safe compare);
  plaintext codes only logged when enforcement is off (dev).
- GDPR: anonymize_user also scrubs 2FA columns and staff notes.

Infra/docs:
- nginx: /api/ response cache removed (cross-user disclosure); port 80
  redirects to HTTPS (localhost/RFC1918 exempt, end-anchored regexes); HSTS;
  separate webhook rate-limit zone.
- Schema: users 2FA columns; payments/till_sales square_source_id +
  square_request_snapshot.
- Legal docs: gift-card cooling-off, international-transfers section, tips
  policy; Gap Backlog P3 webhooks marked done; stale counts/wording corrected.
- Flaky test race fixed (t.Parallel + global mock mutation); suite 26/26
  packages green, 2,142 tests, svelte-check clean.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 4b28e93710
commit e9b0f0f2a7
50 changed files with 4223 additions and 413 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ Set the customer (or select "Walk-in / Call-in Guest" for anonymous cards), the
**Payment methods for creating a card:**
- **Card Machine** — process via Square Terminal
- **Cash** — enter cash received
- **Card Details** — enter card number, expiry, and CVC for online processing
- **Card Payment** — the customer pays via Square-secured card entry (Square Web Payments SDK). Card numbers are tokenized client-side and never touch the app or server.
- **Giveaway** — create the card at no charge (for loyalty rewards, etc.)
**Top-Up a Gift Card** — Select a gift card and click **Top Up**. Choose whether the top-up is a Giveaway (no charge) or Purchase (customer pays). Enter the amount and choose the payment method.
@@ -26,7 +26,7 @@ These are things that work fine in dev (with mocks) but need real implementation
| # | Task | Effort | Area | Dev Status | Notes |
|---|---|---|---|---|---|
| P2 | **S3/R2 storage: implement prod side of the abstraction** | M (2-3d) | Backend | Dev works (`internal/s3/s3_dev.go` — RustFS + in-memory fallback). Prod side (`internal/s3/s3.go:52-62`) returns "not implemented" for Upload/Download/Delete. The prod `S3Client` struct lacks the `*s3.Client` field entirely — it was never populated. | The storage abstraction was defined early and the dev side got a full implementation. The prod side needs the AWS SDK v2 dependency and real S3/R2 calls. Portfolio images and profile pictures will start working in prod once this is done. |
| P3 | **Square webhook event handling: from log-only to action** | S (1d) | Backend | **Still open.** Webhook signature verification works (HMAC-SHA256) and is **fail-closed** (503 without the signing key, 403 on bad/missing signature), event parsing works, and `event_id` dedup is implemented (duplicate events are skipped). But `handlePaymentUpdated` (`square.go:152`) and `handleRefundUpdated` (`square.go:163`) still only log the event data — they never update booking/payment state. | In the interim, payment/refund state is tracked via the synchronous request paths plus the three background sweeps (`sweep-pending-square-refunds`, `sweep-stale-pending-payments`, `sweep-stale-terminal-checkouts`), which reconcile stuck states without webhook events. The handlers that act on events were deferred: update payment status on `payment.updated`, update refund status on `refund.updated`. |
| P3 | **Square webhook event handling: from log-only to action** | S (1d) | Backend | **DONE (Aug 2026)**`handlers/webhooks/square.go` now dispatches events to state-mutating handlers instead of logging only. HMAC-SHA256 verification is **fail-closed** (503 without the signing key, 403 on bad/missing signature, 400 on an empty `event_id`). Events are deduplicated by `event_id` — a fast-path in-memory cache plus a persistent `square_webhook_events` row committed **after** successful dispatch (at-least-once: on a dispatch error no dedup row is written and a 5xx is returned so Square retries; the handlers are idempotent). `payment.updated`/`payment.created` reconcile pending `payments` and `till_sales` (pending-only, with gift-card funding clawback on definitively failed charges), `refund.updated`/`refund.created` update `refunds`, and `dispute.created`/`dispute.state.updated` upsert `disputes` — a lost dispute marks the payment failed and raises a `critical_payment_log` admin notification. | The three background sweeps (`sweep-pending-square-refunds`, `sweep-stale-pending-payments`, `sweep-stale-terminal-checkouts`) remain the eventual backstop for stuck states. **Remaining limitation:** no in-app dispute-evidence submission — `dispute.evidence.*` and `terminal.checkout.*` events are still log-only, so evidence is filed via the Square Dashboard. |
| P4 | **Payment reconciliation: add recovery for split-brain scenarios** | L (3-5d) | Backend | **Partial progress (Aug 2026).** 20 `log.Printf("CRITICAL: ... manual reconciliation required")` calls exist across payment, refund, and till handlers. When Square succeeds but the DB transaction fails afterwards, state diverges with no automated recovery. The three background sweeps now provide interim recovery for *pending* states (refund sweep retries up to 3 attempts; stale-pending and terminal-checkout sweeps fail/clean stale rows), but a DB-commit failure after a successful Square charge still leaves no automated path to reconcile the orphaned Square-side payment. | This happens when the application correctly processes a Square payment but then hits a DB error on commit. In dev, this was handled by just logging it. For prod, we need a reconciliation job or retry mechanism. (Count grew from 18 to 20 with the stale-pending sweep's manual-reconciliation warnings in the Aug 2026 review round.) |
| P5 | **Till Purchases: wire the backend payment flow** | M (1d) | Frontend + Backend | ✅ **DONE (Aug 2026)**`backend/handlers/payments/till.go` implements the till-sale endpoint (cash, `card_machine` Terminal checkout + polling, `saved_card`, `online_square` Web Payments SDK nonce, `on_the_house`); `TillPurchases.svelte` wires all payment methods and the Charge button is enabled (gated only for retail-item carts, which cannot be charged yet). | The till UI and backend sale path are fully connected. Only retail-item charging remains deferred (see `TillPurchases.svelte` `canCharge`). |
| P6 | **Email/SMS notification delivery** | XL (5-7d) | Backend | `user_notification_preferences` table stores delivery preferences. 8 TODO markers reference this blocker. Notification creation works (admin_notifications table), but no delivery channel exists. No SMTP configuration, no SMS provider. 2 tests skipped as "WIP handler." | The notification queue works (reasons, priorities, acknowledging). What's missing is the delivery backend. Affects: slot eviction alerts, edit request approvals/denials, gift card codes, unpaid booking reminders, idle account warnings. |
@@ -1,6 +1,6 @@
# Gift Card Terms & Conditions
**Last Updated:** June 2026
**Last Updated:** August 2026
**Status:** DRAFT — Local development (not yet in production)
---
@@ -22,18 +22,19 @@ Gift cards can be purchased:
- **As gifts:** Purchased for another person (recipient receives card code)
### 3.2 Denominations
- Minimum value: £5
- Maximum value: £500 per card
- Custom amounts accepted (within range)
- **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
- Gift cards are **Single-Purpose Vouchers (SPVs)** under UK VAT law.
- **VAT is charged at point of purchase**, not at redemption.
- When you pay with a gift card, no additional VAT is charged (already paid).
- 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).
- This applies to both direct gift card payments and account balance payments.
**Legal basis:** HMRC VAT Notice 700/12, EU VAT Directive Article 30a
*VAT treatment is general information, not tax or legal advice — verify the position with an accountant before relying on it.*
---
## 3. Gift Card Expiry
@@ -62,7 +63,7 @@ We will send warning emails before expiry (if purchaser contact info available):
### 3.4 After Expiry
- Unredeemed gift cards: Balance becomes dormant, transferred to recovery system.
- No automatic refund.
- Recovery possible with card code (contact us within 6 years).
- Recovery possible with the card code — the app places no deadline on recovery claims.
**Legal basis:**
- UK Consumer Rights Act 2015: Expiry terms must be "fair and transparent"
@@ -108,7 +109,7 @@ If your account is deleted (due to inactivity or your request):
- Transferred to our recovery system.
- You receive your **Account ID** via email.
- You can recover balance at any time by providing Account ID.
- No deadline for recovery (but records deleted after 7 years per HMRC).
- No deadline for recovery (the dormant-balance record is retained indefinitely — Account ID only, no personal data).
---
@@ -135,22 +136,43 @@ To recover a dormant balance:
---
## 7. Right to Cancel (Online Purchases)
If you buy a gift card **online** (or by any distance method rather than face-to-face in the salon), the **Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013** give you a **14-day right to cancel**, running from the day after purchase.
- **How to cancel:** email us at help@crussell.invalid within 14 days of purchase with your order details and the gift card code, if you have it.
- **Refund:** we will refund the full purchase amount within 14 days of receiving your cancellation, to the original payment method.
- **When the right is lost:** the right to cancel ends once the gift card's value has been redeemed or used within the 14-day period. Redeeming a card or using it to pay for salon services starts the supply of those services at your request; once they are fully performed, the right to cancel no longer applies (regulation 36(2) of the Regulations). This is why we ask you to return the unused card code where possible.
- **In-store purchases:** this right applies to distance purchases only. Gift cards bought in person in the salon are not distance sales.
See our [[Terms & Conditions - Overall App#5. Distance Contracts & Right to Cancel|General Terms]] for the wider distance-contract position.
*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.*
---
## Appendix: VAT Examples
### Example 1: Direct Gift Card Payment
- Service cost: £60 (inc. VAT @ 20% = £10 VAT)
- Gift card purchased for £60 (inc. £10 VAT already paid)
The salon is **not currently VAT registered**, so no VAT is charged anywhere in these flows at present. The examples below also show the position if and when the salon registers for VAT.
### Example 1: Direct Gift Card Payment (current position — no VAT registered)
- Service cost: £60 (no VAT charged)
- Gift card purchased for £60 (no VAT charged)
- Payment with gift card: £60 deducted
- **No additional VAT charged** (already paid at gift card purchase)
- **No VAT element**
### Example 2: Account Balance Payment
- Gift card £50 redeemed to account (inc. £8.33 VAT already paid)
- Service cost: £30 (inc. VAT @ 20% = £5 VAT)
### Example 2: Account Balance Payment (current position — no VAT registered)
- Gift card £50 redeemed to account (no VAT charged)
- Service cost: £30 (no VAT charged)
- Payment with account balance: £30 deducted
- **No additional VAT charged** (already paid at gift card purchase)
- **No VAT element**
### Example 3: Split Payment
- Service cost: £60 (inc. VAT @ 20% = £10 VAT)
- Gift card balance: £40 (inc. £6.67 VAT already paid)
- Cash payment: £20 (inc. £3.33 VAT charged now)
- **Total VAT: £10** (£6.67 from gift card + £3.33 from cash)
### Example 3: Split Payment (current position — no VAT registered)
- Service cost: £60 (no VAT charged)
- Gift card balance: £40 (no VAT charged)
- Cash payment: £20 (no VAT charged)
- **No VAT element**
### Example 4: If the salon becomes VAT registered (SPV treatment)
- A £60 gift card purchased when VAT is 20% includes £10 VAT, collected at purchase.
- Redeeming that card for a service deducts £60 with **no additional VAT** — VAT was already collected at purchase.
+3 -1
View File
@@ -34,7 +34,9 @@ Square integration has two build-tagged implementations:
- **Dev** (`//go:build dev`): Mock client simulates async checkout with polling. No real payments.
- **Prod** (`//go:build !dev`): Connects to live Square API. Requires Square credentials in `.env`.
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 `/api/webhooks/square` receive payment/refund events (HMAC-verified **fail-closed** 503 without the signing key, 403 on bad signature; currently log-only — status is tracked via the synchronous + sweep/reconcile paths, backlog P3).
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 `/api/webhooks/square` 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 raises a `critical_payment_log` admin notification). The background sweeps remain as the eventual backstop.
**2FA on online card payments:** a loosely-faked two-factor-authentication feature stands in for PSD2 Strong Customer Authentication. Charging a **saved card** requires the user to have 2FA enabled when it is enforced (enforcement only when `REQUIRE_2FA` is not `false` **and** `SQUARE_ENVIRONMENT` is `sandbox`/`production`; new-card/nonce charges are not gated). The 6-digit code is currently delivered by logging it server-side (`[2FA]` prefix) — fake delivery until real email/SMS infrastructure lands. 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) are DEAD SCHEMA — zero Go references; they were a placeholder for Square bank reconciliation against Mettle. Keep them unused; backlog item T1 tracks dropping them, and Mettle/FreeAgent integration is a planned upcoming body of work.**
+19 -7
View File
@@ -1,7 +1,7 @@
# Privacy Policy
**Last Updated:** August 2026
**Status:** DRAFT — Local development (not yet in production). **§2.2 (Saved Cards & Square) is drafted from the P14 plan; the placeholder sections below still need to be made 'real' before go-live.**
**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.**
---
@@ -68,29 +68,41 @@ We collect health-related information with your **explicit consent**:
---
## 3. Data Retention & Deletion Process
## 3. International Transfers
### 3.1 Retention Schedule
Our payment processor, **Square**, is based in the United States. When you pay by card or save a card, the personal data that supports the payment — your name, email address, and card-payment references — is processed by Square and may be transferred outside the UK.
- **What actually crosses the border:** Square's payment script (Square.js) runs in your browser and tokenises your card details into a one-time nonce or a stored-card reference before anything is sent to our servers. We never send your full card number to Square's US systems ourselves; only these nonces and references (plus the name and email we already hold) travel to Square.
- **Lawful basis and safeguards:** transfers are made under **UK GDPR Article 46** on the basis of appropriate safeguards. We rely on **Square's Data Processing Addendum**, which incorporates the **UK International Data Transfer Addendum** and/or the **Standard Contractual Clauses** issued by the Information Commissioner's Office, to protect your data when it leaves the UK.
- **More information:** Square's privacy policy (linked in §2.2) explains how Square handles data on our behalf.
*This is a summary of a general nature, not legal advice; please verify the position with a solicitor before going live.*
---
## 4. Data Retention & Deletion Process
### 4.1 Retention Schedule
| Data Category | Retention Period | Legal Basis |
|---------------|------------------|-------------|
| **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** | 7 years | HMRC requirement |
| **Financial records** | 6 years (HMRC accounting requirement) + 1 year buffer (7 years total) | HMRC accounting requirement (6 years); 1-year buffer for dispute resolution |
| **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 |
| **Dormant balances** | Indefinite (Account ID only) | Recovery mechanism |
| **Marketing preferences** | Until withdrawn | Consent |
### 3.2 Deletion Process
### 4.2 Deletion Process
**Account deletion (your request):**
1. You confirm deletion (warning about data loss).
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 7 years (HMRC) then aggregated.
5. Financial records retained 6 years (HMRC accounting requirement) plus a 1-year buffer, then aggregated at 7 years.
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.
@@ -104,7 +116,7 @@ We collect health-related information with your **explicit consent**:
---
## 4. Your Rights
## 5. Your Rights
Under UK GDPR, you have the right to:
- **Access** your personal data (Article 15)
+23 -5
View File
@@ -50,7 +50,7 @@ Backend (:8080)
|---------|--------|---------|
| SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events |
| S3/R2 | Active (dev); prod side **planned** | Portfolio images (AVIF), profile pictures (WebP) |
| Square | **Active** | Payment processing — in-person Terminal (`CreateTerminalCheckout`) + online card payments (saved cards + new cards tokenized via the Square Web Payments SDK `cnon:` nonces; new-card entry is gated only when the frontend Square env vars are unset — see `plans/p11-square-web-payments-sdk.md`). Backend accepts only `cnon:`/`ccof:` tokens (raw PANs rejected). Dev mock (`//go:build dev`) mirrors production PCI-DSS behaviour; prod client (`!dev`) connects to live API. |
| Square | **Active** | Payment processing — in-person Terminal (`CreateTerminalCheckout`) + online card payments (saved cards + new cards tokenized via the Square Web Payments SDK `cnon:` nonces; new-card entry is gated only when the frontend Square env vars are unset — see `plans/p11-square-web-payments-sdk.md`). Backend accepts only `cnon:`/`ccof:` tokens (raw PANs rejected). Dev mock (`//go:build dev`) mirrors production PCI-DSS behaviour; prod client (`!dev`) connects to live API. Webhook events arrive at `/api/webhooks/square` — HMAC-verified fail-closed and dispatched to state-mutating handlers (see `handlers/webhooks`). |
| SMTP | Planned | Email/SMS notification delivery — upcoming body of work (backend not wired yet) |
| Mettle / FreeAgent | Planned | Accounting integration (bank feed + bookkeeping export) — upcoming body of work |
@@ -65,7 +65,7 @@ Backend (:8080)
| `handlers/auth` | local.go, social.go | Registration (with referral code validation), login, refresh, email verification |
| `handlers/bookings` | bookings.go, reserve.go, manage.go, admin_reserve.go, cancel_reservation.go, admin_cancel_reservation.go, closing_time.go | Booking CRUD, reservations with **self-blocking prevention** (`excludeUserID` parameter on `CheckTimeBlockerOverlap` + pre-overlap DELETE with IP hash anon cleanup), admin management, edit requests, discounts, closing hours validation (`checkClosingHours` + `getClosingTimeForDate` resolves staged default hours for bookings), active booking limits, GetBookingsByCreatedRange, created_by_name resolution, **explicit reservation cancellation** (`DELETE /api/bookings/reserve` for users, `DELETE /api/admin/bookings/reserve` for admin walk-in/call-in) |
| `handlers/payments` | handlers.go, service.go, validators.go, giftcards.go, till.go, refunds.go, refund_policy.go | Square payments: terminal, online, refunds, tips, saved cards, gift cards (CRUD, topup, transfer, redeem, buy, expired balances, till sales). Refund calculation with notice-period tiers and deposit protection |
| `handlers/webhooks` | square.go | Square webhook handler for payment status updates. **Fail-closed signature check** — rejects with 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset, and 403 when the `x-square-hmacsha256-signature` header is missing or invalid. HMAC-SHA256 signature verified per Square spec (base64 output, notificationURL + body). `payment.updated`/`refund.updated` events are currently **log-only** (backlog P3 — status flows through the synchronous + sweep/reconcile paths instead). |
| `handlers/webhooks` | square.go | Square webhook endpoint. **Fail-closed signature check** — rejects with 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset, 403 on a missing/invalid `x-square-hmacsha256-signature`, 400 on an empty `event_id`. HMAC-SHA256 verified per Square spec (base64 output, notificationURL + body). Events are deduplicated by `event_id` — a fast-path in-memory cache plus a persistent `square_webhook_events` row committed **after** successful dispatch (at-least-once: on any dispatch error no dedup row is written and a 5xx is returned so Square retries) — and dispatched to state-mutating handlers: `payment.updated`/`payment.created` reconcile `payments` and `till_sales` (pending-only, with gift-card funding clawback on definitively failed charges), `refund.updated`/`refund.created` update `refunds`, and `dispute.created`/`dispute.state.updated` upsert `disputes` — a lost dispute marks the payment failed and raises a `critical_payment_log` admin notification. `terminal.checkout.*` and dispute-evidence events are logged only. The background sweeps remain the eventual backstop. |
| `handlers/admin` | users.go, analytics.go, custom_services.go, discount_campaigns.go, settings.go | Admin user management, custom services CRUD (list/create/get/update/promote/delete), discount campaigns, analytics (stub), business settings (GET/PUT with VAT, gift card config) |
| `handlers/today` | today.go | Current/next appointment, today's grid, pending approvals, `DoneForDay` state with daily/weekly summary (`DailySummary` with `total_bookings`, `customers_served`, `summary_scope`), auto-status transitions, closed-day aggregation via `findWeekSummaryRange` + `computeAggregateSummary`. Exceptional hours lookup uses `exceptional_group_applications.week_start` (0=Monday). |
| `handlers/user` | profile.go, account.go, guest.go, loyalty.go, customer_relationship.go, gdpr_export.go | User profile, guest creation (with CheckEmailHandler for registered-email detection), loyalty, contact info, GDPR export (async with 12h cache) |
@@ -269,7 +269,7 @@ Added in the June 2026 security pass:
| Fix | File | Description |
|-----|------|-------------|
| Removed verification code logging | `handlers/auth/local.go:545` | Deleted `log.Printf("DEBUG: Verification code for %s: %s ...")` — was leaking verification codes to stdout |
| Webhook signature fail-closed | `handlers/webhooks/square.go:84-102` | Webhook verification is fully **fail-closed**: 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset (a misconfigured deployment must not silently accept forged events), 403 when the signature header is missing or invalid. Previously it skipped verification when the key was empty. (The `event_id` dedup-set struct lives at `square.go:34-60`.) |
| Webhook signature fail-closed | `handlers/webhooks/square.go:84-102` | Webhook verification is fully **fail-closed**: 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset (a misconfigured deployment must not silently accept forged events), 403 when the signature header is missing or invalid, 400 on an empty `event_id`. Previously it skipped verification when the key was empty. Dedup is now restart-safe: each handled event is recorded in the `square_webhook_events` table, with the row committed **after** successful dispatch (at-least-once; a failed dispatch writes no row and returns 5xx so Square retries), fronted by a bounded in-memory fast-path cache (the `squareWebhookDedup` struct, `square.go:36-82`). |
| S3 delete error checking | `handlers/portfolio/images.go:975` | Changed `s3.Client.Delete(...)` (ignored return) → `if err := s3.Client.Delete(...); err != nil { log.Printf(...) }` |
CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. No CSP violations expected — the SvelteKit SPA doesn't load external scripts or fonts.
@@ -404,7 +404,7 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS.
| PUT | `/api/admin/gift-cards/{id}/topup` | Top up gift card |
| POST | `/api/admin/gift-cards/{id}/transfer` | Transfer gift card to another user |
| POST | `/api/admin/gift-cards/{id}/redeem` | Redeem gift card to account balance |
| POST | `/api/gift-cards/buy` | User buys gift card online (with idempotency_key) |
| POST | `/api/user/giftcards/buy` | User buys gift card online (with idempotency_key) |
| GET | `/api/admin/custom-services` | List custom services (search `q`, popular, pagination `page`/`per_page`) |
| POST | `/api/admin/custom-services` | Create custom service (name, price, duration, minimum age, notes) |
| GET | `/api/admin/custom-services/{id}` | Get single custom service |
@@ -807,6 +807,22 @@ validTransitions := map[string]map[string]bool{
---
### Two-Factor Authentication (2FA) — PSD2 SCA stand-in
**What it is:** a loosely-faked two-factor-authentication feature that stands in for PSD2 Strong Customer Authentication on saved-card online charges until real SCA/email-SMS infrastructure lands. Enabling it is optional per-user; when enforcement is active, a user who has **not** enabled 2FA is blocked (403 JSON, parseable via `extractErrorMessage`) from saved-card online payment paths.
**Enforcement** (`twoFactorEnforced`, `handlers/payments/twofa.go`):
- Enforced only when `REQUIRE_2FA` is **not** `"false"` **AND** `SQUARE_ENVIRONMENT` is `sandbox` or `production`.
- Local dev (`SQUARE_ENVIRONMENT` empty or `"mock"`) never enforces. `REQUIRE_2FA=false` disables enforcement even in a deployed environment, for local testing.
**State:** stored on `users``two_factor_enabled BOOLEAN DEFAULT FALSE`, `two_factor_method` (`'email'` / `'sms'`), `two_factor_pending_code_hash` (SHA-256), `two_factor_pending_code_expires` (10-minute TTL). Only the digest is stored in the DB; the plaintext code is delivered by logging it with a `[2FA]` prefix — **fake delivery** until real email/SMS infrastructure replaces that log line. When enforcement is off (dev), the setup endpoint also returns the code in its response and verify accepts any code, so the flow is testable without grepping backend logs.
**Gate:** `requireTwoFactorForCardAccess` (`handlers/payments/twofa.go`) is called on the saved-card online charge paths — booking payments, tips, and saved-card till sales. New-card (nonce) charges are **not** gated; a verification token from Square's own SDK covers the SCA step on new-card entry. Disabling 2FA accepts a code field but ignores it — a documented loose-fake simplification until the real SCA flow requires re-authentication to disable.
**Endpoints:** `GET /api/user/2fa/status`, `POST /api/user/2fa/setup`, `POST /api/user/2fa/verify`, `POST /api/user/2fa/disable`. UI: Account → Two-Factor Authentication.
---
### Scheduling System
**Default Hours:** `working_hours` table (weekday 0-6, start_time, end_time, is_open). Bulk updateable via PUT.
@@ -1037,6 +1053,8 @@ A record is only deleted when **both** applicable conditions are met — the 7-y
**Tables:** `financial_aggregates`, `payments`, `refunds`
**Webhook retention is separate from financial records:** `square_webhook_events` dedup rows are swept after 90 days (`sweep-square-webhook-events`, daily 2:30am) — that retention exists to bound the dedup table and must never be mistaken for financial record-keeping. Tip records are stored in the `payments` table (`payment_type='tip'`) and in `refunds`, which are kept under the full financial-record retention above (7 years; HMRC requires 6 years from the end of the accounting period), so tips survive long after the webhook dedup rows are gone.
**Decision:** The aggregation is idempotent — safe to run repeatedly. All cleanup now runs on the centralised `jobs` scheduler (cron-based). See `backend/internal/jobs/cleanup.go` for schedules.
---
@@ -1288,7 +1306,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
### Test Coverage
**1,902 tests run** across all packages (4 skipped, 0 failures). 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,133 tests run** across all packages (4 skipped, 0 failures). 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 |
|---------|--------------|
@@ -1,6 +1,6 @@
# Terms & Conditions — Overall App
**Last Updated:** June 2026
**Last Updated:** August 2026
**Status:** DRAFT — Local development (not yet in production)
---
@@ -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 7 years (HMRC requirement) then aggregated.
- Booking history will be retained for 6 years (HMRC requirement) plus a 1-year buffer, then aggregated at 7 years.
- You will lose access to loyalty stamps, referral codes, and booking history.
**If your account has a balance:**
@@ -75,15 +75,18 @@ All warning emails include your Account ID for future balance recovery.
- Some services require a deposit (typically 20-50% of service cost).
### 2.2 Cancellations & Rescheduling
- **Client cancellation:** Must be made at least 24 hours before appointment.
- **Late cancellation (<24 hours):** Deposit may be forfeited.
- **No-show:** Deposit forfeited, may affect future booking eligibility.
- **Business cancellation:** Full refund or reschedule offered.
- **Client cancellation:** You can cancel from your Account page at any time. Refunds are calculated from how much notice you give before the appointment:
- **More than 72 hours' notice** — full refund of everything you have paid.
- **24 to 72 hours' notice** — partial refund: the salon keeps a protected deposit (up to 50% of the service total) and the remainder is refunded.
- **Less than 24 hours' notice** — no refund; payments are retained.
- **No-show:** treated like a cancellation with less than 24 hours' notice — no refund, and it may affect future booking eligibility.
- **Business cancellation:** if the salon cancels, the same notice-period calculation applies. Where the cancellation is the salon's fault (or in genuine emergencies), we may waive the retention ("forgive fees") and issue a full refund, or offer to reschedule.
- **How refunds are returned:** card payments are refunded back to the card via Square; gift-card payments are credited back to the gift card (or to your account balance); cash payments are credited to your account balance (in store, for guests).
### 2.3 Deposits
- Deposits are non-refundable if you cancel <24 hours before appointment.
- Deposits are non-refundable if you cancel (or don't show up) less than 24 hours before the appointment. Between 24 and 72 hours' notice, a protected deposit of up to 50% of the service total may be retained (see §2.2).
- Deposits are applied to your final bill.
- If we cancel, deposit is fully refunded.
- If the salon cancels and waives the retention ("forgive fees"), the deposit is refunded in full.
### 2.4 Service Changes
- We reserve the right to refuse service for health/safety reasons.
@@ -111,6 +114,15 @@ All warning emails include your Account ID for future balance recovery.
- Each payment method processed separately.
- Refunds apply proportionally to each payment method.
### 3.4 Tips Policy
Card tips are collected through Square alongside your payment and are recorded separately from the cost of your appointment.
- **Who tips belong to:** tips belong to the worker who provided the service. Crussell Salon is currently a sole trader whose only worker is the owner, so card tips are paid to the owner in full. Tips are **never** used to top up wages or as part of any wage calculation.
- **Allocation:** tips received in a pay period are allocated in full within one month of the end of that period.
- **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.
---
## 4. Gift Cards & Account Balances
@@ -128,9 +140,21 @@ For detailed gift card terms, see [[Gift Card Terms & Conditions]].
- If account deleted with balance, funds become dormant but recoverable with Account ID.
### 4.3 VAT Treatment
- Gift cards are Single-Purpose Vouchers (SPVs) under UK VAT law.
- VAT charged at point of gift card purchase, **not** at redemption.
- When you pay with gift card balance, no additional VAT charged (already paid).
- 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 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).
---
## 5. Distance Contracts & Right to Cancel
Purchases made on our Platform (rather than face-to-face in the salon) are **distance contracts** under the Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013. This gives you a **14-day right to cancel** most online purchases, running from the day after purchase.
- **Gift cards bought online** carry this 14-day right unless they have been redeemed or used within that period — see the [[Gift Card Terms & Conditions#7. Right to Cancel (Online Purchases)|Gift Card Terms]].
- **Appointment bookings** made online for a specific date are services with a specified date of performance (regulation 28(1)(h) — services related to leisure activities), so the 14-day right does not apply to the service itself; our cancellation and refund policy in section 2 applies instead.
- **How to exercise it:** email help@crussell.invalid within 14 days. Refunds are made within 14 days, to the original payment method.
*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%, 1,902 tests passed, 4 skipped)
**Last Updated:** August 2026 (v6 — coverage 50.4%→65.0%, 2,133 tests passed, 4 skipped)
---
@@ -86,6 +86,7 @@ Features and their tests should pass `-count=1` for iterative development, but a
- Goroutine-unsafe package-level variables used concurrently (fixed: `titleCaser` in `local.go`, `testEmailCounter` in fixtures)
- Shared global state cleared by one test affecting another (`loginInProgress` map in auth handler)
- Polling timeouts in mock clients (`square_dev_test.go` mockSleep 3s vs test polling 100ms)
- Tests that mutate package-global mocks (e.g. swapping `SquareClient`) must NOT use `t.Parallel()` — one test's mutation races another test's reads (B1 flaky-test lesson)
---
@@ -501,7 +502,7 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic
|--------|-------|
| Quick check (`-count=1`) | **~2min** |
| Packages | 25 tested, 0 failures |
| Tests | 1,902 passed, 4 skipped, 0 failing |
| Tests | 2,133 passed, 4 skipped, 0 failing |
New test additions in this batch:
| Test | Coverage |
@@ -520,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:** 1,902 passed across all packages (4 skipped). 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,133 passed across all packages (4 skipped). 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
@@ -637,7 +638,7 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use
### Q: What's the total test count?
1,902 tests run across all packages (4 skipped). 0 failures.
2,133 tests run across all packages (4 skipped). 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).