popertots d54f526b56 Fix tip retry amount guard false 400 on non-exact pence values
The pending-retry amount guard compared pence via int64(pounds*100),
which truncates instead of rounding. For non-exact pound values (e.g.
£1.14 stored as the float64 1.1399999999999999) the truncation yields
113 != 114, falsely rejecting a legitimate same-amount retry with 400.
Since the frontend reuses the idempotency key on same-amount retries,
every retry was rejected, permanently stranding the pending record
(and any orphaned Square charge) with no recovery path.

Fix: compare in pence via math.Round — the existing pattern already
used in refunds.go — so non-exact values round to the true pence.

Also applied the same correction to the sibling lossy conversions:
- CreateTipPayment / CreateBookingPayment completed-dedup responses
  (would have reported 113p for a 114p payment)
- BuyGiftCard retry amount guard (latent: £10/£20/£50 are float-exact
  so it never bit, but the identical trap is now closed)

Tests:
- TestTipPayment_RetryPending_NonExactAmountSucceeds: 114p pending
  record + same-amount retry completes and charges 114p (failed on the
  old truncation with 400)
- TestTipPayment_RetryPending_AmountMismatchRejected: a same-key retry
  at a different amount is still rejected with 400 and the pending
  record is left untouched

Verified: full backend suite green (25/25 packages, 0 failures),
-race clean on handlers/payments, go build + go vet clean.
2026-08-22 00:34:49 +01:00
2025-10-31 23:11:33 +00:00

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 21 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 (currently raw-PAN entry in dev only; production nonce integration is backlog item P11 — see obsidian/Crussell/Future Work - Gap Backlog.md). 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).

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:0011: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, 21-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
  • No production S3/R2 — prod storage stubs return "not implemented"
  • 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

cp .env.example .env
# Edit .env — set POSTGRES_*, JWT_SECRET_KEY
docker compose up --build -d
Service URL
Frontend http://localhost
API http://localhost/api
SabreDAV http://localhost/dav

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,716 tests passed (4 skipped, ~13s)
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: 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
  • Global: gitleaks secret scan (skips gracefully if not installed)

To bypass: git commit --no-verify.

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.

Full Documentation

Detailed architecture, schema, admin workflows, user journeys, and backlog in obsidian/Crussell/.

S
Description
Nail salon website with booking system and portfolio
Readme
48 MiB
Languages
Go 64.6%
JavaScript 16.5%
Svelte 14.8%
TypeScript 1.9%
PLpgSQL 1.2%
Other 0.9%