From 6bd952238e89e317215f23e48417e2407a937525 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 16 Aug 2026 12:27:21 +0100 Subject: [PATCH] =?UTF-8?q?docs:=20README=20+=20obsidian=20parity=20?= =?UTF-8?q?=E2=80=94=202FA/verification-code=20delivery=20is=20dev/test-on?= =?UTF-8?q?ly=20stdout=20log,=20production=20fails=20closed=20until=20emai?= =?UTF-8?q?l/SMS=20(P6);=20fixed=20posture,=20counts,=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TWO_FACTOR_ALLOW_LOG_DELIVERY production opt-in removed from every doc: log delivery reframed as a local DEV ONLY feature while email/SMS is implemented - Technical Manual: 2FA state + delivery, /api/verify/generate route table, future-work item - Feature Catalog: SCA posture + verification-code feature - payments and money processes: relay model + env var reference (removed) - Overview, User Manual: delivery posture - test counts, refresh-token grace, session lifetime, deposit advance, gift-card expiry, patch-test notice kept accurate --- README.md | 6 +-- obsidian/Crussell/Admin Manual.md | 4 +- obsidian/Crussell/Feature Catalog.md | 42 ++++++++++--------- .../Crussell/Future Work - Gap Backlog.md | 2 +- .../Crussell/Gift Card Terms & Conditions.md | 2 +- obsidian/Crussell/Overview.md | 10 ++--- obsidian/Crussell/Privacy Policy.md | 10 +++++ obsidian/Crussell/Technical Manual.md | 38 +++++++++-------- .../Terms & Conditions - Overall App.md | 4 +- .../Testing Architecture & DB Management.md | 16 +++---- obsidian/Crussell/User Manual.md | 16 +++---- .../Crussell/payments and money processes.md | 18 ++++---- 12 files changed, 94 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 2f5f676..e5b5d77 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL **Custom Services**: One-off or special-request services not in the permanent catalog. Admin management with create, edit, promote to permanent service (migrates booking references), and delete. Full CRUD API with search, popular sorting, and pagination. Can be added to any booking alongside regular services. -**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Custom services (one-off services with create/edit/promote/delete). Discount campaigns (time-based and milestone). Time blocker CRUD. Portfolio image upload with tag management. Gift card management. Business settings (VAT, gift card config). Notification queue with priority ordering. The money-critical `critical_payment_log` / `refresh_token_reuse` notification queue is flood-capped at `adminnotify.MaxUnacknowledgedCriticalLogs` (100 unacknowledged rows per reason), folded atomically into the INSERT at every insert site (Square webhooks, account-erasure cleanup, time-blockers cleanup, the critical-log scan job, refresh-token-reuse detection, and the payment-sweep path); at the cap, further inserts are suppressed with an operator-facing log until outstanding notifications are acknowledged (which re-arms inserts). +**Admin**: Today page with interactive calendar grid. Booking management (create, edit, reschedule, approve, cancel). User management with customer relationship data (spend, visits, top services). Custom services (one-off services with create/edit/promote/delete). Discount campaigns (time-based and milestone). Time blocker CRUD. Portfolio image upload with tag management. Gift card management. Business settings (VAT, gift card config). Notification queue with priority ordering. The money-critical `critical_payment_log` / `refresh_token_reuse` notification queue is flood-capped at `adminnotify.MaxUnacknowledgedCriticalLogs` (100 unacknowledged rows per reason), folded atomically into the INSERT at every insert site (Square webhooks, account-erasure cleanup, time-blockers cleanup, the critical-log scan job, refresh-token-reuse detection, the payment-sweep path, the booking-creation `new_booking`/`pending_booking` inserts, the cancellation and edit-request inserts, the `refund_failed` insert, the deposit-deadline cleanup's `deposit_not_paid_by_deadline` insert, and the 1-week/1-month unpaid-booking notices); at the cap, further inserts are suppressed with an operator-facing log until outstanding notifications are acknowledged (which re-arms inserts). **Loyalty & Discounts**: 1 stamp per paid appointment (max 1/day). 10 stamps → 10% off via opt-in checkbox at payment or till. Stamps refunded on cancellation. Campaigns auto-apply at both payment and completion: time-based, per-user milestone, global milestone (in-person only), anniversary. All discounts stack additively against original total. Discount payment records excluded from refund calculations. @@ -82,7 +82,7 @@ The backend replays a byte-identical request to Square when it rescues a stale p Saved-card online payments are authorised **exclusively** by Square **PSD2 SCA** (buyer verification via the Web Payments SDK's `tokenizeWithVerification`). A customer-initiated stored-credential charge is a PSR 2017-regulated transaction: Square's verification token both satisfies SCA and shifts chargeback liability to the card scheme. On the wire, the 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 but is no longer the primary contract). 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). 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 regardless of consent). The `TWO_FACTOR_FALLBACK` switch was **removed entirely**. The dev Square mock simulates SCA (`SimulateSavedCardVerificationRequired` + `cnon:sca-...` tokenize-results), so development has full parity with the SCA-only production posture. -Homegrown 2FA remains for **admin and account verification only** — 2FA setup, disable, and delete-account re-authentication — **never** for authorising a card charge. The gate itself is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. The intended 2FA delivery channel is email/SMS (the method chosen at setup), **not yet wired**; until it lands, the 6-digit code is delivered via the server log (`[2FA]` prefix; the operator relays it) — but only in production builds when `TWO_FACTOR_ALLOW_LOG_DELIVERY=true` is set (an explicit, insecure opt-in); without it, production code issuance fails closed (503) and no user can complete 2FA setup or disable. +Homegrown 2FA remains for **admin and account verification only** — 2FA setup, disable, and delete-account re-authentication — **never** for authorising a card charge. The gate itself is **fail-closed**: enforcement is ON by default for any `SQUARE_ENVIRONMENT` except an explicit `mock`/`dev`/`development`/`test` value — empty or unknown values are treated as production-enforced. Disable it with `REQUIRE_2FA=false` or an explicit mock env. 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 to the **local dev stdout log** (`[2FA]` prefix; the developer/operator relays it) in **dev/test builds only** — stdout-log delivery is a local-dev convenience, never a production channel. Production builds have **no delivery channel at all** and 2FA code issuance **fails closed (503)** — no user can complete 2FA setup or disable — until the email/SMS transport is implemented. ### Local dev (tmux) @@ -101,7 +101,7 @@ Default logins (password: `password`): ```bash cd backend && go build -o bin/backend ./main.go cd frontend && npm ci && npm run build -cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,555 backend test functions under the test,dev tags + 129 frontend vitest cases, as of 15 Aug 2026 (~2min) +cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 2,554 backend test functions under the test,dev tags (per `go test -tags "test,dev" -list 'Test.*'`) + 130 frontend vitest cases (93 plain `it(` + 37 `it.each` rows in square.test.ts), as of 16 Aug 2026 (~2min) cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min) # NOTE: -count=N>1 is unreliable for handlers/payments and handlers/webhooks — # those suites share package-global state (Square mock ledger, in-memory webhook diff --git a/obsidian/Crussell/Admin Manual.md b/obsidian/Crussell/Admin Manual.md index 1440cae..c2d9949 100644 --- a/obsidian/Crussell/Admin Manual.md +++ b/obsidian/Crussell/Admin Manual.md @@ -712,7 +712,7 @@ Discounts are only applied through active campaigns or loyalty stamps. If they d ### "A customer says they want to reschedule for tomorrow but the system won't let them" -If they have deposit obligations, they can only book appointments at least 24 hours away. If they don't have deposits, they can book as long as it's at least 1 hour before the start time. Also, after 22:00, the system blocks next-morning slots (00:00–11:00) for non-admin users. +If they have deposit obligations, they can only book appointments at least 36 hours away. If they don't have deposits, they can book as long as it's at least 1 hour before the start time. Also, after 22:00, the system blocks next-morning slots (00:00–11:00) for non-admin users. ### "A customer says they see two bookings on the same day but they only booked one" @@ -722,6 +722,8 @@ Check if they have a guest booking and a registered booking. Guest bookings are Guest data is anonymized 6 months after the appointment. Registered account data is kept until the account is deleted. They can request a full data export from their Account page via the GDPR section. +**Account deletion re-authenticates the customer** (Account page → GDPR): the backend requires the customer's **current password**, plus a fresh one-time **2FA code** when 2FA is enforced and enabled on the account, before any erasure happens. If a customer has forgotten their password and the deletion fails, they need a password reset (or the operator can support them out-of-band). + ### "A customer says they can't see certain services" If they're logged in and under the minimum age, those services are hidden. If a service is greyed out, it needs a patch test. If they're not logged in, all services are shown (age is checked later). diff --git a/obsidian/Crussell/Feature Catalog.md b/obsidian/Crussell/Feature Catalog.md index d751730..a75afdc 100644 --- a/obsidian/Crussell/Feature Catalog.md +++ b/obsidian/Crussell/Feature Catalog.md @@ -27,11 +27,12 @@ Three booking flows for creating appointments, each with its own entry point and **Related:** [[Services Catalog|1.5 Services Catalog]], [[Patch Tests|1.6 Patch Tests]], [[Deposit System|1.2 Deposit System]], [[Slot Reservation System|1.8 Slot Reservation System]] ### 1.2 Deposit System -**What it does:** Some customers need to pay a 20% deposit upfront to secure a booking (enforced after accumulating no-shows). The deposit must be paid 24 hours before the appointment, or the slot becomes vulnerable to eviction. +**What it does:** Some customers need to pay a 20% deposit upfront to secure a booking (enforced after accumulating no-shows). The deposit must be paid 24 hours before the appointment, or the slot becomes vulnerable to eviction. When deposit restrictions are active, bookings must also be made **at least 36 hours in advance** (`DepositAdvanceWindow`, `refund_policy.go`) — the customer needs time to pay the deposit before the 24-hour deadline. **Key rules:** - Deposit required = 20% of booking total -- Deadline = 24 hours before start time +- Deadline = 24 hours before start time (the `DepositDeadlineWindow`) +- Advance window = deposit-obligated bookings must be made at least 36 hours before the appointment start - If unpaid by deadline → booking enters `pending_release` (slot vulnerable) - If another booking claims the slot → evicted to `deposit_lapsed` - Paying ≥20% at any point promotes back to `confirmed` @@ -62,7 +63,7 @@ Three booking flows for creating appointments, each with its own entry point and **Related:** [[Custom Services|1.7 Custom Services]], [[Patch Tests|1.6 Patch Tests]] ### 1.6 Patch Tests -**What it does:** Some services (e.g., acrylics) require a patch test 24-48 hours before the appointment. The system tracks which customers have taken which tests and when they expire (6 months). +**What it does:** Some services (e.g., acrylics) require a patch test ahead of the appointment (default notice window: 24 hours — `patch_tests.notice_duration_hours`, defaulted to 24 in the schema). The system tracks which customers have taken which tests and when they expire (6 months). **Layman summary:** "You need an allergy test before some treatments. The system remembers when you had it and nags if it's expired." @@ -205,7 +206,7 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif **Related:** [[Booking System|1. Booking System]] (completed bookings trigger tip eligibility), [[Today Page|7. Today Page]] (daily tips summary) ### 2.7 Refunds -**What it does:** Admin processes refunds with automatic routing by payment method. Square card payments are refunded via Square API (post-DB-commit). Cash refunds are credited as gift card balance. Gift card payments are returned to the card's balance. Refunds are resolved by Square's actual status (COMPLETED→completed, PENDING→left pending for the sweep, FAILED/REJECTED→failed) so an in-flight refund never blocks the over-refund guard. A background sweep (`sweep-pending-square-refunds`) retries/reconciles stuck refunds with a 23h age guard and surfaces failures as admin notifications. +**What it does:** Admin processes refunds with automatic routing by payment method. Square card payments are refunded via Square API (post-DB-commit). Cash refunds are credited to the **customer's account balance** (`user_giftcard_balances` — redeemable against future bookings; guest cash refunds are not credited — the admin processes them at the till). Gift card payments are returned to the card's balance. Refunds are resolved by Square's actual status (COMPLETED→completed, PENDING→left pending for the sweep, FAILED/REJECTED→failed) so an in-flight refund never blocks the over-refund guard. A background sweep (`sweep-pending-square-refunds`) retries/reconciles stuck refunds with a 23h age guard and surfaces failures as admin notifications. **Layman summary:** "If a booking is cancelled, the system automatically refunds the right amount to the right place." @@ -233,7 +234,7 @@ 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 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. +**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, codes are delivered to the **local dev stdout log** (`[2FA]` prefix) in **dev/test builds only** — a local-dev convenience while email/SMS delivery is implemented. Production builds have **no delivery channel** and code issuance **fails closed (503)**. **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." @@ -625,7 +626,7 @@ JWT-based authentication with refresh token rotation, role-based access control, **Related:** [[Rate Limiting|8.4 Rate Limiting]] ### 8.6 Email Verification & Password Reset -**What it does:** Backend supports sending verification codes for email verification and password reset. The frontend verification/reset UI is not yet wired (notable gap). +**What it does:** Backend supports sending verification codes for email verification and password reset. Codes are **hashed** (never stored or logged in plaintext) and delivered via a `[VERIFY]` stdout-log relay: dev/test builds always log the code for **local development**; production builds **fail closed (503)** — stdout-log delivery is a dev-only convenience, never a production channel, and there is no email/SMS transport yet (P6). The frontend verification/reset UI is not yet wired (notable gap — there is no email/SMS transport yet). **Layman summary:** "The system can send verification codes, but the 'forgot password' link isn't on the website yet." @@ -661,7 +662,7 @@ Full compliance with UK GDPR, including Article 15 data export, right to erasure **Related:** [[Background Jobs|13. Background Jobs]] (gdpr export cache cleanup), [[Frontend Architecture|15. Frontend Architecture]] (GDPR page) ### 9.2 Account Deletion (Right to Erasure) -**What it does:** Customers can delete their account. The system scrubs PII from all child tables (social logins deleted, saved cards soft-deleted, verification codes expired, notes nulled, notification prefs deleted). Also scrubs external systems (S3 profile pictures deleted; Square saved cards deleted via the Square Cards API, and the stored `square_card_id` / `square_customer_id` references are NULLed so no Square identifiers survive the erasure). +**What it does:** Customers can delete their account. Deletion **re-authenticates the customer** before anything is erased: the backend requires the **current password** (bcrypt-compared) plus, in enforced environments for a user with 2FA enabled, a **fresh one-time 2FA verification code** (single-use, consumed on verify) — a stolen session token alone can never delete an account. The system then scrubs PII from all child tables (social logins deleted, saved cards soft-deleted, verification codes expired, notes nulled, notification prefs deleted). Also scrubs external systems (S3 profile pictures deleted; Square saved cards deleted via the Square Cards API, and the stored `square_card_id` / `square_customer_id` references are NULLed so no Square identifiers survive the erasure). **Layman summary:** "Delete your account and we wipe your data — everywhere." @@ -731,26 +732,27 @@ A pull-based admin notification queue with priority ordering. Note: email/SMS de ### 11.1 Admin Notification Queue **What it does:** Internal notifications for the admin about important events: new bookings, cancellations, edit requests, deposit deadlines, unpaid reminders, and schedule changes. -**All 16 notification reasons (DB enum):** +**All 17 notification reasons (DB enum):** | Reason | Trigger | Created By | |--------|---------|------------| -| `new_booking` | Every customer-created booking unconditionally | `bookings.go:2328` | -| `pending_booking` | Booking needs admin approval (has notes or is today) | `bookings.go:2349`, `manage.go:1693` | -| `cancelled_booking` | Booking cancelled by customer or admin | `bookings.go:3330`, `manage.go:101,242` | -| `edit_requested` | User requests booking edit/reschedule | `manage.go:1673` | -| `deposit_not_paid_by_deadline` | Deposit deadline passed in `pending_release` | `time-blockers.go:760` (cron every 5min) | -| `1_week_no_pay` | Booking ended 7-30 days ago with no payment | `scheduled-cleanup.go:71` (cron daily 07:00) | -| `1_month_no_pay` | Booking ended 30+ days ago with no payment | `scheduled-cleanup.go:139` (cron daily 07:30) | +| `new_booking` | Every customer-created booking unconditionally | `CreateBookingHandler` (`bookings.go`) | +| `pending_booking` | Booking needs admin approval (has notes or is today) | `CreateBookingHandler`, `RequestEditHandler` (`bookings.go`, `manage.go`) | +| `cancelled_booking` | Booking cancelled by customer or admin | `UserCancelBookingHandler`, `AdminCancelBookingHandler`, `DeleteBookingHandler` (`manage.go`, `bookings.go`) | +| `edit_requested` | User requests booking edit/reschedule | `RequestEditHandler` (`manage.go`) | +| `deposit_not_paid_by_deadline` | Deposit deadline passed in `pending_release` | `CleanupExpiredDeposits` (`time-blockers.go`, cron every 5min) | +| `1_week_no_pay` | Booking ended 7-30 days ago with no payment | `NotifyUnpaidOneWeek` (`scheduled-cleanup.go`, cron daily 07:00) | +| `1_month_no_pay` | Booking ended 30+ days ago with no payment | `NotifyUnpaidOneMonth` (`scheduled-cleanup.go`, cron daily 07:30) | | `gift_card_purchased_for_friend` | *(defined in DB enum — code removed, email delivery pending)* | Removed — email TODO | -| `default_hours_changed` | Staged hours change auto-applied at midnight | `scheduled-cleanup.go:342` (cron daily 00:05) | +| `default_hours_changed` | Staged hours change auto-applied at midnight | `ApplyScheduledDefaultHours` (`scheduled-cleanup.go`, cron daily 00:05) | | `late_cancellation` | *(defined in DB enum — NOT yet created by any code)* | Planned | | `rescheduled_booking` | *(defined in DB enum — NOT yet created by any code)* | Planned | | `affiliate_claim` | *(defined in DB enum — NOT yet created by any code)* | Planned | | `deposit_paid` | *(defined in DB enum — NOT yet created by any code)* | Planned | | `edit_request` | *(defined in DB enum — only `edit_requested` is used in code)* | Unused | -| `refund_failed` | A refund definitively fails after the refund sweep's retries (or is rejected by Square) | `refunds.go:774` | -| `critical_payment_log` | Lost disputes and CRITICAL payment-log lines surfaced to the admin via the sweep job | `cleanup.go:312` (sweep), dispute handling | +| `refund_failed` | A refund definitively fails after the refund sweep's retries (or is rejected by Square) | `insertRefundFailedNotifications` (`refunds.go`) | +| `critical_payment_log` | Lost disputes and CRITICAL payment-log lines surfaced to the admin | `insertCriticalPaymentNotification` (`sweep.go`, Square webhooks), `ScanCriticalPaymentLogs` (`cleanup.go`) | +| `refresh_token_reuse` | A rotated refresh token replayed after the 20s grace window — treated as theft, the whole rotation family is revoked | `VerifyRefreshToken` (`auth/jwt.go`) | **Layman summary:** "Staff get notified when things happen — new bookings, cancellations, unpaid reminders." @@ -857,7 +859,7 @@ A centralized cron scheduler that runs 26 maintenance jobs for cleanup, transiti **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) -### 13.1 Every 5 Minutes +### 13.1 Every 5 Minutes (plus the 15-Minute Terminal-Checkout Sweep) - **cleanup-reservations**: Delete expired slot reservations by TTL type - **cleanup-expired-deposits**: Move unpaid bookings to `pending_release` state - **cleanup-rate-limiters**: Purge stale rate limiter entries @@ -948,6 +950,8 @@ The SvelteKit 5 static SPA that powers the entire user interface. - `/pay-tip/[id]` — Tip payment page - `/privacy-policy` — Privacy policy page - `/terms` — Terms & conditions page +- `/cancellation-policy` — Booking, deposit & cancellation policy (refund tiers verbatim) +- `/gift-card-terms` — Gift card terms & conditions (linked from the site footer) **Related:** [[Self-Service Booking (Customer-Facing)|1.1 Self-Service Booking]], [[Admin Dashboard|5. Admin Dashboard]], [[Today Page|7. Today Page]], [[Contact Page|14.1 Contact Page]] diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 82c30f2..4d955d3 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -46,7 +46,7 @@ These are missing functionality that prevents daily operations, legal compliance | # | Gap | Effort | Area | Notes | |---|---|---|---|---| -| M1 | **VAT/Tax export endpoints** | M (1-2d) | Backend | 6 SQL functions exist (`get_vat_return_data`, `export_sales_transactions`, `get_monthly_business_summary`, `get_sales_totals`, `calculate_vat`, `get_receipt_data`) but no Go handler calls them. Needed for HMRC MTD compliance. | +| M1 | **VAT/Tax export endpoints** | M (1-2d) | Backend | **9 VAT/sales SQL functions exist** in `init-scripts/init-script.sql`: `get_vat_return_data`, `export_sales_transactions`, `get_monthly_business_summary`, `get_sales_totals`, `calculate_vat`, `get_receipt_data`, `enable_vat_registration`, `apply_vat_to_payment`, `apply_vat_to_till_sale`. **7 are not exposed by any Go endpoint** (the first seven listed); `apply_vat_to_payment` and `apply_vat_to_till_sale` ARE wired through `handlers/payments/vat.go` (applied on booking payments and till sales). Needed for HMRC MTD compliance. | | M2 | **Password reset — no frontend route** | S (2-3h) | Frontend | Backend has verification codes with `verification_purpose = 'password_reset'` and `/verify/generate` + `/verify/check` endpoints. No "forgot password" link or reset form exists. | | M3 | **Email verification calls wrong API endpoint** | S (2h) | Frontend | `+layout.svelte:33` calls `/api/verify-email` which 404s. Correct endpoints: `POST /api/verify/generate` and `POST /api/verify/check`. Every login triggers a silent failure. | | M4 | **CSRF protection** | S (2-3h) | Backend | SvelteKit handles CSRF for its own forms, but direct API calls to `/api/*` bypass it. | diff --git a/obsidian/Crussell/Gift Card Terms & Conditions.md b/obsidian/Crussell/Gift Card Terms & Conditions.md index 5107b9f..0271238 100644 --- a/obsidian/Crussell/Gift Card Terms & Conditions.md +++ b/obsidian/Crussell/Gift Card Terms & Conditions.md @@ -56,7 +56,7 @@ The 24-month period **resets** with each use. For example: - Card topped up: 1 December 2024 → Expiry now 1 December 2026 ### 3.3 Expiry Notification -Your gift card code and its expiry date are always available in your account. Where a contact email is on file and email delivery is available, we will send a reminder before expiry. Please note the expiry date of any gift card you hold. +Your gift card code and its expiry date are always available in your account. Please note the expiry date of any gift card you hold. (An email reminder before expiry is planned for when email delivery is wired up — it is not sent today.) ### 3.4 After Expiry - Unredeemed gift cards: Balance becomes dormant, transferred to recovery system. diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md index 049077c..8f996ce 100644 --- a/obsidian/Crussell/Overview.md +++ b/obsidian/Crussell/Overview.md @@ -20,7 +20,7 @@ Slot reservations stored as `time_blocker` entries with `RESERVATION:*` descript 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. -**Deposit system:** Bookings have a 24h deposit deadline. Unpaid bookings enter `pending_release` — the slot becomes vulnerable to eviction by overlapping new bookings. Payment of >= 20% of the total at any point promotes back to `confirmed`. Evicted bookings enter `deposit_lapsed`. Admin forgiveness (`forgive_fees`/`forgive_noshow`) on cancel and reschedule. Refund calculation uses notice-period tiers (72h/24h full/partial/none) with deposit protection capping retention at 50% of total. The frontend derives the 20%/50% figures from the shared `POLICY` constants (`frontend/src/lib/constants/policy.ts`: `REQUIRED_DEPOSIT_PCT` = 0.2, `PROTECTED_DEPOSIT_MAX_PCT` = 0.5), single-sourced with the backend's `refund_policy.go`, instead of per-file literals. +**Deposit system:** Bookings have a 24h deposit deadline (`DepositDeadlineWindow`); deposit-obligated bookings must be made at least 36h in advance (`DepositAdvanceWindow`). Unpaid bookings enter `pending_release` — the slot becomes vulnerable to eviction by overlapping new bookings. Payment of >= 20% of the total at any point promotes back to `confirmed`. Evicted bookings enter `deposit_lapsed`. Admin forgiveness (`forgive_fees`/`forgive_noshow`) on cancel and reschedule. Refund calculation uses notice-period tiers (72h/24h full/partial/none) with deposit protection capping retention at 50% of total. The frontend derives the 20%/50% figures from the shared `POLICY` constants (`frontend/src/lib/constants/policy.ts`: `REQUIRED_DEPOSIT_PCT` = 0.2, `PROTECTED_DEPOSIT_MAX_PCT` = 0.5), single-sourced with the backend's `refund_policy.go`, instead of per-file literals. Service eligibility filters by age (min age on service) and patch test validity (6-month expiry, 24-hour notice period). Guest accounts are disposable — no identity tracking across bookings, PII scrubbed 6 months after appointment. @@ -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-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]]. +**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 to the **local dev stdout log** (`[2FA]` prefix; the developer/operator relays it) in **dev/test builds only** — stdout-log delivery is a local-dev convenience while email/SMS is implemented, never a production channel. Production builds have **no delivery channel at all** and code issuance **fails closed (503)** so no user can complete 2FA setup or disable until the email/SMS transport ships (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.** @@ -97,7 +97,7 @@ Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `a ### Frontend -**Routing**: SvelteKit 5 static SPA with adapter-static. Routes: home, book (5-step wizard + welcome step for unauthenticated), login/register, account (profile, bookings, loyalty, saved cards, gift cards, notifications, GDPR export), admin dashboard, today page, portfolio (tag/category filtering + multi-format images), prices, contact (dynamic from first admin user + MapLibre map), schedule, `/admin/schedule` (week view), `/pay-tip/[id]`, `/tip`, `/privacy-policy`, `/terms`, `/gdpr`, `/admin/notifications`. +**Routing**: SvelteKit 5 static SPA with adapter-static. Routes: home, book (5-step wizard + welcome step for unauthenticated), login/register, account (profile, bookings, loyalty, saved cards, gift cards, notifications, GDPR export), admin dashboard, today page, portfolio (tag/category filtering + multi-format images), prices, contact (dynamic from first admin user + MapLibre map), schedule, `/admin/schedule` (week view), `/pay-tip/[id]`, `/tip`, `/privacy-policy`, `/terms`, `/cancellation-policy`, `/gift-card-terms`, `/gdpr`, `/admin/notifications`. **Component library**: PaymentModal (multi-method, service price overrides, tip presets), UserPaymentModal (deposit/partial/full/balance), SquareCardInput (Web Payments SDK tokenization), CardSelection (saved-card list + new-card toggle), TipPayment (shared tip flow), MockCardForm (local dev mock card form), BookingFlow (5 steps, auto-select, shared timeSlots utils), TodayCalendar (interactive grid), PendingApprovals (dedup refresh), NavBar (responsive with notification badge), PhoneInput (UK validation), CharCounter (grapheme counter for notes), ImageVariant (multi-format `` element), MapLibre GL map components. @@ -220,7 +220,7 @@ npm run dev # Dev server with HMR ```bash cd backend -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" ./... # 2,554 backend test functions compiled (under test,dev tags, per `go test -tags "test,dev" -list 'Test.*'`) + 130 frontend vitest cases (93 plain `it(` + 37 `it.each` rows), as of 16 Aug 2026 go test -tags "test,dev" -v -run TestName ./... # Single test ``` @@ -229,7 +229,7 @@ Test infrastructure notes: - **⚠️ Build tag:** Always use `-tags "test,dev"`. The `dev` tag is required by Square mock (`internal/square/square_dev.go`) and rate limiter (`mw/ratelimit_dev.go`). Without it, handlers/payments and handlers/bookings tests are silently skipped. - **PoolProxy architecture:** `db.Conn` is a `*db.PoolProxy` that routes DB calls through per-test transactions stored in context. Production handlers pass `r.Context()`; tests inject tx context via `req.WithContext(ctx)`. - **SetupTestTx pattern:** Each test begins a PostgreSQL transaction (`testutils.SetupTestTx(t)`) that automatically rolls back via `t.Cleanup`. No truncation between tests. -- **t.Parallel() supported:** ~90% of tests use `t.Parallel()` with per-test transaction isolation. New tests (duplicate completion guard, daily stamp cap, invalid transitions, sequential edit, timezone independence, past-booking no-show guard) all use `t.Parallel()`. +- **t.Parallel() supported:** roughly **40%** of test functions call `t.Parallel()` directly (all `SetupTestTx` DB tests can; packages that mutate package-global mocks — the Square mock ledger, the in-memory webhook dedup cache — deliberately omit it). See Testing Architecture. - **PreferSimpleProtocol:** Test pools disable prepared statements to prevent "conn busy" errors on parallel transactions. - Statement-by-statement SQL parser (`splitSQLStatements()`) respects dollar-quoted PL/pgSQL blocks - Build tag: all test files use `//go:build test` diff --git a/obsidian/Crussell/Privacy Policy.md b/obsidian/Crussell/Privacy Policy.md index 426c533..2befd31 100644 --- a/obsidian/Crussell/Privacy Policy.md +++ b/obsidian/Crussell/Privacy Policy.md @@ -80,6 +80,16 @@ If your bank cannot complete the SCA step, the payment cannot be processed and i 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. +### 2.6 Third Parties & Infrastructure + +We use a small number of third-party services to operate the Platform. Each receives only the data needed for its function: + +- **Cloudflare** — our edge proxy and CDN. Cloudflare routes traffic to the Platform and enforces our UK-only geo-block; its edge servers see the IP address you connect from (conveyed to us as `CF-Connecting-IP` where we need to identify a connection). +- **Cloudflare R2 / S3-compatible object storage** — profile pictures are stored in object storage (the `crussell-profile-pics` bucket). +- **CardDAV / sabre/dav sync** — your profile photo is synchronised to a CardDAV address-book endpoint so it displays consistently across the Platform. +- **Google Fonts** — the Playfair Display typeface is loaded from `fonts.googleapis.com`; Google's servers see your IP address when your device fetches the font. +- **CARTO** — map tiles on the contact page are served from `basemaps.cartocdn.com`; CARTO's servers see your IP address when your device fetches map tiles. + --- ## 3. International Transfers diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 9b5dbe6..91e912d 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -146,6 +146,8 @@ The DAV service has a completely separate database connection from the rest of t | `/pay-tip/[id]` | pay-tip/[id]/+page.svelte | Tip payment page — percentage-based or custom | | `/privacy-policy` | privacy-policy/+page.svelte | Privacy policy page (`?format=pdf` print path) | | `/terms` | terms/+page.svelte | Terms & conditions page | +| `/cancellation-policy` | cancellation-policy/+page.svelte | Booking, deposit & cancellation policy (refund tiers verbatim) | +| `/gift-card-terms` | gift-card-terms/+page.svelte | Gift card terms & conditions (linked from the site footer) | | `/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 | | `/api/[...path]` | api/[...path]/+server.ts | API catch-all proxy (dev) | @@ -287,8 +289,8 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main. | 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+ and 60min at 10+ failures). | -| POST | `/api/verify/generate` | None | — | Generate email verification or password reset code | -| POST | `/api/verify/check` | None | — | Verify code | +| POST | `/api/verify/generate` | None | — | Generate email verification or password reset code. Codes are **hashed** at rest (never stored or logged in plaintext). Delivery: dev/test builds log the code via a `[VERIFY]` stdout-log line (user id and code on separate lines) — a **local DEV ONLY feature**; production builds **fail closed (503)** — stdout-log delivery is dev/test-only and no email/SMS transport exists yet (P6). Frontend reset UI not yet wired. | +| POST | `/api/verify/check` | None | — | Verify code (hashed comparison; `verification_codes` rows are single-purpose and expire) | | GET | `/api/health` | None | — | Health check (DB, S3, Square, frontend status) | | GET | `/api/contact` | None | — | Business contact info (first admin user) | | POST | `/api/users/guest` | None | 10/min | Create disposable guest account | @@ -316,7 +318,7 @@ CORS uses a `FRONTEND_ORIGIN` allowlist, not `*`. `corsAllowedOrigins()` (`main. | PUT | `/api/user/change-password` | Change password | | GET | `/api/user/notification-preferences` | Get notification preferences | | PUT | `/api/user/notification-preferences` | Update notification preferences | -| DELETE | `/api/user/account` | Delete account (GDPR anonymization + external scrubbing) | +| DELETE | `/api/user/account` | Delete account — requires re-auth: `current_password` (bcrypt-compared) plus, when 2FA is enforced and enabled, a fresh one-time `verification_code` (single-use) before GDPR anonymization + external scrubbing | | GET | `/api/user/gdpr-export` | Async GDPR data export (12h cache, background generation) | | GET | `/api/user/loyalty` | Get loyalty stamp count | | GET | `/api/bookings` | List user's bookings | @@ -674,7 +676,7 @@ The admin TTL is 15 minutes for **both** walk-in and call-in (`admin_reserve.go: ### Deposit System -**How it works:** `users.deposits_required` integer (0-3) tracks outstanding deposit obligations. When a user books a service with deposit requirements, the booking gets a `deposit_deadline` (24h before start_time) and `deposit_required = true`. +**How it works:** `users.deposits_required` integer (0-3) tracks outstanding deposit obligations. When a user books a service with deposit requirements, the booking gets a `deposit_deadline` (24h before start_time — the `DepositDeadlineWindow`) and `deposit_required = true`. Deposit-obligated bookings must also be made at least **36 hours** before the start time (`DepositAdvanceWindow` in `handlers/payments/refund_policy.go`), so the customer has time to pay before that deadline. **Deposit Deadline Flow:** @@ -843,7 +845,7 @@ validTransitions := map[string]map[string]bool{ - **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 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). +**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 unconditionally:** the **intended** channel is email/SMS (the method chosen at setup) — **not yet wired (P6)**. Until that transport lands, the code is written to the **local dev stdout log** with a `[2FA]` prefix (user id and code on **separate** lines, so a single record cannot trivially pair them) in **dev/test builds only** — stdout-log delivery is a **local DEV ONLY feature**, never a production channel. **Production builds have no delivery channel at all**, so code issuance is **always refused (503 / `errTwoFADeliveryUnavailable`)** and no user can complete 2FA setup or disable until email/SMS ships — 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). **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. @@ -920,7 +922,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, 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. +**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, the payment-sweep path (`insertCriticalPaymentNotification`, `handlers/payments/sweep.go`), the booking-creation `new_booking`/`pending_booking` inserts, the cancellation and edit-request inserts, the `refund_failed` insert, the deposit-deadline cleanup's `deposit_not_paid_by_deadline` insert, and the 1-week/1-month unpaid-booking notices — 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): @@ -929,13 +931,13 @@ validTransitions := map[string]map[string]bool{ | 1 | `pending_booking` | Approve/Decline (ApprovalModal) | | 2 | `cancelled_booking` | Acknowledge | | 3 | `late_cancellation` | Acknowledge + See User | -| 4 | `no_deposit` | Acknowledge + See User | -| 5 | `deposit_paid` | Acknowledge | -| 6 | `affiliate_claim` | Acknowledge | -| 7 | `edit_requested` | See Booking (BookingModal) | -| 8 | `new_booking` | See Booking (BookingModal) | -| 9 | `1_month_no_pay` | Acknowledge + See User | -| 10 | `1_week_no_pay` | Acknowledge + See User | +| 4 | `deposit_paid` | Acknowledge | +| 5 | `affiliate_claim` | Acknowledge | +| 6 | `edit_requested` | See Booking (BookingModal) | +| 7 | `new_booking` | See Booking (BookingModal) | +| 8 | `1_month_no_pay` | Acknowledge + See User | +| 9 | `1_week_no_pay` | Acknowledge + See User | +| 10 (ELSE) | Everything else — `edit_request`, `rescheduled_booking`, `deposit_not_paid_by_deadline`, `gift_card_purchased_for_friend`, `default_hours_changed`, `refund_failed`, `critical_payment_log`, `refresh_token_reuse` | Acknowledge | **Auto-acknowledge behavior:** Clicking "Approve Booking", "See Booking", or "See User" automatically acknowledges the notification before opening the modal. The standalone "Acknowledge" button is for dismissing without action. @@ -1255,18 +1257,18 @@ Lockout state is stored in `users.failed_attempts` and `users.locked_until` colu - `business_address` — address - `vat_registration_number` — VAT number - `is_vat_registered` — boolean -- `gift_card_expiry_months` — default 12 (configurable, but actual expiry logic uses 24 months) +- `gift_card_expiry_months` — default **24** (`defaultGiftCardExpiryMonths`, `handlers/payments/giftcards.go`), minimum 12 (enforced in `handlers/admin/settings.go`); this is the SINGLE source of truth for the rolling expiry window, read by `payments.GetGiftCardExpiryMonths` and used by the payment handlers' `expiry_date` writes and `CleanupExpiredGiftCards` - `voucher_type` — `SPV` (default); a stored `MPV` is accepted for backward compatibility but overridden to `SPV` at read time **Validation:** - `voucher_type` must be `SPV` or `MPV` (an `MPV` value is accepted but treated as `SPV` — a salon-only gift card is an SPV under HMRC VAT Notice 700/7) -- `gift_card_expiry_months` must be a positive integer +- `gift_card_expiry_months` must be a positive integer, and the admin settings API rejects values below 12 (CMA guidance flags sub-12-month expiry as an unfair contract term; 24 is recommended) - `vat_registration_number` must be a valid UK VAT number: `GB` followed by 9 digits (standard) or 12 digits (branch). Previously allowed up to 20 arbitrary characters. - `business_email` must be 254 characters or fewer - Website URL validated with Go's `url.ParseRequestURI` - Partial update — only provided fields are changed -**Decision:** The `gift_card_expiry_months` field is configurable but the actual expiry logic uses 24 months (hardcoded in `CleanupExpiredGiftCards`). The `gift_card_expiry_months` field exists for future flexibility but is not currently used by the expiry logic. +**Decision:** The `gift_card_expiry_months` field is the single source of truth for the rolling expiry window. The expiry logic (`CleanupExpiredGiftCards`, the gift-card purchase/top-up `expiry_date` writes, and the refund-time re-issue check) all read it through `payments.GetGiftCardExpiryMonths`, falling back to `defaultGiftCardExpiryMonths` = **24** when the settings row is missing or the stored value is < 1. The admin settings API enforces a 12-month floor. --- @@ -1343,7 +1345,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user ### Test Coverage -**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. +**2,554 backend test functions compiled** (under `test,dev` tags — `go test -tags "test,dev" -list 'Test.*'`, 16 Aug 2026) plus **130 frontend vitest cases** (93 plain `it(` calls + 37 `it.each` rows in `square.test.ts`) — as of 16 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 | |---------|--------------| @@ -1369,7 +1371,7 @@ Items that must be closed before a production go-live. This is a living list; ad - **Set `SUPPORT_EMAIL`.** Every consumer-facing legal doc ([[Terms & Conditions - Overall App]], [[Privacy Policy]], [[Gift Card Terms & Conditions]], and the `/terms`, `/privacy-policy`, `/cancellation-policy` routes) currently uses the `{{SUPPORT_EMAIL}}` placeholder for the support address. The real address must be substituted in **all** of those places before launch — a placeholder in a live policy is a consumer-law exposure. - **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. +- **Wire email/SMS (P6) to enable production 2FA delivery.** 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 to the **local dev stdout log** (`[2FA]` prefix) in **dev/test builds only** — a local DEV ONLY feature while email/SMS is implemented (P6) — see the Two-Factor Authentication section in this manual. Production builds have **no delivery channel**: stdout-log delivery is dev/test-only (there is deliberately no production opt-in) and code issuance **fails closed (503)**, so no user can complete 2FA setup or disable until the email/SMS transport is wired. That is the deliberate fail-closed posture until P6 lands — 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`. diff --git a/obsidian/Crussell/Terms & Conditions - Overall App.md b/obsidian/Crussell/Terms & Conditions - Overall App.md index e42d3bd..b964e16 100644 --- a/obsidian/Crussell/Terms & Conditions - Overall App.md +++ b/obsidian/Crussell/Terms & Conditions - Overall App.md @@ -57,13 +57,13 @@ To comply with GDPR storage limitation principles, we delete inactive accounts: - **No balance:** Deleted after 2 years of inactivity. - **With balance:** Deleted after 5 years of inactivity (Scottish prescriptive period). -**Warnings are scheduled before deletion:** +**Pre-deletion warnings are planned but NOT yet implemented:** - 18 months idle (no balance): 6-month warning. - 23 months idle (no balance): 30-day warning. - 4 years idle (with balance): 1-year warning with balance amount. - 59 months idle (with balance): 30-day warning with balance amount. -Warnings include your Account ID for future balance recovery. Email delivery is not yet wired up, so the warnings are scheduled and will be delivered by email once email sending is available. +The warnings are designed to include your Account ID for future balance recovery. They are **scheduled to be sent by email once email sending is available** — email delivery is not wired up yet, so today no warning email is sent before an inactive account is deleted. The deletion thresholds above (2 years no balance / 5 years with balance) are enforced by the `CleanupIdleAccounts` job regardless. --- diff --git a/obsidian/Crussell/Testing Architecture & DB Management.md b/obsidian/Crussell/Testing Architecture & DB Management.md index f05ad31..6709c46 100644 --- a/obsidian/Crussell/Testing Architecture & DB Management.md +++ b/obsidian/Crussell/Testing Architecture & DB Management.md @@ -1,6 +1,6 @@ # Testing Architecture & DB Management -**Last Updated:** August 2026 (v7 — coverage 50.4%→65.0%, 2,555 tests compiled under test,dev tags, plus 129 frontend vitest cases) +**Last Updated:** August 2026 (v7 — coverage 50.4%→65.0%, 2,554 tests compiled under test,dev tags, plus 130 frontend vitest cases) --- @@ -361,7 +361,7 @@ When `t.Parallel()` was first added during migration, it revealed three pre-exis - `handlers/bookings/bookings.go:AdminSearchBookings` — count query before rows loop **Current t.Parallel() coverage:** -Nearly all test files using `SetupTestTx` also use `t.Parallel()`. Exceptions (no `t.Parallel()`): some health check tests, dev-only mocks, and pure unit tests without DB interaction. +Roughly **40% of test functions** call `t.Parallel()` directly (about half of the 90 files that use `SetupTestTx`). Parallelism is the default wherever it is safe — per-test transaction isolation via `SetupTestTx` means no cross-test data contamination — but the deliberate exceptions are large: the payments package's mock-heavy suites (the shared Square mock ledger), the webhook suite (the in-memory dedup cache), and auth's `loginInProgress` map cannot parallelize and omit it on purpose. ### SeedBaseline @@ -502,7 +502,7 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic |--------|-------| | Quick check (`-count=1`) | **~2min** | | 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) | +| Tests | 2,554 compiled under test,dev tags + 130 frontend vitest cases (as of 16 Aug 2026) | New test additions in this batch: | Test | Coverage | @@ -521,13 +521,13 @@ 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,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). +**Total tests:** 2,554 `func Test` compiled under `test,dev` tags across all packages (per `go test -tags "test,dev" -list 'Test.*'`), plus 130 frontend vitest cases (93 plain `it(` + 37 `it.each` rows) — as of 16 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 | Component | Time | |-----------|------| -| `CREATE DATABASE` + migration per package | ~1s × 18 packages = ~4s (parallelized) | +| `CREATE DATABASE` + migration per package | ~1s × 19 packages = ~4s (parallelized) | | Test execution (heaviest: `bookings` ~200 tests) | ~25s | | Test execution (admin ~180 tests) | ~17s | | Test execution (payments ~150 tests) | ~6s | @@ -544,7 +544,7 @@ New test additions in this batch: ### Bottleneck -The `CREATE DATABASE` operation serializes at the PostgreSQL catalog level. With 18+ packages creating databases concurrently, they queue on catalog locks. This adds ~4-8s of overhead. +The `CREATE DATABASE` operation serializes at the PostgreSQL catalog level. With 19+ packages creating databases concurrently, they queue on catalog locks. This adds ~4-8s of overhead. ### How to Make It Even Faster @@ -564,7 +564,7 @@ The `CREATE DATABASE` operation serializes at the PostgreSQL catalog level. With ### Q: Why don't all tests use `t.Parallel()`? -Most test files using `SetupTestTx` now also use `t.Parallel()`. The exceptions that intentionally lack `t.Parallel()`: +About half the test files using `SetupTestTx` also use `t.Parallel()` (roughly 40% of all test functions call it directly). The exceptions that intentionally lack `t.Parallel()`: 1. **Health check tests** (`main_test.go`) — mutate global `db.Conn` directly, incompatible with parallelism 2. **Dev-only Square mocks** (`square_dev_test.go`) — each test creates its own MockClient, already parallel-safe @@ -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,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. +2,554 `func Test` compiled under `test,dev` tags across all packages, plus 130 frontend vitest cases (93 plain `it(` + 37 `it.each` rows in `square.test.ts`), as of 16 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). diff --git a/obsidian/Crussell/User Manual.md b/obsidian/Crussell/User Manual.md index afb11d1..d38b60c 100644 --- a/obsidian/Crussell/User Manual.md +++ b/obsidian/Crussell/User Manual.md @@ -66,7 +66,7 @@ The customer enters: **What to tell the customer:** "If the system says 'Please log in,' that means there's already an account with that email. If it's really you, log in. If you don't have an account, use a different email." -**Deposit check:** If the customer has outstanding deposit obligations from a previous late cancellation, they can only book appointments at least 24 hours away. Guest bookings skip this check. +**Deposit check:** If the customer has outstanding deposit obligations from a previous late cancellation, they can only book appointments at least 36 hours away. Guest bookings skip this check. All bookings must be made at least 1 hour before the appointment start time. @@ -114,7 +114,7 @@ Customers can register at the login page. They need: - **Password**: Up to 72 characters - **Referral code** (optional): 12 characters, formatted as they type (xxxx-xxxx-xxxx). If they enter a valid code, the system links their account to the person who referred them. -After registering, the account starts in an "unverified" state. An email verification system exists on the backend but isn't fully connected to the frontend yet — they can still log in and book. +After registering, the account starts in an "unverified" state. An email verification system exists on the backend but isn't fully connected to the frontend yet — they can still log in and book. Verification codes are hashed (never stored in plaintext) and delivered to the **local dev stdout log** (a `[VERIFY]`-prefixed line) in **dev/test builds only** — a local DEV ONLY convenience while email/SMS delivery is implemented (P6). Production builds have no delivery channel and code issuance **fails closed (503)**. **What to tell the customer:** "After you register, you can log in and book straight away. Email verification isn't fully set up yet, but it won't stop you from using the site." @@ -122,11 +122,11 @@ After registering, the account starts in an "unverified" state. An email verific Customers enter their email and password on the Login page. There's a short delay between login attempts (about 5 seconds) to prevent unauthorised access. -Once logged in, they stay logged in for **30 days**. After that, they need to log in again. The system automatically refreshes their session in the background so they won't get logged out unexpectedly while using the site. +Once logged in, they stay logged in for **90 days**. After that, they need to log in again. The system automatically refreshes their session in the background so they won't get logged out unexpectedly while using the site. ### Forgotten Password -A password reset system exists on the backend, but the "Forgot Password" link hasn't been added to the login page yet. If a customer has forgotten their password, they need to contact the salon directly. +A password reset system exists on the backend, but the "Forgot Password" link hasn't been added to the login page yet. If a customer has forgotten their password, they need to contact the salon directly. Reset codes are hashed and delivered via the same `[VERIFY]` log relay as verification codes (fail-closed in production unless the operator opts in). **What to tell the customer:** "If you've forgotten your password, call us and we'll reset it for you. There's a backend system for this, but the button on the website isn't wired up yet." @@ -402,9 +402,9 @@ If they're logged in, the booking is under their account. If they want to book f ### "Why can't I book today?" -All bookings must be made at least 1 hour before the appointment start time. Also, if they have deposit obligations, they can only book appointments at least 24 hours away. Additionally, after 22:00, the system blocks next-morning slots (00:00–11:00) for non-admin users. +All bookings must be made at least 1 hour before the appointment start time. Also, if they have deposit obligations, they can only book appointments at least 36 hours away. Additionally, after 22:00, the system blocks next-morning slots (00:00–11:00) for non-admin users. -**What to tell them:** "You need to book at least an hour before the appointment. If you owe deposits, you need to book at least 24 hours ahead. Also, late at night, the system stops showing morning slots for the next day." +**What to tell them:** "You need to book at least an hour before the appointment. If you owe deposits, you need to book at least 36 hours ahead. Also, late at night, the system stops showing morning slots for the next day." ### "How do I leave a tip?" @@ -422,6 +422,8 @@ Their slot was held temporarily, but the hold expired before they finished the f Guest data is anonymized 6 months after the appointment. Registered account data is kept until the account is deleted. Full GDPR data export is available via `/gdpr` for registered users. +**Deleting the account** (Account page → GDPR) re-authenticates the customer before anything is erased: they must enter their **current password**, and when 2FA is enforced and enabled on the account, a fresh one-time **2FA code** as well. The session alone can never delete the account. + **What to tell them:** "If you book as a guest, we remove your details 6 months after your appointment. If you have an account, you can request a full copy of your data from us." ### "Can I see my gift card balance?" @@ -450,7 +452,7 @@ For booking confirmation and contact. The phone number is used to send appointme ### "Do I need to verify my email?" -Not yet. The backend supports email verification but the frontend isn't fully wired. They can log in and book without verifying. +Not to use the site. The backend supports email verification and delivers codes via the `[VERIFY]` server-log relay (the operator relays them out-of-band), but the frontend verification UI isn't fully wired, and the salon has no email/SMS transport yet. They can log in and book without verifying. **What to tell them:** "Email verification isn't fully set up yet, but you can log in and book without it." diff --git a/obsidian/Crussell/payments and money processes.md b/obsidian/Crussell/payments and money processes.md index c809d13..f8caeee 100644 --- a/obsidian/Crussell/payments and money processes.md +++ b/obsidian/Crussell/payments and money processes.md @@ -179,7 +179,7 @@ Customers call two different things "deposit", and the system keeps them apart ( ### The deposit ladder: pending_release and deposit_lapsed -When a booking that requires a deposit is created, the customer has until 24 hours before the appointment to pay at least 20% of the total: +When a booking that requires a deposit is created, the customer has until 24 hours before the appointment to pay at least 20% of the total (the `DepositDeadlineWindow`). Deposit-obligated bookings must also be made at least 36 hours in advance (`DepositAdvanceWindow`, `refund_policy.go`) so the customer has time to pay before that deadline: - 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. @@ -999,9 +999,9 @@ The saved-card gate has one job: **require SCA**. In an enforced environment a s ### 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. +Delivery today is the local-dev relay. In **dev/test builds** the plaintext code appears in the **stdout 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 developer (or an operator on a local machine) reads the code from the log and relays it. This stdout-log delivery is a **local DEV ONLY feature** — a convenience while the intended email/SMS channel is implemented (P6) — never a production channel. -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. +Production is stricter, and unconditionally so. A production build has **no delivery channel at all** — email/SMS is not wired yet (P6) and stdout-log delivery is dev/test-only — so code issuance **fails closed (503)**: 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". There is deliberately **no production opt-in** to log delivery — writing plaintext codes to a server log anyone with backend access can read would defeat the account 2FA gate, and the trade is not one to make silently. 2FA setup/disable stays unavailable until the email/SMS transport (P6) ships. ### The 2FA flow for account actions @@ -1045,9 +1045,9 @@ Every money endpoint in the app sits behind two layers of defence. The first lay The app issues two credentials. The access token is a JWT that lives for one hour. The refresh token is opaque: 32 random bytes, presented only to the refresh endpoint, valid for 90 days. Only its SHA-256 hash is stored in the database. Two decisions matter here. Opaque and hashed, because a database leak is then useless: there is nothing in the database that can be spent. Short-lived access, because a stolen access token is only good for an hour and, crucially, cannot renew itself. -### Rotation families and the 60-second grace +### Rotation families and the 20-second grace -Every use of a refresh token consumes it and mints a descendant in the same **rotation family**. A family is a lineage: the original token, then every rotated descendant, all sharing a family id. Two tabs in one browser share a single stored refresh token and can both fire a refresh on load, which looks exactly like a replay. So there is a 60-second grace window in which a replayed token gets the generic error but kills nothing and raises no alert. The window was widened from 30 to 60 seconds because a legitimately rotated token can be replayed by a suspended tab that wakes up late; the extra 30 seconds of freshness for a stolen token is an acceptable trade against killing a real session. +Every use of a refresh token consumes it and mints a descendant in the same **rotation family**. A family is a lineage: the original token, then every rotated descendant, all sharing a family id. Two tabs in one browser share a single stored refresh token and can both fire a refresh on load, which looks exactly like a replay. So there is a **20-second grace window** (`refreshTokenReuseGrace`, `auth/jwt.go`) in which a replayed token gets the generic error but kills nothing and raises no alert. The window was narrowed from 60 to 20 seconds (LOW-2) because the frontend's cross-tab coordination (`REFRESH_LOCK_TTL_MS = 15s`, plus a 20s wait-for-timeout in `frontend/src/lib/stores/auth.svelte.ts`) guarantees only ONE tab performs a rotation and every sibling tab adopts the rotated pair instead of replaying the old token — the only legitimately-arriving replays are same-tick races, sub-second. The old 60s window handed a stolen refresh token up to a full minute of freshness before reuse detection fired; 20s keeps comfortable margin over the cross-tab bound while cutting the undetected-theft window to a third. ### The family kill @@ -1055,7 +1055,7 @@ A replay after the grace window is treated as theft, and the response is total. ### Refresh rotation and reuse detection — flow -Every use of a refresh token consumes it and mints a descendant in the same family; a replay inside the 60-second grace window is tolerated, a replay after it kills the whole lineage. +Every use of a refresh token consumes it and mints a descendant in the same family; a replay inside the 20-second grace window is tolerated, a replay after it kills the whole lineage. ```mermaid sequenceDiagram @@ -1072,7 +1072,7 @@ sequenceDiagram A-->>C: Rotated refresh token C->>A: Refresh again with a stale token A->>A: Time since the token was first used? - alt Within the 60-second grace + alt Within the 20-second grace A-->>C: Generic error - nothing killed, no alert else After the grace - suspected theft A->>D: DELETE the whole family + every bound access token @@ -1246,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 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. +**TWO_FACTOR_ALLOW_LOG_DELIVERY** — **removed.** This variable no longer exists. Stdout-log delivery of 2FA codes is a **local DEV ONLY feature** (dev/test builds always log the code) while the email/SMS transport is implemented (P6). Production builds never log codes and code issuance **fails closed (503)** — no user can complete 2FA setup or disable until email/SMS lands (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 @@ -1312,7 +1312,7 @@ The rule for the operator: **when a customer's bank cannot do SCA, the payment i ### Delivery channel: email/SMS is the intent; the `[2FA]` log is the stopgap -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. +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, codes are delivered to the **local dev stdout log** (`[2FA]` marker) in **dev/test builds only** — a local-development convenience, with the user id and code on separate log lines so a single record cannot trivially pair a code with its owner. **Production builds have no delivery channel at all**: stdout-log delivery is dev/test-only and there is deliberately no production opt-in to it, so code issuance **fails closed (503, `errTwoFADeliveryUnavailable`)** and no user can complete 2FA setup or disable until the email/SMS transport ships. Card charges are unaffected by the delivery channel: they are SCA-only and never require a 2FA code. ### Why it is built this way