diff --git a/README.md b/README.md index 1591985..ab381a8 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL ## Features -**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 22 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours. +**Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (4 TTL types). **Self-blocking prevention**: `excludeUserID` parameter filters a user's own `RESERVATION` entries from time blocker overlap checks, allowing re-reservation and booking at overlapping slots. **Explicit cancellation**: `DELETE /api/bookings/reserve` releases a user reservation; `DELETE /api/admin/bookings/reserve` releases an admin walk-in/call-in reservation. **Background cleanup**: Centralised cron scheduler (`backend/internal/jobs/`) runs 23 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, and token/code cleanup. Guest accounts with GDPR-compliant anonymization (including `RESERVATION:edit_request:%` scrubbing). Service eligibility based on age + patch test validity. Overlap checks use `FOR UPDATE` row locks inside transactions. Closing-hours validation (`closing_time.go`) resolves both current and staged default hours. -**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — gated off in local dev until `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID` are set). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A PostgreSQL `pg_advisory_lock` serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Two background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), and `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge. +**Payments**: Square Terminal (in-person, via `CreateTerminalCheckout`) + online card payments via saved cards or new cards tokenized through the Square Web Payments SDK (`cnon:` nonces — new-card entry falls back to `CardEntryUnavailable` only when neither mock mode nor Square credentials are configured). The backend accepts only tokens, never raw PANs (PCI-DSS parity, mirrored in the dev mock). Cash with change calculation. Gift cards (12-digit code or account balance). Saved cards for faster checkout. Tips on completed bookings. Refunds with notice-period tiers and deposit protection (72h/24h thresholds). All payment types: deposit, full, partial, balance, tip. Payment >20% of total promotes `pending_release` bookings back to `confirmed`. Deposit paid is computed from payments on-the-fly. The first 50% of each payment is always carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. A PostgreSQL `pg_advisory_lock` serializes payment attempts per-booking to prevent two-tab double-payment races. Gift card purchases insert a pending payment record with VAT before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record (same-key retries reuse it). Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds (with a 23h age guard), `sweep-stale-pending-payments` fails stale pending payments/till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour so a never-polled checkout cannot complete into an invisible, untracked charge. **Gift Cards**: Multi-method purchase (cash, card machine, online card, giveaway). Inventory cards for stock management. 24-month rolling expiry. Idle account cleanup (2yr/5yr thresholds). Expired balance recovery with admin audit trail. Transaction audit log. Idempotency keys for purchases. @@ -29,8 +29,8 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL ## Limitations - **Single employee** — no multi-staff scheduling, no team management -- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, and notifications are UI-only -- **No production S3/R2** — prod storage stubs return "not implemented" +- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, and notifications are UI-only (planned upcoming body of work) +- **No production S3/R2** — prod storage stubs return "not implemented" (planned upcoming body of work) - **No social auth** — OAuth providers (Google, Microsoft, Facebook) not registered - **No dark mode, no PWA, no recurring bookings, no CSV export** - **Password reset flow exists backend-only — no frontend link** @@ -47,18 +47,27 @@ Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL ## Getting Started +Create the two env files first. The backend container reads `.env` at the repo root, and the frontend build reads `frontend/.env`: + ```bash -cp .env.example .env -# Edit .env — set POSTGRES_*, JWT_SECRET_KEY +cp .env.example .env # backend + postgres + Square + S3 credentials +cp frontend/.env.example frontend/.env # frontend VITE_* vars (VITE_SQUARE_ENVIRONMENT=mock) +# Edit .env — set POSTGRES_*, JWT_SECRET_KEY, and any Square credentials for sandbox/production docker compose up --build -d ``` +`VITE_SQUARE_ENVIRONMENT=mock` (default in `frontend/.env`) makes the frontend render its built-in mock card form, pairing with the backend's `SQUARE_ENVIRONMENT=mock` for a token-only local walkthrough. Set it to `sandbox` or `production` only once real Square credentials are configured, never `mock` in a deployed build. + | Service | URL | |---------|-----| | Frontend | http://localhost | | API | http://localhost/api | | SabreDAV | http://localhost/dav | +### Square webhooks (production) + +`SQUARE_WEBHOOK_NOTIFICATION_URL` and `SQUARE_WEBHOOK_SIGNATURE_KEY` in `.env` must exactly match the webhook subscription configured in the Square Dashboard. An unset URL defaults to `http://localhost:8080/webhooks/square`, which is fail-closed (503 without the signing key, 403 on missing/bad signature). If you don't need webhooks, leave both empty — the handler still rejects cleanly. + ### Local dev (tmux) ```bash @@ -76,7 +85,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 ./... # 1,716 tests passed (4 skipped, ~13s) +cd backend && go test -tags "test,dev" -count=1 -parallel 8 ./... # 1,902 tests passed (4 skipped, ~2min) cd backend && go test -tags "test,dev" -count=1 -race -timeout 480s ./... # race detector (all packages, ~4min) cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough verification (~2-3min) ``` @@ -84,12 +93,14 @@ cd backend && go test -tags "test,dev" -count=10 -parallel 8 ./... # thorough v ### Pre-commit hooks `.githooks/pre-commit` runs on every commit (configured via `git config core.hooksPath .githooks`): -- **Frontend**: `prettier --write` auto-format, then `eslint` all files -- **Backend** (only if `backend/` changed): `go vet`, `golangci-lint` (3m timeout), `staticcheck`, `gosec`, `go mod tidy` check +- **Frontend**: `eslint` all files (runs on every commit); `prettier --write` auto-format only when `frontend/` files are staged +- **Backend** (only if `backend/` files changed): `go vet` (with `test,dev` tags) and a `go mod tidy` drift check - **Global**: `gitleaks` secret scan (skips gracefully if not installed) To bypass: `git commit --no-verify`. +The heavier static analyzers (`golangci-lint`, `staticcheck`, `gosec`) are **not** part of the local hook — they run in CI (`.gitea/workflows/ci.yaml`): `golangci-lint` runs once without build tags, `staticcheck` and `gosec` run against both `test,dev` and `test,!dev` build tags, and CI also runs `govulncheck` (dependency vulnerabilities), alongside `go vet`, `go mod tidy`, and the gitleaks scan. + ### CI caching CI caches Go modules (`~/go/pkg/mod`) and npm dependencies (`~/.npm`, `node_modules`) via `actions/cache` — keyed on `go.sum` and `package-lock.json` respectively. Cache is served by Gitea's built-in cache server at `git.popertots.com`. First run downloads everything (~3m35s), subsequent runs restore from cache in seconds. @@ -127,7 +138,7 @@ CREATE INDEX IF NOT EXISTS idx_terminal_checkouts_booking ON terminal_checkouts( CREATE INDEX IF NOT EXISTS idx_terminal_checkouts_status ON terminal_checkouts(status); ``` -The sweep job (`internal/jobs/cleanup.go`) and `refunds.go` cast `'refund_failed'::admin_notification_reason`, so an un-migrated DB fails at runtime — apply these before deploying the payment changes. +`backend/handlers/payments/refunds.go` casts `'refund_failed'::admin_notification_reason` (the sweep job in `internal/jobs/cleanup.go` only registers the handler), so an un-migrated DB fails at runtime — apply these before deploying the payment changes. #### Saved-card per-user uniqueness + Square customer provisioning (P14) @@ -144,6 +155,16 @@ ALTER TABLE user_saved_cards ADD CONSTRAINT user_saved_cards_user_id_square_card ALTER TABLE user_saved_cards ADD COLUMN IF NOT EXISTS square_customer_id TEXT; ``` +#### Saved-card Square references scrubbing (GDPR account anonymization) + +Account anonymization (`anonymize_user`, `delete_guest_user`, `AnonymizeStaleGuestAccounts`) now NULLs `square_card_id` / `square_customer_id` on `user_saved_cards` so external Square references are removed on erasure (Feature Catalog §9.2). This requires `square_card_id` to be nullable — existing deployments must apply: + +```sql +ALTER TABLE user_saved_cards ALTER COLUMN square_card_id DROP NOT NULL; +``` + +Fresh installs get the nullable column from `init-scripts/init-script.sql`; the schema is not migration-managed, so this one-liner is required for existing DBs before deploying the anonymization changes. + ## Full Documentation Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/). diff --git a/obsidian/Crussell/Feature Catalog.md b/obsidian/Crussell/Feature Catalog.md index d5ffc01..e2440a8 100644 --- a/obsidian/Crussell/Feature Catalog.md +++ b/obsidian/Crussell/Feature Catalog.md @@ -163,7 +163,7 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif **Related:** [[Booking System|1. Booking System]] (deposits), [[Gift Cards|4. Gift Cards]] (pay by gift card), [[Admin Dashboard|5. Admin Dashboard]] (till purchases) ### 2.1 Online Card Payment (Square — saved cards or new cards via Web Payments SDK) -**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`), which renders a plain HTML card form and mints the same `cnon:` tokens the backend dev mock accepts — a full as-if-live walkthrough with zero credentials; without credentials or mock mode, new-card entry is gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips. +**What it does:** Customers pay online with a card. Saved-card payments work via Square tokenized card IDs (`ccof:`); new-card payments are tokenized client-side through the Square Web Payments SDK into `cnon:` nonces and accepted by the backend everywhere. Saving a card also provisions a Square customer profile (P14), reused for subsequent saves. The backend rejects raw PANs (PCI-DSS parity, mirrored by the dev mock). Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`), which renders a plain HTML card form and mints the same `cnon:` tokens the backend dev mock accepts — a full as-if-live walkthrough with zero credentials; without credentials or mock mode, new-card entry is gated behind a `CardEntryUnavailable` notice. Used for deposits, full payments, balance payments, and tips. **Layman summary:** "Pay online with your card — just like any online shop." @@ -191,14 +191,14 @@ Multi-method payment system accepting Square (card terminal & online), cash, gif **Related:** [[Gift Cards|4. Gift Cards]], [[VAT Calculation|2.10 VAT Calculation]] ### 2.5 Saved Cards -**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (a Web Payments SDK `cnon:` nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile`. When frontend Square credentials are unset and mock mode is off (local dev), add-card shows the `CardEntryUnavailable` notice; with `VITE_SQUARE_ENVIRONMENT=mock` it uses the frontend mock form instead (saved mock cards appear as `ccof:mock_*` rows in the dev DB). +**What it does:** Customers can save their card details for faster checkout next time. Cards are tokenized via Square (`ccof:` card IDs; the full PAN exists only in Square's vault — our DB stores only the reference + brand/last4/fingerprint). Saving a card also provisions a Square customer profile (P14) — `square_customer_id` is stored on the row and reused for subsequent saves. The dev mock mirrors this (raw PANs rejected). Soft-deleted with 7-year UK retention. The "Add Card" flow posts a `card_token` (a Web Payments SDK `cnon:` nonce) to `CreatePaymentMethodFromToken`, which calls `CreateCardOnFile`. When frontend Square credentials are unset and mock mode is off (local dev), add-card shows the `CardEntryUnavailable` notice; with `VITE_SQUARE_ENVIRONMENT=mock` it uses the frontend mock form instead (saved mock cards appear as `ccof:mock_*` rows in the dev DB). **Layman summary:** "Save your card for next time — one-click payment." **Related:** [[GDPR & Compliance|9. GDPR & Compliance]] (financial data retention), [[Frontend Architecture|15. Frontend Architecture]] (Cards tab) ### 2.6 Tips -**What it does:** Customers can add a tip to a completed booking. Available as percentage presets (10%/15%/20%) or custom amount. Cash tip via "keep change as change" checkbox. +**What it does:** Customers can add a tip to a completed booking. Available as percentage presets (10%/15%/20%) or custom amount. Cash tip via "keep change as tip" checkbox. **Layman summary:** "Add a tip after your appointment — either by card or by leaving the change." @@ -652,14 +652,14 @@ 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, Square saved cards). +**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). **Layman summary:** "Delete your account and we wipe your data — everywhere." **Related:** [[Saved Cards|2.5 Saved Cards]], [[SabreDAV (CardDAV)|12.7 SabreDAV]] (contact deletion) ### 9.3 Guest PII Anonymization -**What it does:** Guest accounts with bookings older than 6 months are automatically anonymized: name → "Guest Anonymized", email → "anon-{id}@anon.invalid", phone zeroed, DOB reset. +**What it does:** Guest accounts with bookings older than 6 months are automatically anonymized: name → "Guest Anonymized", email → "anon-{id}@anon.invalid", phone zeroed, DOB reset. Saved cards are scrubbed too: `square_card_id` / `square_customer_id` NULLed and the cards soft-deleted with 7-year `retained_until` (matching §9.2). **Layman summary:** "Guest details are automatically wiped after 6 months." @@ -758,7 +758,7 @@ A pull-based admin notification queue with priority ordering. Note: email/SMS de **Related:** [[Pending Approvals Queue|7.3 Pending Approvals Queue]] ### 11.4 User Notification Preferences -**What it does:** Users can set their notification preferences (email, SMS, push). **Note:** The table exists but no delivery system is wired — SMTP/SMS integration is not yet implemented. +**What it does:** Users can set their notification preferences (email, SMS, push). **Note:** The table exists but no delivery system is wired — SMTP/SMS integration is not yet implemented — email/SMS delivery is a planned upcoming body of work. **Layman summary:** "You can choose how you want to be notified (but notifications aren't being sent yet)." @@ -842,7 +842,7 @@ The platform infrastructure — Docker Compose stack, CI/CD, local development e ## 13. Background Jobs (Cron Scheduler) -A centralized cron scheduler that runs 22 maintenance jobs for cleanup, transitions, and data management. +A centralized cron scheduler that runs 23 maintenance jobs for cleanup, transitions, and data management. **Related:** [[Availability & Scheduling|3. Scheduling]] (hours apply), [[GDPR & Compliance|9. GDPR & Compliance]] (cleanup), [[Gift Cards|4. Gift Cards]] (expiry/cleanup), [[Payments|2. Payments]] (idempotency cleanup) @@ -853,8 +853,9 @@ A centralized cron scheduler that runs 22 maintenance jobs for cleanup, transiti - **cleanup-gdpr-export-cache**: Expire old GDPR export caches - **sweep-pending-square-refunds**: Reconcile/retry stuck Square refunds (23h age guard; aggregates per charge, single refund per charge, resolves by Square status, notifies on failure) — starts on the `:00` ticks - **sweep-stale-pending-payments**: Fail stale pending payments and till-sales older than Square's ~24h idempotency-key retention, so a late retry is cleanly rejected instead of issuing a second charge — starts on the `:01` ticks, offset one minute from the refund sweep to avoid table contention +- **sweep-stale-terminal-checkouts**: Cancel card-machine (Terminal) checkouts still pending at Square after an hour, so a never-polled checkout cannot complete into an invisible, untracked charge — every 15 minutes -**Related:** [[Slot Reservation TTLs|1.14 Slot Reservation TTLs]], [[Deposit System|1.2 Deposit System]], [[Payments|2. Payments]] (refund sweeps, stale-pending payment sweep) +**Related:** [[Slot Reservation TTLs|1.14 Slot Reservation TTLs]], [[Deposit System|1.2 Deposit System]], [[Payments|2. Payments]] (refund sweeps, stale-pending payment sweep, terminal checkout sweep) ### 13.2 Every Minute - **cleanup-progressive-rate-limiter**: Clean progressive rate limiter state @@ -929,8 +930,10 @@ The SvelteKit 5 static SPA that powers the entire user interface. - `/contact` — Business info + MapLibre map - `/gdpr` — GDPR data export - `/admin/notifications` — Notification queue -- `/book/confirmed/[id]` — Booking confirmation +- `/tip` — Tip payment page (shared TipPayment component) - `/pay-tip/[id]` — Tip payment page +- `/privacy-policy` — Privacy policy page +- `/terms` — Terms & conditions page **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]] @@ -966,8 +969,8 @@ The SvelteKit 5 static SPA that powers the entire user interface. Documented gaps and missing functionality (from codebase audit): - **Single employee** — No multi-staff scheduling -- **No email/SMS** — SMTP not wired; notifications are UI-only -- **No production S3/R2** — Prod storage stubs return "not implemented" +- **Email/SMS pending (planned)** — SMTP not wired yet; notifications are UI-only; email integration is a planned upcoming body of work +- **Production S3/R2 pending (planned)** — prod storage stubs return "not implemented" until the S3 integration body of work lands - **No social auth** — OAuth provider registrations pending - **No dark mode, PWA, recurring bookings, CSV export** - **Password reset** — Backend exists, no frontend link diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 9165490..7e06e08 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -8,7 +8,7 @@ This document has three lists: **Pre-Launch** (integration tasks), **MVP** (miss ### Development Context -Much of the application was built rapidly in development mode. External integrations (Square payments, S3/R2 storage, SMTP email, OAuth, error monitoring) were stubbed out as the business logic evolved — mocks and placeholders that let us move fast without configuring real services. +Much of the application was built rapidly in development mode. External integrations (Square payments, S3/R2 storage, SMTP email, OAuth, error monitoring) were stubbed out as the business logic evolved — mocks and placeholders that let us move fast without configuring real services. Square payments has since been fully implemented; the remaining integrations (S3/R2 storage, SMTP email, OAuth, error monitoring) are the upcoming bodies of work. Those dev placeholders have **not kept pace** with the application's feature growth. As we approach production, each needs: 1. A **production-side implementation** wired alongside the dev mock @@ -26,9 +26,9 @@ These are things that work fine in dev (with mocks) but need real implementation | # | Task | Effort | Area | Dev Status | Notes | |---|---|---|---|---|---| | P2 | **S3/R2 storage: implement prod side of the abstraction** | M (2-3d) | Backend | Dev works (`internal/s3/s3_dev.go` — RustFS + in-memory fallback). Prod side (`internal/s3/s3.go:52-62`) returns "not implemented" for Upload/Download/Delete. The prod `S3Client` struct lacks the `*s3.Client` field entirely — it was never populated. | The storage abstraction was defined early and the dev side got a full implementation. The prod side needs the AWS SDK v2 dependency and real S3/R2 calls. Portfolio images and profile pictures will start working in prod once this is done. | -| P3 | **Square webhook event handling: from log-only to action** | S (1d) | Backend | Webhook signature verification works (HMAC-SHA256, references Square docs). Event parsing works. But `handlePaymentUpdated` and `handleRefundUpdated` (`square.go:88-94`) only log the event data — they never update booking/payment state. | The webhook receiver was built first (parse + verify). The handlers that act on events were deferred. Now they need to: update payment status on `payment.updated`, update refund status on `refund.updated`. | -| P4 | **Payment reconciliation: add recovery for split-brain scenarios** | L (3-5d) | Backend | 20 `log.Printf("CRITICAL: ... manual reconciliation required")` calls exist across payment, refund, and till handlers. When Square succeeds but the DB transaction fails afterwards, state diverges with no automated recovery. | This happens when the application correctly processes a Square payment but then hits a DB error on commit. In dev, this was handled by just logging it. For prod, we need a reconciliation job or retry mechanism. (Count grew from 18 to 20 with the stale-pending sweep's manual-reconciliation warnings in the Aug 2026 review round.) | -| P5 | **Till Purchases: wire the backend payment flow** | M (1d) | Frontend + Backend | Frontend (`TillPurchases.svelte:203-208`) has the UI built but the Charge button is disabled with "Payment flow and backend integration coming soon." The till sale submission path was deferred. | The till UI is fully designed — service selection, gift card types, payment method selection. Only the final "submit payment" path was left as a placeholder. Needs the backend `till.go` sale endpoint wired. | +| P3 | **Square webhook event handling: from log-only to action** | S (1d) | Backend | **Still open.** Webhook signature verification works (HMAC-SHA256) and is **fail-closed** (503 without the signing key, 403 on bad/missing signature), event parsing works, and `event_id` dedup is implemented (duplicate events are skipped). But `handlePaymentUpdated` (`square.go:152`) and `handleRefundUpdated` (`square.go:163`) still only log the event data — they never update booking/payment state. | In the interim, payment/refund state is tracked via the synchronous request paths plus the three background sweeps (`sweep-pending-square-refunds`, `sweep-stale-pending-payments`, `sweep-stale-terminal-checkouts`), which reconcile stuck states without webhook events. The handlers that act on events were deferred: update payment status on `payment.updated`, update refund status on `refund.updated`. | +| P4 | **Payment reconciliation: add recovery for split-brain scenarios** | L (3-5d) | Backend | **Partial progress (Aug 2026).** 20 `log.Printf("CRITICAL: ... manual reconciliation required")` calls exist across payment, refund, and till handlers. When Square succeeds but the DB transaction fails afterwards, state diverges with no automated recovery. The three background sweeps now provide interim recovery for *pending* states (refund sweep retries up to 3 attempts; stale-pending and terminal-checkout sweeps fail/clean stale rows), but a DB-commit failure after a successful Square charge still leaves no automated path to reconcile the orphaned Square-side payment. | This happens when the application correctly processes a Square payment but then hits a DB error on commit. In dev, this was handled by just logging it. For prod, we need a reconciliation job or retry mechanism. (Count grew from 18 to 20 with the stale-pending sweep's manual-reconciliation warnings in the Aug 2026 review round.) | +| P5 | **Till Purchases: wire the backend payment flow** | M (1d) | Frontend + Backend | ✅ **DONE (Aug 2026)** — `backend/handlers/payments/till.go` implements the till-sale endpoint (cash, `card_machine` Terminal checkout + polling, `saved_card`, `online_square` Web Payments SDK nonce, `on_the_house`); `TillPurchases.svelte` wires all payment methods and the Charge button is enabled (gated only for retail-item carts, which cannot be charged yet). | The till UI and backend sale path are fully connected. Only retail-item charging remains deferred (see `TillPurchases.svelte` `canCharge`). | | P6 | **Email/SMS notification delivery** | XL (5-7d) | Backend | `user_notification_preferences` table stores delivery preferences. 8 TODO markers reference this blocker. Notification creation works (admin_notifications table), but no delivery channel exists. No SMTP configuration, no SMS provider. 2 tests skipped as "WIP handler." | The notification queue works (reasons, priorities, acknowledging). What's missing is the delivery backend. Affects: slot eviction alerts, edit request approvals/denials, gift card codes, unpaid booking reminders, idle account warnings. | | P7 | **Production security headers** | S (1h) | Backend | HSTS and Referrer-Policy headers are commented out in `main.go:231-233` with TODO markers. They were left disabled for dev HTTP convenience. | Uncomment and configure for production. | | P8 | **Social auth stubs (Google/Microsoft/Facebook)** | L (2-3d) | Backend + Frontend | `handlers/auth/social.go` is 1 line (`package auth`). Frontend login page has 3 social buttons that show `toast.info("${provider} login coming soon")`. The `user_social_logins` table and `account_type` enum values exist from early schema design. | The schema was designed for social auth from the start (table + enum values). The OAuth flow itself was never implemented. Buttons exist as UI placeholders. | @@ -36,6 +36,7 @@ These are things that work fine in dev (with mocks) but need real implementation | P12 | **Square sandbox smoke test (pre-go-live gate)** | S-M (1d, once credentials available) | E2E | **BLOCKED — no real Square credentials available.** Must exercise the real API path end-to-end: new-card tokenization → payment → saved card → refund → reconcile, against Square's sandbox. Also verifies the M-8 open question (is `card.customer_id` enforced as Required?). | The dev mock cannot exercise Square's real wire contract (key-length limits, `device_options`, refund statuses, error codes). This is the sole remaining item before the production flip. See `plans/p11-square-web-payments-sdk.md` Remaining Items. | | P13 | **Reconcile deterministically-keyed saved-card charges** | S (2-3h) | Backend | **Deferred — deliberate trade-off (N-OBS-1).** The admin "Charge Saved Card" idempotency key `bookingID-sc-type-amount-cardID` dedups two *identical* repeat charges on one booking. Not UI-reachable today (PaymentModal always sends the current `totalDue`, which changes after each charge). | Revisit if the admin flow ever gains a "charge exact amount twice" path — the key would then need a client nonce or attempt counter. Tracked from the final payment review. | | P14 | **Square customer provisioning & consent** | S-M (1-2d) | Backend + Frontend + Docs | **IMPLEMENTED (Aug 2026)** — lazy customer provisioning on card-save, `square_customer_id` persisted + forwarded to Square as `card.customer_id`/CreatePayment `CustomerID`, one-off/guest no-customer, `/privacy-policy` route + consent pop-over, SCA verificationDetails wired across all charge flows. **Remaining:** P12 sandbox verification that Square enforces `customer_id`, and final privacy-policy copy review (route ships DRAFT-bannered). See `plans/p14-square-customer-provisioning-consent.md`. | Closed out of the deep post-implementation review (Aug 2026). | +| P15 | **Accounting integration (Mettle bank feed + FreeAgent bookkeeping export)** | M (2-3d) | Backend | **Planned upcoming body of work.** No code yet. The `square_deposits` schema (backlog T1) was the placeholder for Square batch deposit reconciliation against Mettle. | Mettle bank feed: match Square batch deposits against bank statements. FreeAgent: bookkeeping export (VAT return / P&L data) feeding the existing HMRC MTD SQL functions (see M1/M9). | --- @@ -54,7 +55,7 @@ These are missing functionality that prevents daily operations, legal compliance | M8 | **Business settings management UI** | M (1-2d) | Frontend | `GET/PUT /api/admin/settings` endpoints exist. No admin page — staff use curl or SQL. | | M9 | **CSV/Excel export for bookings/payments** | M (1d) | Backend | No endpoint for accounting software export. SQL functions exist but not wired. | | M10 | **CurrentAppointment action stubs** | M (1d) | Frontend | Extend and Cancel buttons on Today page are dead. Edit/TakePayment/Reschedule are already wired. | -| M11 | **`/terms` and `/privacy` routes don't exist** | S (1h) | Frontend | Login and account pages link to these — both 404. | +| M11 | **`/terms` and `/privacy` routes are draft only stubs** | S (1h) | Frontend | Login and account pages link to these — needs work. | | M12 | **Admin notifications list/acknowledge untested** | S (2-3h) | Backend | 2 tests skipped as WIP (`today_test.go:940,946`). Notification endpoints have zero coverage. | --- @@ -97,7 +98,7 @@ These don't add features but reduce maintenance cost and risk. | T4 | **Create or remove documented `update_data_consent()` function** | S (1h) | DB Schema | Listed in FUNCTION USAGE SUMMARY comment (~line 2401) but no `CREATE FUNCTION` exists. | | T5 | **Resolve 2 route-conflicted lint-ignored handlers** | S (1h) | Backend | `manage.go:27,314` — handlers exist only for tests but routes conflict. | | T6 | **Resolve portfolio lint-ignored handler** | S (1h) | Backend | `images.go:53` — handler referenced from tests only, never routed. | -| T7 | **Fix README job count: 22 not 21** | S (5min) | Docs | ✅ **COMPLETED Aug 2026** — README updated to 22 maintenance jobs (the two payment sweeps added in the review round: `sweep-pending-square-refunds`, `sweep-stale-pending-payments`). | +| T7 | **Fix README job count: 22 not 21** | S (5min) | Docs | ✅ **COMPLETED Aug 2026** — README now documents 23 maintenance jobs (the three payment sweeps: `sweep-pending-square-refunds`, `sweep-stale-pending-payments`, `sweep-stale-terminal-checkouts`). | | T8 | **Audit 18 silent catch blocks** | M (1d) | Frontend | 1 `catch (e) {}`, 17 `catch (_err)` — errors swallowed silently. Many should show user-facing toasts. | | T9 | **33 `svelte/no-navigation-without-resolve` suppressions** | M (1d) | Frontend | Create a project-wide `goto` wrapper instead of suppressing per-file. | | T10 | **Replace `as any` in HolidayHours** | S (30min) | Frontend | `HolidayHours.svelte:234` — `(group.hours as any[])?.map(…)`. Hours array has known shape. | @@ -113,7 +114,7 @@ These don't add features but reduce maintenance cost and risk. - ~~**Square payments: wire prod client alongside dev mock (P1)** — `internal/square/square_http_client.go` implements the real REST client (payments, terminal checkouts, refunds, cards, list-refunds). Prod client (`internal/square/square.go`) and dev `devProdClient` (`square_dev.go`) both call real Square when `SQUARE_ENVIRONMENT=sandbox|production`; `mock` uses the in-memory client. The health endpoint reports `"mock"`/`"ok"` accordingly (was `"not_implemented"`).~~ - ~~**Square Web Payments SDK: re-enable new-card entry with nonce-based flow (P11)** — `SquareCardInput.svelte` tokenizes cards to `cnon:` nonces via the Web Payments SDK (env-gated on `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`); all 8 flows re-enabled (tips ×3, booking payment, deposit, Buy a Gift Card, account Add Card, till `online_square`); `CardEntryUnavailable` kept only as the no-credentials fallback. See `plans/p11-square-web-payments-sdk.md`.~~ - ~~**Square webhook signature verification — enforce always (M6)** — the webhook handler is now **fail-closed**: rejects with 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset and 403 when the signature header is missing/invalid (`handlers/webhooks/square.go`).~~ -- ~~**Fix README job count (T7)** — README updated to 22 maintenance jobs.~~ +- ~~**Fix README job count (T7)** — README updated to 23 maintenance jobs.~~ - ~~**Fix `devProdClient` rune-arithmetic in test (T13)** — `rune('0'+idx)` replaced with `fmt.Sprintf("concurrent-key-%d", idx)`.~~ ## Previously Completed Items (July 2026 backlog) diff --git a/obsidian/Crussell/Overview.md b/obsidian/Crussell/Overview.md index c3e9d31..01832f9 100644 --- a/obsidian/Crussell/Overview.md +++ b/obsidian/Crussell/Overview.md @@ -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 `/api/webhooks/square` receive payment/refund events (HMAC-verified **fail-closed** — 503 without the signing key, 403 on bad signature; currently log-only — status is tracked via the synchronous + sweep/reconcile paths, backlog P3). -Fees column on `payments` stores actual Square deductions. `square_deposits` table for bank reconciliation (matching batch deposits to Mettle account). +Fees column on `payments` stores actual Square deductions. **`square_deposits` (and the `generate_square_deposit_id()` function) are DEAD SCHEMA — zero Go references; they were a placeholder for Square bank reconciliation against Mettle. Keep them unused; backlog item T1 tracks dropping them, and Mettle/FreeAgent integration is a planned upcoming body of work.** ### Gift Cards @@ -53,7 +53,7 @@ Expiry is 24 months from last use (not from purchase). Each use resets the timer Accounts idle 2+ years (no balance) or 5+ years (with balance) are anonymized. Balances before deletion move to `gift_card_expired_balances`. `CleanupIdleAccounts()` runs on availability fetch. -VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) by default — VAT charged at purchase, not redemption. Configurable to Multi-Purpose Voucher (MPV) in business settings. Gift card purchases now insert a pending payment record with VAT applied before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record rather than losing the payment. Two background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds, and `sweep-stale-pending-payments` fails stale pending payments and till-sales so a late retry cannot issue a second charge. +VAT treatment: gift cards are Single-Purpose Vouchers (SPVs) by default — VAT charged at purchase, not redemption. Configurable to Multi-Purpose Voucher (MPV) in business settings. Gift card purchases now insert a pending payment record with VAT applied before calling Square — the DB transaction commits first, so Square failures leave a retryable pending record rather than losing the payment. Three background sweeps close Square's ~24h idempotency-key retention window: `sweep-pending-square-refunds` reconciles/retries stuck refunds, `sweep-stale-pending-payments` fails stale pending payments and till-sales so a late retry cannot issue a second charge, and `sweep-stale-terminal-checkouts` cancels card-machine checkouts still pending at Square after an hour. ### Scheduling @@ -95,9 +95,9 @@ 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]`, `/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`, `/gdpr`, `/admin/notifications`. -**Component library**: PaymentModal (multi-method, service price overrides, tip presets), UserPaymentModal (deposit/partial/full/balance), 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. +**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. **State**: Svelte 5 runes. Auth store wraps JWT in localStorage with auto-refresh (hourly, 1h access token, 90-day refresh token rotation with consumption-based invalidation). Role-based UI via `hasRole()`, `isAdmin()`, `isVerified()`. @@ -123,8 +123,8 @@ Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `a ## Limitations - **Single employee** — no multi-staff scheduling, no team management -- **No email/SMS** — SMTP integration not wired; booking reminders, password resets, and notifications are UI-only (`user_notification_preferences` table exists but unused for delivery) -- **No production S3/R2** — prod storage stubs return "not implemented" errors +- **Email/SMS not yet wired (planned)** — SMTP integration is a planned upcoming body of work; booking reminders, password resets, and notification delivery are currently UI-only (`user_notification_preferences` table exists but unused for delivery) +- **No production S3/R2 yet (planned)** — the prod side of the storage abstraction is a planned upcoming body of work; prod storage stubs currently return "not implemented" - **No social auth** — OAuth provider registrations pending (Google, Microsoft, Facebook) - **No dark mode, no PWA, no recurring bookings, no CSV/Excel export** - **Password reset flow exists backend-only — no frontend link or form** @@ -218,7 +218,7 @@ npm run dev # Dev server with HMR ```bash cd backend -go test -tags "test,dev" ./... # 1,716 tests passed (4 skipped) +go test -tags "test,dev" ./... # 1,902 tests passed (4 skipped) go test -tags "test,dev" -v -run TestName ./... # Single test ``` diff --git a/obsidian/Crussell/Privacy Policy.md b/obsidian/Crussell/Privacy Policy.md index bf11e65..ac4e590 100644 --- a/obsidian/Crussell/Privacy Policy.md +++ b/obsidian/Crussell/Privacy Policy.md @@ -39,7 +39,7 @@ Email: help@crussell.invalid - Gift card codes and balances - Account balances - Payment transaction records (processed via Square, not stored by us) -- Saved-card references (tokenised, stored with our payment provider Square — see §2.3) +- Saved-card references (tokenised, stored with our payment provider Square — see §2.2) - Dormant balance records (Account ID only, no PII) ### 2.2 Saved Cards & Payment Provider (Square) @@ -83,7 +83,7 @@ We collect health-related information with your **explicit consent**: | **Dormant balances** | Indefinite (Account ID only) | Recovery mechanism | | **Marketing preferences** | Until withdrawn | Consent | -### 5.2 Deletion Process +### 3.2 Deletion Process **Account deletion (your request):** 1. You confirm deletion (warning about data loss). @@ -100,6 +100,8 @@ We collect health-related information with your **explicit consent**: 2. If no activity, account deleted as above. 3. Dormant balance recoverable with Account ID. +**Note:** Email, SMS and push notifications, object storage, and accounting integrations are planned upcoming features; this policy will be updated when they launch. + --- ## 4. Your Rights diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index 4466f0d..86670fe 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -49,9 +49,10 @@ Backend (:8080) | Service | Status | Purpose | |---------|--------|---------| | SabreDAV (CardDAV/CalDAV) | Active | Contact sync (profile photos), calendar events | -| S3/R2 | Active (dev) | Portfolio images (AVIF), profile pictures (WebP) | +| S3/R2 | Active (dev); prod side **planned** | Portfolio images (AVIF), profile pictures (WebP) | | Square | **Active** | Payment processing — in-person Terminal (`CreateTerminalCheckout`) + online card payments (saved cards + new cards tokenized via the Square Web Payments SDK `cnon:` nonces; new-card entry is gated only when the frontend Square env vars are unset — see `plans/p11-square-web-payments-sdk.md`). Backend accepts only `cnon:`/`ccof:` tokens (raw PANs rejected). Dev mock (`//go:build dev`) mirrors production PCI-DSS behaviour; prod client (`!dev`) connects to live API. | -| SMTP | Not implemented | Email/SMS notifications — backend not wired | +| SMTP | Planned | Email/SMS notification delivery — upcoming body of work (backend not wired yet) | +| Mettle / FreeAgent | Planned | Accounting integration (bank feed + bookkeeping export) — upcoming body of work | --- @@ -139,8 +140,10 @@ The DAV service has a completely separate database connection from the rest of t | `/admin/schedule` | admin/schedule/+page.svelte | Admin weekly calendar view — Google Calendar-style week grid | | `/contact` | contact/+page.svelte | Dynamic contact info from first admin user + MapLibre GL map | | `/demo` | demo/+page.svelte | Demo mode | -| `/booking-confirmed/[id]` | booking-confirmed/[id]/+page.svelte | Booking confirmation with payment summary, deposit info | +| `/tip` | tip/+page.svelte | Tip payment page (shared TipPayment component) | | `/pay-tip/[id]` | pay-tip/[id]/+page.svelte | Tip payment page — percentage-based or custom | +| `/privacy-policy` | privacy-policy/+page.svelte | Privacy policy page (DRAFT-bannered, `?format=pdf` print path) | +| `/terms` | terms/+page.svelte | Terms & conditions page | | `/gdpr` | gdpr/+page.svelte | GDPR data export — skeleton loading, polling, styled reports, PDF export, JSON download | | `/admin/notifications` | admin/notifications/+page.svelte | Admin notifications with priority sorting, acknowledge flow | | `/api/[...path]` | api/[...path]/+server.ts | API catch-all proxy (dev) | @@ -182,7 +185,13 @@ src/lib/components/ │ └── SelectedTimeSummary.svelte # Selected date/time display ├── payments/ │ ├── PaymentModal.svelte # Admin payment — multi-method: Card (Terminal), Cash (change), Gift Card (12-digit ID), account balance, service price overrides, tip presets -│ └── UserPaymentModal.svelte # User payment — deposit, partial, full, balance (no tip) +│ ├── UserPaymentModal.svelte # User payment — deposit, partial, full, balance (no tip) +│ ├── SquareCardInput.svelte # Square Web Payments SDK card iframe → cnon: nonce tokenization +│ ├── CardSelection.svelte # Saved-card list + "Use a new card" toggle (reusable card picker) +│ ├── TipPayment.svelte # Shared tip payment flow (tip + pay-tip/[id] routes) +│ ├── MockCardForm.svelte # Local mock card form (VITE_SQUARE_ENVIRONMENT=mock) +│ ├── CardBrandIcon.svelte # Brand SVG icons for Square-supported cards +│ └── CardEntryUnavailable.svelte # Fallback notice when no credentials / no mock mode ├── today/ │ ├── CurrentAppointment.svelte # Active appointment display │ ├── PendingApprovals.svelte # Pending bookings + edit requests (dedup refresh) @@ -194,6 +203,7 @@ src/lib/components/ │ └── PortfolioCarousel.svelte # Home page gallery ├── ui/ │ ├── CharCounter.svelte # Grapheme counter for notes (Intl.Segmenter) +│ ├── policyPopover.svelte # Policy pop-over (Open/Download PDF) — cancellation + privacy policies │ └── phone-input/ │ ├── PhoneInput.svelte # UK phone input with inline validation, auto-formatting, digit filtering │ └── index.ts # Re-export @@ -259,7 +269,7 @@ Added in the June 2026 security pass: | Fix | File | Description | |-----|------|-------------| | Removed verification code logging | `handlers/auth/local.go:545` | Deleted `log.Printf("DEBUG: Verification code for %s: %s ...")` — was leaking verification codes to stdout | -| Webhook signature fail-closed | `handlers/webhooks/square.go:34-60` | Webhook verification is fully **fail-closed**: 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset (a misconfigured deployment must not silently accept forged events), 403 when the signature header is missing or invalid. Previously it skipped verification when the key was empty. | +| Webhook signature fail-closed | `handlers/webhooks/square.go:84-102` | Webhook verification is fully **fail-closed**: 503 when `SQUARE_WEBHOOK_SIGNATURE_KEY` is unset (a misconfigured deployment must not silently accept forged events), 403 when the signature header is missing or invalid. Previously it skipped verification when the key was empty. (The `event_id` dedup-set struct lives at `square.go:34-60`.) | | S3 delete error checking | `handlers/portfolio/images.go:975` | Changed `s3.Client.Delete(...)` (ignored return) → `if err := s3.Client.Delete(...); err != nil { log.Printf(...) }` | CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. No CSP violations expected — the SvelteKit SPA doesn't load external scripts or fonts. @@ -323,7 +333,7 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. | POST | `/api/bookings/{id}/tip` | Add tip to completed booking | | GET | `/api/bookings/{id}/payment-summary` | Get payment summary for booking | | GET | `/api/user/payment-methods` | Get saved cards | -| DELETE | `/api/user/payment-methods/{id}` | Soft-delete a saved card | +| DELETE | `/api/user/payment-methods/{id}` | Soft-delete a saved card — first disables the card at Square (`DeleteCardOnFile`, `service.go:526-549`), then local soft-delete | | POST | `/api/user/payment-methods` | Add a saved card | ### Admin Endpoints @@ -384,7 +394,7 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. | POST | `/api/admin/bookings/{id}/payment` | Create Terminal payment | | GET | `/api/admin/payments/{checkout_id}/status` | Poll checkout status | | POST | `/api/admin/payments/{payment_id}/refund` | Refund payment | -| GET | `/api/webhooks/square` | Square webhook endpoint | +| POST | `/api/webhooks/square` | Square webhook endpoint | | POST | `/api/admin/gift-cards/expired-balances` | List expired/dormant balances | | POST | `/api/admin/gift-cards/expired-balances/claim` | Claim an expired balance (409 if already claimed) | | GET | `/api/admin/settings` | Get business settings (name, address, VAT, gift card config) | @@ -449,10 +459,10 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. | `time_blockers` | Admin time blocks + slot reservations (description LIKE 'RESERVATION:%') | | `forgiven_no_shows` | Tracks no-shows forgiven by admin (booking_id, forgiven_by FK to users, created_at). Used by `CountUnforgivenNoShows()` to exclude forgiven records | | `payments` | Payment transactions (VAT fields, invoice_number sequence, fees column for Square deductions, saved_card_id, gift_card_id) | -| `user_saved_cards` | Saved card details (square_card_id, brand, last4, fingerprint, soft delete with retained_until) | +| `user_saved_cards` | Saved card details (square_card_id, square_customer_id TEXT nullable (P14), brand, last4, fingerprint, soft delete with retained_until; UNIQUE (user_id, square_card_id) constraint — per-user card uniqueness) | | `refunds` | Refund records linked to a payment (amount, reason, `square_refund_id`, `refund_attempts` int, `origin` manual|cancellation, `idempotency_key` unique, `created_by` FK to users, ON DELETE SET NULL; `booking_id` is nullable — NULL for non-booking payments such as gift-card purchase refunds) | | `financial_aggregates` | Monthly aggregated financial statistics (no PII) — populated when granular records expire | -| `square_deposits` | Square deposit batch tracking for bank reconciliation (batch_id, total_amount, deposited_at) | +| `square_deposits` | Square deposit batch tracking for bank reconciliation (batch_id, total_amount, deposited_at) — **DEAD SCHEMA: zero Go references; it was a placeholder for Square bank reconciliation against Mettle. Do not rely on it (backlog T1 tracks dropping it, with `generate_square_deposit_id()`); Mettle/FreeAgent integration is a planned upcoming body of work.** | | `affiliate_payouts` | Affiliate commission tracking | | `loyalty_redemptions` | Loyalty stamp redemptions (pending → applied, 6-month expiry, FIFO) | | `discount_campaigns` | Discount campaigns: time-based and milestone (draft → active → completed lifecycle) | @@ -625,10 +635,10 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS. 3. Always create new guest (no guest-to-guest collision check) 4. Set `account_role = 'guest'`, `account_type = 'email'`, `date_of_birth = '1900-01-01'` -**GDPR Anonymization:** `AnonymizeStaleGuestAccounts()` runs on every availability fetch: +**GDPR Anonymization:** `AnonymizeStaleGuestAccounts()` runs daily at 03:00 via the cron scheduler (`cleanup.go:126-131`): - Scrubs PII 6 months after booking's `start_time` - Preserved: `account_role`, `account_type`, `deposits_required`, `id`, `created_at` -- Scrubbed: name → "Guest Anonymized", email → "anon-{id}@anon.invalid", phone → "000000000000", DOB → "1900-01-01", profile_pic_url → NULL, referral_code → NULL, notes → NULL, data_retention_consent → FALSE +- Scrubbed: name → "Guest Anonymized", email → "anon-{id}@anon.invalid", phone → "000000000000", DOB → "1900-01-01", profile_pic_url → NULL, referral_code → NULL, notes → NULL, data_retention_consent → FALSE; saved cards: `square_card_id` → NULL, `square_customer_id` → NULL, soft-deleted with 7-year `retained_until`, `last_4` → 'XXXX', `fingerprint` → NULL - Excludes users with active/pending bookings **Decision:** Guest accounts are always created fresh (no guest-to-guest dedup) because the business model is a UK GDPR-compliant salon where each booking is a discrete interaction. Attempting to link guest bookings across sessions would create a tracking profile, which violates the spirit of disposable accounts. @@ -1269,7 +1279,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user ### Test Coverage -**1,716 tests run** across all packages (4 skipped, 0 failures). Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000. +**1,902 tests run** across all packages (4 skipped, 0 failures). Coverage improved from 50.4% to 65.0% via 56 new test files covering booking handlers, user handlers, payments (giftcards, till, refunds), DAV, auth, middleware, validators, zxcvbn, and scheduling. Key additions: coverage improvement tests (bookings_coverage_test.go, user_coverage_test.go, payments coverage expansion — all meaningful error-path tests, not padding), split-lunch detection tests, savepoint/transaction-context tests for time-sensitive operations, VAT lifecycle and parallel-deadlock regression tests, and cleanup of 10 dead test functions flagged by staticcheck U1000. | Package | Coverage Area | |---------|--------------| @@ -1357,7 +1367,8 @@ sequenceDiagram B-->>F: 202 + checkout_id F-->>A: Show "Waiting for terminal..." T->>S: Customer inserts card - S-->>B: Webhook: payment.completed + B->>S: Poll GetCheckoutStatus + S-->>B: COMPLETED + payment_ids B->>DB: INSERT payment (status=completed) B->>DB: Update booking status B->>DB: Apply discounts (if completed) @@ -1808,8 +1819,9 @@ FROM bookings b LEFT JOIN payments p ON p.booking_id = b.id WHERE b.user_id = $1 |-----------|------|------| | Every min | Progressive rate limiter cleanup | `* * * * *` | | Every 5 min | Reservation cleanup, expired deposits, rate limiter cleanup, GDPR cache cleanup, **refund sweep** (`sweep-pending-square-refunds`), **stale-pending payment/till-sale sweep** (`sweep-stale-pending-payments`, offset +1 min) | `*/5 * * * *` | +| Every 15 min | **Stale terminal checkout sweep** (`sweep-stale-terminal-checkouts`) — cancels card-machine checkouts still pending at Square after an hour | `*/15 * * * *` | | Hourly | Loyalty redemptions, idempotency keys, revoked JTIs, stale login entries, discount campaign auto-transition | `0 * * * *` | -| Daily 7am | Unpaid booking notifications (1-week and 1-month overdue) | `0 7 * * *` | +| Daily 7am / 7:30am | Unpaid booking notifications (1-week overdue at 7am, 1-month overdue at 7:30am) | `0 7 * * *` / `30 7 * * *` | | Daily 2am | Expired verification codes, expired/revoked refresh tokens | `0 2 * * *` | | Daily 3am | Stale guest account anonymization | `0 3 * * *` | | Daily 3:30am | Idle account cleanup | `30 3 * * *` | diff --git a/obsidian/Crussell/Testing Architecture & DB Management.md b/obsidian/Crussell/Testing Architecture & DB Management.md index 1cdf2cd..3ce2381 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:** July 2026 (v5 — coverage 50.4%→65.0%, 1,716 tests) +**Last Updated:** August 2026 (v6 — coverage 50.4%→65.0%, 1,902 tests passed, 4 skipped) --- @@ -499,9 +499,9 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic | Metric | Value | |--------|-------| -| Quick check (`-count=1`) | **~13s** | -| Packages | 19 tested, 0 failures | -| Tests | 1,716 passed, 4 skipped, 0 failing | +| Quick check (`-count=1`) | **~2min** | +| Packages | 25 tested, 0 failures | +| Tests | 1,902 passed, 4 skipped, 0 failing | New test additions in this batch: | Test | Coverage | @@ -520,7 +520,7 @@ New test additions in this batch: | `TestCancelReservation_DoesNotTouchAnonReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:anon:%` (defensive — the WHERE clause only matches `RESERVATION:user:%`) | | `TestCancelReservation_DoesNotTouchAdminReservations` | Inverse-isolation test — user cancel ignores `RESERVATION:admin:%`. Pairs with the admin-side test that verifies admin cancel ignores `RESERVATION:user:%`. Proves the two endpoints are properly partitioned. | -**Total tests:** 1,716 passed across all packages (4 skipped). 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, and removal of 10 dead test functions flagged by staticcheck U1000. +**Total tests:** 1,902 passed across all packages (4 skipped). 0 failures. Growth driven by: coverage improvement pass (new test files for bookings, user, payments, giftcards, till, refunds, DAV, auth, middleware, validators, zxcvbn — 56 new files, coverage 50.4%→65.0%), VAT lifecycle and parallel-deadlock regression tests, savepoint/transaction-context pattern for time-sensitive tests, split-lunch detection tests, removal of 10 dead test functions flagged by staticcheck U1000, and the Square payments test-gap round (terminal CreateCheckout-failure, GetCheckoutStatus reference_id mismatch, deadline wire shape, loyalty lock contention 409, GDPR saved-card scrubbing, ValidateAmount/isTokenLike/lock helpers direct units, buildSplitRecords tip overflow). ### What Drives Test Time @@ -637,7 +637,7 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use ### Q: What's the total test count? -1,716 tests run across all packages (4 skipped). 0 failures. +1,902 tests run across all packages (4 skipped). 0 failures. **Notable new tests:** Centralised job scheduler tests (3 — RegisterAll count, schedules, handler signatures), scheduled-cleanup handler tests (21 — NotifyUnpaidOneWeek/Month, TransitionDiscountCampaigns, CleanupExpiredVerificationCodes/RefreshTokens), GDPR export cache cleanup (4), stale login entry cleanup (4), rate limiter cleanup tests (6), rate limiter production behavior tests (6). Duplicate completion guard (idempotent second `"completed"` call), daily stamp cap (two completions same day → 1 stamp), invalid status transitions (no-show→completed rejected with 400), sequential edit (two edits in sequence), timezone independence (UTC in, UTC out — no shift), past-booking no-show guard (past confirmed booking cancelled → `client_cancelled`, not `no_show`). New closing_time tests (3), content-type middleware tests (2), clock package tests, expanded admin reserve overlap tests, expanded gift card buy flow tests with VAT, and full admin reservation cancel coverage (12 tests covering walkin + callin + isolation + no-op + idempotency + response format parity). diff --git a/obsidian/Crussell/plans/p11-square-web-payments-sdk.md b/obsidian/Crussell/plans/p11-square-web-payments-sdk.md index 3fbb391..df18617 100644 --- a/obsidian/Crussell/plans/p11-square-web-payments-sdk.md +++ b/obsidian/Crussell/plans/p11-square-web-payments-sdk.md @@ -1,6 +1,6 @@ # P11 — Square Web Payments SDK Implementation Plan -**Status:** ✅ COMPLETE — IMPLEMENTED (August 2026). All 8 flows re-enabled; new-card entry tokenized via `cnon:` nonces. Two follow-up items remain intentionally open (see [Remaining Items](#remaining-items--why-deferred) — both require real Square credentials and gate the production flip). +**Status:** ✅ COMPLETE — IMPLEMENTED (August 2026). All 8 flows re-enabled; new-card entry tokenized via `cnon:` nonces. One follow-up item remains intentionally open (see [Remaining Items](#remaining-items--why-deferred) — R1 requires real Square credentials and gates the production flip; R2 was superseded into the P12 sandbox check). **Owner:** Agent implementing P11 (Square Web Payments SDK) **Estimated effort:** 2-3 days (backend groundwork already landed; this is now a frontend-only integration) **Backlog reference:** `Future Work - Gap Backlog.md` item P11 @@ -18,8 +18,8 @@ New-card entry is **tokenized via the Square Web Payments SDK** (`cnon:` nonces) ### Frontend — new-card entry is TOKENIZED (no raw PANs anywhere): All 8 flows now render `SquareCardInput` (`frontend/src/lib/components/payments/SquareCardInput.svelte`), which loads the Square Web Payments SDK (`frontend/src/lib/square/square.ts`, env-gated on `VITE_SQUARE_APPLICATION_ID`/`VITE_SQUARE_LOCATION_ID`) and tokenizes the entered card into a `cnon:xxx` nonce sent as `new_card_token`. The tokenized form is the only card-entry path — there is no raw-PAN fallback. Local dev can opt into the built-in frontend mock (`VITE_SQUARE_ENVIRONMENT=mock`): `SquareCardInput` renders a plain HTML card form (`MockCardForm.svelte`) and `tokenize()` returns the deterministic `cnon:` tokens the backend dev mock (`SQUARE_ENVIRONMENT=mock`) accepts, so all 8 flows run end-to-end with zero credentials. When neither the SDK env vars nor mock mode are configured, flows keep the `CardEntryUnavailable` notice. Note: in mock mode the response's brand/last4 always reflects the token's canonical test card (e.g. `4242 4242 4242 4242` → VISA 4242, `4111 1111 1111 1111` → VISA 1111) — an arbitrarily typed card that is Luhn-valid but not one of the four canonical numbers still maps to `cnon:test-card` (VISA 4242), so its displayed last4 is the canonical one, not the typed digits. Cosmetic and dev-only. -1. `frontend/src/routes/tip/+page.svelte` — tip; saved-card list + `card_id`, SquareCardInput for new card -2. `frontend/src/routes/pay-tip/[id]/+page.svelte` — tip; same pattern +1. `frontend/src/routes/tip/+page.svelte` — tip; saved-card list + `card_id`, SquareCardInput for new card (renders the shared `TipPayment.svelte` component) +2. `frontend/src/routes/pay-tip/[id]/+page.svelte` — tip; same pattern (also renders the shared `TipPayment.svelte` component) 3. `frontend/src/lib/components/account/UserBookingModal.svelte` — tip modal; same pattern 4. `frontend/src/lib/components/payments/UserPaymentModal.svelte` — booking payment; uses `CardSelection` with SquareCardInput 5. `frontend/src/lib/components/booking/BookingFlow.svelte` — deposit; saved-card list + `card_id`, SquareCardInput for new card (incl. guest flow) @@ -35,10 +35,10 @@ The tokenization component. Loads the SDK, attaches the Square card iframe form, ### Backend (already P11-ready — verified): - `backend/internal/square/square_http_client.go` — `createCardOnFileHTTP` accepts a `source_id` token and calls `POST /v2/cards`. Works with `cnon:xxx` nonces. -- `backend/handlers/payments/handlers.go` — `CreateTipPayment`, `CreateBookingPayment`, and `BuyGiftCard` accept `new_card_token` and pass it to `CreateCardOnFile`. Works with nonces. -- `CreatePaymentMethodFromToken` (service.go:504) — account add-card uses `CreateCardOnFile` with the token (raw-PAN path deleted). -- Till `online_square` (till.go:497) — requires `card_token` and calls `CreateCardOnFile`. Works with nonces. -- `CreateCardOnFileRaw` — **deleted** from the `SquareClient` interface (`types.go:158` shows only `CreateCardOnFile`) and all implementations. +- `backend/handlers/payments/handlers.go` — `CreateTipPayment` and `CreateBookingPayment` accept `new_card_token`; only the `save_card=true` branch calls `CreateCardOnFile`, one-off charges use the `cnon:` nonce directly as `source_id`. `BuyGiftCard` lives in `backend/handlers/payments/giftcards.go` (same nonce-direct pattern). Works with nonces. +- `CreatePaymentMethodFromToken` (service.go:585) — account add-card first calls `EnsureSquareCustomer` then `CreateCardOnFile` with the customer id (P14 customer provisioning; raw-PAN path deleted). +- Till `online_square` (till.go:740-756) — charges the `cnon:` nonce **directly** as `source_id` (no `CreateCardOnFile`). Works with nonces. +- `CreateCardOnFileRaw` — **deleted** from the `SquareClient` interface (`types.go:175` shows only `CreateCardOnFile`) and all implementations. --- @@ -127,7 +127,7 @@ All 8 flows render `SquareCardInput` and send the resulting `cnon:xxx` as `new_c | # | Item | Why deferred | |---|---|---| | R1 | **Sandbox smoke test** — new-card tokenization → payment → saved card → refund → reconcile, exercised against a real Square endpoint | **BLOCKED — no real Square credentials available.** The full end-to-end path (Web Payments SDK nonce → `POST /v2/cards` → `POST /v2/payments` → refund → `ListRefunds` reconcile) can only be validated against Square's sandbox. Must run before any production flip. | -| R2 | **M-8 open question: is `card.customer_id` enforced as Required at runtime?** | The app deliberately omits `customer_id` (no Square customer provisioning — linkage uses `reference_id`). Square's API reference documents `customer_id` as Required, but integrations report cards can be created without it. If a sandbox `POST /v2/cards` 400s with `MISSING_REQUIRED_PARAMETER`, the app must provision Square customers before go-live. Gated on the same sandbox credentials as R1. | +| R2 | **M-8 open question: is `card.customer_id` enforced as Required at runtime?** | **Superseded by P14 (Aug 2026).** Customer provisioning now happens on card-**save** only: the app lazily creates a Square customer, persists `square_customer_id`, and sends it as `card.customer_id` on create-card and as `CustomerID` on `ccof:` charges. One-off and guest payments still mint no customer. The remaining open question is now just the P12 sandbox check that Square accepts these fields on the real wire contract (and that `customer_id` is not required for non-`ccof:` one-off charges). | | R3 | **N-OBS-1: saved_card deterministic idempotency key dedups identical repeat charges** | **Deliberate, accepted trade-off** (not a credential blocker). The admin "Charge Saved Card" key `bookingID-sc-type-amount-cardID` means two *identical* charges on one booking dedup to the first. Not UI-reachable today (PaymentModal always sends the current `totalDue`, which changes after a charge), and the double-click protection is worth more than a hypothetical "charge exact amount twice" path. Flagged for revisit in the Gap Backlog if that path ever appears. | --- diff --git a/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md b/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md index 311f10e..8db8f6c 100644 --- a/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md +++ b/obsidian/Crussell/plans/p14-square-customer-provisioning-consent.md @@ -1,6 +1,6 @@ # P14 — Square Customer Provisioning & Consent -**Status:** ✅ PARTIALLY IMPLEMENTED (backend + frontend code landed Aug 2026) — remaining verification gated on P12 (sandbox credentials). +**Status:** ✅ IMPLEMENTED (awaiting P12 sandbox verification + final privacy copy review) — backend + frontend code landed Aug 2026. **Owner:** Implementation agent (payment integration round) **Estimated effort:** S-M (1-2 days backend/frontend + privacy policy copy) **Backlog reference:** `Future Work - Gap Backlog.md` item P14 (added alongside this plan) @@ -11,7 +11,7 @@ ## Executive Summary -The final payment review (Aug 2026) flagged a P0: Square's current API docs mark **`customer_id` as Required** on (a) `POST /v2/cards` (the `card` object) and (b) `CreatePayment` **when `source_id` is a Cards-API card-on-file (`ccof:`)**. The app deliberately sends **no `customer_id`** (no Square customer provisioning — linkage uses `reference_id`; see commit 80bad01 and P11 plan Remaining Item R2 / M-8). +The final payment review (Aug 2026) flagged a P0: Square's current API docs mark **`customer_id` as Required** on (a) `POST /v2/cards` (the `card` object) and (b) `CreatePayment` **when `source_id` is a Cards-API card-on-file (`ccof:`)**. At review time the app deliberately sent **no `customer_id`** (no Square customer provisioning — linkage used `reference_id`; see commit 80bad01 and P11 plan Remaining Item R2 / M-8). **That is now fixed by this plan**: customers are provisioned lazily on card-**save** only, so `customer_id` is sent on create-card/ccof charges while one-off and guest payments still mint no customer. This plan covers **what to do if `customer_id` is enforced**: provision Square customers, persist the id, and — the question this plan answers — **what consent/agreement/checkbox layer is required**. Answer: **no new standalone "Square customer" consent checkbox**; instead three transparency items (privacy policy disclosure, explicit card-save checkbox copy, data-minimisation by not minting customers for one-off payers). @@ -19,8 +19,8 @@ This plan covers **what to do if `customer_id` is enforced**: provision Square c ## Background -- The app creates a Square card-on-file for **every new-card payment** (even `save_card=false`), via `CreateCardOnFile` before the charge (`handlers.go` `CreateBookingPayment`/`CreateTipPayment`; `square_http_client.go` `createCardOnFileHTTP` → `POST /v2/cards`). The card is intentionally left as a Square-side orphan when not saved (retry idempotency via deterministic sha256 key). -- If `POST /v2/cards` enforces `customer_id`, **every new-card payer — including guests — would get a Square customer profile** (name + email from the users/guest record) even when they never opted to save anything. That is the central data-protection problem this plan designs around. +- One-off new-card charges use the `cnon:` nonce **directly** as `source_id` — no card-on-file, no customer — in `CreateBookingPayment`/`CreateTipPayment` (`handlers.go` ~1465-1511 one-off nonce-direct; save branch calls `CreateCardOnFile` at ~1484; tip ~2694-2741), `BuyGiftCard` (`giftcards.go` ~1007-1042), and till `online_square` (`till.go` ~740-756). `CreateCardOnFile` is only called when `save_card=true`. +- Only the **save** path provisions a Square customer: `service.go` `EnsureSquareCustomer` (~594) and `CreatePaymentMethodFromToken` (~585-599); the handler save branch provisions the customer before tokenizing (`handlers.go:1478`). One-off nonce charges mint no customer, so a guest or non-saving payer never gets a Square customer profile. - A Square Customer profile is not a consumer-facing account: it is a merchant-side grouping container holding name, email, and tokenized card references (no PANs — PCI-safe). --- @@ -44,7 +44,7 @@ This plan covers **what to do if `customer_id` is enforced**: provision Square c 2. **Explicit card-save checkbox copy** (`CardSelection.svelte:146`): - Change `Save this card for next time` → `Save this card securely with our payment provider (Square) for next time`. - Ensures informed consent per card-network card-on-file rules and Square's own requirements. Not pre-checked (already the case). -3. **Data minimisation** (design change, see below): do **not** create a Square customer (or card-on-file) for one-off payers or guests who don't save. Charge the `cnon:` nonce directly. +3. **Data minimisation** (design change — now implemented in code, see below): do **not** create a Square customer (or card-on-file) for one-off payers or guests who don't save. Charge the `cnon:` nonce directly. > ⚠️ Legal note: this is a UK card-on-file assessment consistent with ICO guidance and Visa/Mastercard card-on-file rules, not formal legal advice. Sanity-check the privacy policy wording with a professional before go-live. @@ -55,13 +55,13 @@ This plan covers **what to do if `customer_id` is enforced**: provision Square c ### D1 — Provision Square customers ONLY when the user saves a card - Create the Square customer lazily, at the moment `save_card=true`, via Square's `POST /v2/customers` (name + email from the local user record). - Persist the returned `customer_id` on `user_saved_cards` (new column) and reuse it for subsequent card saves by the same user. -- One-off payments (`save_card=false`): **do not create a card-on-file at all** — call `CreatePayment` with the `cnon:` nonce as `source_id` directly (Square supports nonce-as-source without a card or customer). This reverses the current create-card-first pattern for non-save flows. +- One-off payments (`save_card=false`): **do not create a card-on-file at all** — call `CreatePayment` with the `cnon:` nonce as `source_id` directly (Square supports nonce-as-source without a card or customer). This reversed the previous create-card-first pattern for non-save flows, and is implemented. ### D2 — Guests never get a customer profile - Guest bookings have no account and cannot save cards (`canSaveCards` requires `verified_email`, so guests are excluded already). Ensure the backend also refuses to mint a customer for guest users — guest new-card charges must go straight to Square as one-off nonce charges. ### D3 — Retry idempotency without a card-on-file -- The current create-card-first pattern exists so a pending-record retry can re-mint the same card via the deterministic key. For one-off nonce charges, verify the retry path still dedups at Square on the **idempotency key** alone (it should — Square dedups `CreatePayment` by key; a failed first attempt that never charged is replayed safely). If the retry needs a fresh nonce because the old one is consumed, the frontend must re-tokenize on retry (it already caches the nonce per attempt — confirm the cache is cleared when the nonce is marked used). +- One-off nonce charges no longer create a card-on-file; retries dedup at Square on the **CreatePayment idempotency key** alone (Square dedups `CreatePayment` by key; a failed first attempt that never charged is replayed safely). If the retry needs a fresh nonce because the old one is consumed, the frontend must re-tokenize on retry (it already caches the nonce per attempt — confirm the cache is cleared when the nonce is marked used). The P12 sandbox check should verify the retry dedup on a real endpoint. ### D4 — Existing controls stay - Delete-card flow (`DeletePaymentMethod` → Square `POST /v2/cards/{id}/disable`) remains the "view/manage/delete" control card networks require. No change. @@ -113,14 +113,14 @@ In **`CardSelection.svelte`** next to the consent checkbox (`:135-148`), wrap th - `` title "Privacy Policy", `@media print` CSS, `?format=pdf` handling, `Last updated` line. - Content from `obsidian/Crussell/Privacy Policy.md` (incl. the new §2.2 Saved Cards & Square). - Footer link back to `/contact` (match the cancellation page). -- **Terms route** (optional, same pattern): if the Terms placeholder is also made real, add `/terms` + a pop-over near account sign-up. **Out of scope for P14 unless the owner asks** — the consent checkbox only needs the Privacy Policy link. +- **Terms route** (same pattern): a `/terms` route was added after all (`frontend/src/routes/terms/+page.svelte`), despite the original "out of scope for P14" framing — done/in-scope. ### Step 6d — Account page "Policies" block Add a second `PolicyPopover` (Privacy Policy) next to the existing cancellation-policy button in `account/+page.svelte:2356-2378` so users can reach the privacy policy from their account, matching the current presentation. --- -## Implementation Steps (when unblocked by P12) +## Implementation Steps (implemented — P12 sandbox verification remains) ### Backend 1. **Schema**: add `square_customer_id TEXT` to `user_saved_cards` (nullable) + `init-script.sql`; document the manual `ALTER TABLE` for existing deployments (README migration section). @@ -152,10 +152,10 @@ Add a second `PolicyPopover` (Privacy Policy) next to the existing cancellation- |---|---| | Privacy Policy §2.2 content | ✅ Drafted into placeholder (Aug 2026) | | Terms §3.2 cross-ref | ✅ Added to placeholder | -| `policyPopover.svelte` generalisation (`label`/`href` props) | ⏳ In plan (Step 6a) — not started | -| Pop-over on consent checkbox in `CardSelection.svelte` | ⏳ In plan (Step 6b) — not started | -| `/privacy-policy` route (HTML + PDF, mirrors `/cancellation-policy`) | ⏳ In plan (Step 6c) — not started; gated on policy being 'real' (or ships DRAFT-bannered) | -| Account page Policies block second pop-over | ⏳ In plan (Step 6d) — not started | +| `policyPopover.svelte` generalisation (`label`/`href` props) | ✅ **Done** — `frontend/src/lib/components/ui/policyPopover.svelte` now accepts `label`/`href` props defaulting to `/cancellation-policy` + "cancellation policy", so the existing 8 call sites are unchanged | +| Pop-over on consent checkbox in `CardSelection.svelte` | ✅ **Done** — `frontend/src/lib/components/payments/CardSelection.svelte` renders the privacy-policy pop-over next to the card-save consent checkbox (shown when `canSaveCards && squareCardReady`) | +| `/privacy-policy` route (HTML + PDF, mirrors `/cancellation-policy`) | ✅ **Done** — `frontend/src/routes/privacy-policy/+page.svelte` ships DRAFT-bannered (HTML + `?format=pdf` print path) | +| Account page Policies block second pop-over | ✅ **Done** — `frontend/src/routes/account/+page.svelte` Policies block shows a second `PolicyPopover` for the privacy policy | --- @@ -180,5 +180,5 @@ Add a second `PolicyPopover` (Privacy Policy) next to the existing cancellation- ## What this plan explicitly does NOT do - No standalone "I agree to a Square customer account" checkbox. -- **No `/terms` route** (only `/privacy-policy`; a Terms page can reuse the same pattern later if the owner asks). +- **A `/terms` route now exists** (`frontend/src/routes/terms/+page.svelte`) alongside `/privacy-policy`. - No marketing use of Square customer data (out of scope; would need separate PECR/GDPR consent if ever added).