# Crussell Nail salon booking platform — Go 1.26.5 backend + SvelteKit 5 SPA + PostgreSQL 17 + Docker. Built for a UK sole-trader nail artist. UK-only (Cloudflare geo-block), UK phone format. All timestamps UTC-normalised — the backend's `clock.Now()` returns UTC, the DB connection uses `timezone = "UTC"`, and the frontend converts between UTC and wall-clock time client-side. Single-employee business. ## Features **Booking**: Self-service (customer), walk-in (admin), call-in (admin). Slot reservations prevent double-booking (per-type TTLs: logged-in users 1 hour, anonymous guests 10 minutes, admin walk-in and call-in 15 minutes, edit requests 24 hours; the `cleanup-reservations` cron job expires them every 5 minutes). **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 27 maintenance jobs: reservation/deposit cleanup every 5min, hourly campaign transitions, daily unpaid-booking notifications, staged default hours auto-apply, GDPR anonymization, financial aggregation, token/code cleanup, and the S3 deletion retry. 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 — 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 till sales record the gift-card value and are marked completed, with no tendered/change fields. Any change or overpayment is handled manually by the admin at the counter. Gift cards (12-hex-character 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 up-to-50% of the booking total (minus anything already deposited) is carved out as deposit (via `buildSplitRecords`); any overflow beyond the booking total becomes a tip. The frontend computes deposit figures from the shared `POLICY` constants (`frontend/src/lib/constants/policy.ts`): `REQUIRED_DEPOSIT_PCT` (0.2) and `PROTECTED_DEPOSIT_MAX_PCT` (0.5), single-sourced with the backend's `refund_policy.go` (`RequiredDepositPct` / `ProtectedDepositMaxPct`) instead of per-file literals. A bounded PostgreSQL advisory try-lock (`pg_try_advisory_lock`, ~30 × 100ms ≈ 3s bound) 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). 12-hex-character codes (48 bits of randomness — never guessable, never logged). 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. 14-day statutory cancellation for online purchases. **Scheduling**: Default weekly hours, holiday/exceptional groups, time blockers (one-off + recurring with cron), **staged default hours changes** (schedule future changes with effective date picker, conflict detection, and auto-apply at midnight). Lunch protection. Late-night lock (22:00–11:00). Admin schedule page (Google Calendar-style week view). **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, 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. **Compliance**: GDPR Article 15 data export (async, 12h cache, 23-section JSON + PDF — excludes verification codes as authentication tokens). Account deletion with external system scrubbing (S3, Square). Guest PII anonymized 6 months post-appointment. UK financial data retention (7 years). Gift card VAT treated as single-purpose vouchers (SPV at purchase; a stored MPV setting is overridden to SPV at read time). **ICO registration is an operator responsibility**: the sole-trader controller must register with the Information Commissioner's Office (ICO) and pay the data-protection fee unless exempt, before processing personal data at scale (see the Pre-Launch checklist in the Technical Manual). **Frontend**: Portfolio gallery with fuzzy tag search (relevance-sorted) and exact category filters (date-sorted), multi-format images (AVIF/WebP/JPEG/JXL with WASM client-side encoding), cursor-based pagination. MapLibre GL map on contact page. PhoneInput component with UK validation. CharCounter for long notes. **Infrastructure**: Docker Compose (postgres, backend, sabredav, nginx). Dev mock for Square payments (`//go:build dev`) that mirrors production PCI-DSS behaviour (rejects raw PANs; accepts `cnon:`/`ccof:` tokens only). RustFS dev storage, Cloudflare R2 for prod. SabreDAV CardDAV sync for profile photos. **Middleware**: `JsonContentType` sets `Content-Type: application/json` globally, replacing ~80+ individual `w.Header().Set()` calls. `RespondJSON`/`RespondError` helpers standardise API response format. Progressive rate limiting (dual-window) on login/register with account lockout (per-account, after 5 failed attempts a 15-minute lockout, escalating to 30 minutes at 7+ failures and 60 minutes at 10+ — an attacker who keeps guessing past each unlock makes the lock LONGER, and the response stays a uniform 401 so locked vs wrong-password is never distinguishable). ## Limitations - **Single employee** — no multi-staff scheduling, no team management - **No email/SMS** — SMTP integration not wired; booking reminders, password resets, notifications, and 2FA code delivery are UI-only/log-delivery (planned upcoming body of work; until email/SMS lands, 2FA codes are delivered to the local dev stdout log (`[2FA]` prefix) in dev/test builds only — production builds have no delivery channel and code issuance fails closed (503); there is deliberately no production opt-in — see the 2FA section above) - **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** (the repeatable lockout DoS — 5 wrong passwords → 15min lock, repeatable — is mitigated by the escalating lock: 15/30/60-minute tiers mean an attacker who keeps guessing makes the lock LONGER, up to an hour; a locked account's recovery remains the backend-only reset flow or an operator clearing the `failed_attempts`/`locked_until` columns) - **No error tracking/monitoring** — Sentry not configured ## Prerequisites | Tool | Version | |------|---------| | Docker & Docker Compose | >= 20.10 | | Go | >= 1.22 | | Node | >= 18 (npm) | | tmux | >= 3.0 | ## 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 # 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. `FRONTEND_ORIGIN` (backend `.env`) is a comma-separated CORS allowlist for the API. `corsAllowedOrigins()` in `backend/main.go` splits on commas, trims, drops blanks, and falls back to `http://localhost:5173` when the var is unset or empty. Matching is exact-match only (`originAllowed()`), never reflected: `Access-Control-Allow-Origin` and `Vary: Origin` are set only when the request `Origin` is in the allowlist. `SQUARE_ALLOW_REAL_API` is a dev-build safety valve: a `//go:build dev` build **HARD-FAILS** (panics) when `SQUARE_ENVIRONMENT=production` unless this is set to `1`, so a typo'd or leftover production value in a dev shell cannot create real charges. Sandbox is allowed in a dev build (with a loud banner). Never set it in a deployed production build. `TRUST_PROXY_HEADERS` (backend `.env`) defaults to `false`. The backend sits behind a trusted proxy in every real deployment — the nginx in `compose.yml` and/or the Cloudflare edge — which overwrites `X-Real-IP` / `CF-Connecting-IP` with the real client IP. Set `TRUST_PROXY_HEADERS=true` for those deployments: without it every per-IP rate-limit key collapses onto the proxy's IP, so any one client can exhaust the shared per-IP budget and throttle the whole surface for everyone (and per-IP limiter protection is effectively bypassed). Keep it `false` only when the backend is origin-exposed. `compose.yml` deliberately never sets it — the operator decides per deployment (the value is passed through the repo-root `.env` via `env_file`). | 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. ### Request snapshot encryption (`SNAPSHOT_ENC_KEY`) The backend replays a byte-identical request to Square when it rescues a stale pending payment, so every charge's exact request body is stored on the pending row. Those snapshots contain the buyer's email and saved-card (`ccof:`) tokens — personal data — so in non-mock (sandbox/production) deployments they are encrypted at rest with AES-256-GCM under `SNAPSHOT_ENC_KEY` (a base64-encoded 32-byte key; generate with `openssl rand -base64 32`). If the key is unset or invalid, the backend logs a one-time CRITICAL warning at startup and falls back to plaintext storage — a warning, not a refusal, because money-safety first: losing a replayable snapshot would strand pending rows forever. The dev mock stores snapshots in plaintext (no real data). ### Two-factor authentication (2FA) 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** (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) ```bash ./local-dev-2.sh ``` Launches 4-pane tmux session: psql console, Go dev server, Svelte dev server, Rustfs logs. Seeds 20 users, 12 services, 43 bookings, guest accounts, time blockers, exceptional hours. Default logins (password: `password`): - Admin: `admin@example.com` - User: `user@example.com` ## Building & Testing ```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,781 backend test functions under the test,dev tags (per `go test -tags "test,dev" -list 'Test.*'`) + 161 frontend vitest cases, as of 20 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 # dedup cache, fixed-ID test rows) that leaks across in-process iterations. # Use -count=1 there; -count=N works for the other packages. ``` ### Pre-commit hooks `.githooks/pre-commit` runs on every commit (configured via `git config core.hooksPath .githooks`): - **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. ### Database schema policy (pre-launch — no ALTERs) The schema is single-source in `init-scripts/init-script.sql`, applied automatically on a **fresh** volume via `docker-entrypoint-initdb.d`. This project is **pre-launch**: there is no production database, and all dev work starts from a fresh DB recreation. Therefore: - **No `ALTER TABLE` / `ALTER TYPE` / `ADD VALUE` statements anywhere** — not in `init-script.sql`, not in tests, not in code. - Any schema change is edited **directly into the `CREATE` statements** in `init-script.sql`. - There is **no migration-managed delta** and no "apply before deploying" step. If a local dev DB needs updating, drop and recreate it (`docker compose down -v && docker compose up --build -d`), or apply the change by hand locally — never commit ALTERs. - Do not document changes as migration snippets; the schema diff on the next recreate is the migration. ## Full Documentation Detailed architecture, schema, admin workflows, user journeys, and backlog in [obsidian/Crussell/](obsidian/Crussell/).