Only verified accounts (account_role in 'verified_email','admin') may save cards. CreateBookingPayment, CreateTipPayment and BuyGiftCard now reject save_card=true for guests, unverified accounts and affiliates with 403 BEFORE charge-source resolution, the pending-payment insert, or any Square call — failing closed with zero side effects. Unverified users may still pay; only card persistence is blocked. The dedicated save endpoints are additionally protected by mw.RequireVerified middleware on the routes (main.go). isVerifiedRole / rejectSaveCardForUnverified mirror the existing isAdminRequest defense-in-depth pattern. Tests cover: unverified save-card 403 with no payment row, verified save-card succeeds, and unverified pay-without-save succeeds.
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 (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 25 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 — 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-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 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). 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.
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.
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 SPV/MPV VAT treatment configurable.
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.
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 (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
- 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:
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)
./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
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,934 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)
Pre-commit hooks
.githooks/pre-commit runs on every commit (configured via git config core.hooksPath .githooks):
- Frontend:
eslintall files (runs on every commit);prettier --writeauto-format only whenfrontend/files are staged - Backend (only if
backend/files changed):go vet(withtest,devtags) and ago mod tidydrift check - Global:
gitleakssecret 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 migrations
The schema lives in init-scripts/init-script.sql and is applied automatically on a fresh volume via docker-entrypoint-initdb.d. Existing deployments must apply the payment-system delta manually (the schema is not migration-managed):
-- Refund system columns (refunds table)
ALTER TABLE refunds ADD COLUMN IF NOT EXISTS refund_attempts INT NOT NULL DEFAULT 0;
ALTER TABLE refunds ADD COLUMN IF NOT EXISTS origin VARCHAR(16) NOT NULL DEFAULT 'manual';
ALTER TABLE refunds ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(64) UNIQUE;
-- refund_failed notification reason (admin_notification_reason enum)
-- NOTE: ALTER TYPE ... ADD VALUE cannot run inside a transaction block; run on a connection with autocommit.
ALTER TYPE admin_notification_reason ADD VALUE IF NOT EXISTS 'refund_failed';
-- Refunds may now reference non-booking payments (gift-card purchase refunds)
ALTER TABLE refunds ALTER COLUMN booking_id DROP NOT NULL;
-- Terminal checkout in-flight guard + payment_type passthrough (terminal_checkouts table)
-- NOTE: CREATE TABLE is a fresh addition, not a column change. Apply before deploying
-- the terminal-payment changes or CreateTerminalPayment/GetCheckoutStatus fail at runtime.
CREATE TABLE IF NOT EXISTS terminal_checkouts (
checkout_id VARCHAR(64) PRIMARY KEY,
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
payment_type payment_type NOT NULL DEFAULT 'full',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
amount NUMERIC(10,2) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_terminal_checkouts_booking ON terminal_checkouts(booking_id, status);
CREATE INDEX IF NOT EXISTS idx_terminal_checkouts_status ON terminal_checkouts(status);
-- Square webhook dedup (square_webhook_events table)
-- NOTE: CREATE TABLE is a fresh addition, not a column change. Apply before deploying
-- the webhook handler changes or the dedup INSERT fails at runtime.
-- Required for the Square webhook dedup; without it webhooks fail closed 503.
CREATE TABLE IF NOT EXISTS square_webhook_events (event_id TEXT PRIMARY KEY, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
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)
The user_saved_cards.square_card_id UNIQUE constraint is now scoped per user (UNIQUE (user_id, square_card_id)), so the same physical card saved by two users produces two independent rows instead of user B mutating user A's saved-card row. Existing deployments must swap the constraint (the auto-generated constraint name is user_saved_cards_square_card_id_key):
ALTER TABLE user_saved_cards DROP CONSTRAINT user_saved_cards_square_card_id_key;
ALTER TABLE user_saved_cards ADD CONSTRAINT user_saved_cards_user_id_square_card_id_key UNIQUE (user_id, square_card_id);
square_customer_id TEXT (nullable) was also added to user_saved_cards — populated the first time a user saves a card (Square customer provisioning, P14) and reused thereafter:
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:
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/.