Called AFTER a successful payment in `CreateBookingPayment` handler. Runs campaign checks at payment time:
Called from inside the payment transaction in `CreateBookingPayment` handler (moved from outside — now atomic with payment writes). Runs campaign checks at payment time. Uses the provided `db.Querier` (`q`) instead of managing its own transaction — if the payment commit fails, the discount writes roll back atomically.
**Signature change:**`applyEligibleCampaignsAtPayment` now accepts `db.Querier q` as the second parameter (replacing `context.Context`). The caller passes in their active transaction (or pool). The function no longer calls `Begin`/`Commit` — the caller owns the transaction lifecycle.
```
1. Time-based campaign:
@@ -244,6 +246,8 @@ Source: `backend/handlers/payments/handlers.go` line 684+
### Discount Application at Completion (ProgressBookingHandler)
All discount types execute within `if bookingTotal > 0`, guarded by dedup checks. Each creates:
**Anniversary campaign sorting:** Anniversary campaigns are now sorted by `milestone_value DESC` before application. This ensures that when multiple anniversary milestones are active (e.g., 1-year and 2-year), only the longest (highest milestone_value) is applied. Previously, the first qualifying campaign was used regardless of value — now the `sort.Slice` before the loop guarantees deterministic "longest wins" behaviour.
1. A `booking_discounts` row
2. A `payments` row with `payment_method = 'discount'`, `payment_type = 'partial'`
Full-stack booking platform for a UK sole-trader nail artist. Go 1.25 backend, SvelteKit 5 SPA frontend, PostgreSQL 17, Docker Compose. Europe/London timezone only (Cloudflare geo-blocks non-UK). No timezone conversion — times shown are actual salon times.
Full-stack booking platform for a UK sole-trader nail artist. Go 1.25 backend, SvelteKit 5 SPA frontend, PostgreSQL 17, Docker Compose. UK-only (Cloudflare geo-blocks non-UK). All timestamps UTC-normalised — the backend's `clock.Now()` returns `time.Now().UTC()`, the DB connection uses `timezone = "UTC"`, and the frontend converts between UTC and the browser's local wall-clock time via `formatLocalDateTime()` / `parseWallClockDate()`. No timezone conversion ambiguity — times shown are always UK wall-clock times.
---
@@ -97,7 +97,7 @@ Campaign lifecycle: `draft → active → completed` (or any → `cancelled`, `a
**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()`.
**Shared utilities**: `timeSlots.ts` (lunch protection, slot generation, formatting, UTC↔wall-clock conversion via `formatLocalDateTime`/`parseWallClockDate`/`formatWallClockTime`/`formatWallClockDate`), `format.ts` (duration, date/time, age, ISO date), `phone.ts` (UK phone formatting). All booking time handling now uses `formatLocalDateTime` (instead of `.toISOString()`) for sending times to the backend and `parseWallClockDate` (instead of `new SvelteDate()`) for displaying times.
### Infrastructure
@@ -204,7 +204,7 @@ npm run dev # Dev server with HMR
```bash
cd backend
go test -tags "test,dev" ./... # 981 tests, 0 failures, 4 skipped
go test -tags "test,dev" ./... # ~995 tests, 0 failures, 4 skipped (~1,142 functions defined)
go test -tags "test,dev" -v -run TestName ./... # Single test
```
@@ -213,7 +213,7 @@ Test infrastructure notes:
- **⚠️ Build tag:** Always use `-tags "test,dev"`. The `dev` tag is required by Square mock (`internal/square/square_dev.go`) and rate limiter (`mw/ratelimit_dev.go`). Without it, handlers/payments and handlers/bookings tests are silently skipped.
- **PoolProxy architecture:** `db.Conn` is a `*db.PoolProxy` that routes DB calls through per-test transactions stored in context. Production handlers pass `r.Context()`; tests inject tx context via `req.WithContext(ctx)`.
- **SetupTestTx pattern:** Each test begins a PostgreSQL transaction (`testutils.SetupTestTx(t)`) that automatically rolls back via `t.Cleanup`. No truncation between tests.
- **t.Parallel() supported:** 76% of tests (748/981) use `t.Parallel()` with per-test transaction isolation. 233 remaining in 9 files still use old `SetupTestDB` pattern.
- **t.Parallel() supported:** ~90% of tests use `t.Parallel()` with per-test transaction isolation. New tests (duplicate completion guard, daily stamp cap, invalid transitions, sequential edit, timezone independence, past-booking no-show guard) all use `t.Parallel()`.
- **PreferSimpleProtocol:** Test pools disable prepared statements to prevent "conn busy" errors on parallel transactions.
@@ -6,6 +6,20 @@ Architecture, API reference, database schema, and deep-dive technical reference
## Architecture
### Timezone Architecture (UTC-Normalised)
**How it works:** All timestamps are stored and processed in UTC. The backend uses `clock.Now()` (returns `time.Now().UTC()`) everywhere instead of `time.Now()`. The PostgreSQL connection pool is configured with `timezone = "UTC"`, so all SQL `NOW()` and `CURRENT_TIMESTAMP` calls also return UTC.
**Why this matters:** The previous architecture used `time.Now()` (which returns local time, `Europe/London`) for all Go-side timestamp generation, while PostgreSQL stored `TIMESTAMPTZ` values in UTC. This caused subtle DST bugs — a booking created at "10:00 BST" was stored as "09:00 UTC" in the DB, and closing-hours comparisons would drift by 1 hour when BST→GMT changed.
**The fix:**
1.**`clock.Now()`** — single source of truth for all Go-side current time. Returns `time.Now().UTC()`. Replaces all ~200 direct `time.Now()` calls across all handler packages.
2.**DB timezone** — `PostgreSQL` pool sets `timezone = "UTC"` via `RuntimeParams`, ensuring SQL `NOW()` matches Go's `clock.Now()`.
3.**Frontend** — `formatLocalDateTime()` (converts local `Date` to UTC `+00:00` string) and `parseWallClockDate()` (parses UTC ISO string into local Date for display) handle the client-side conversion. **JavaScript `Date.toISOString()`/`SvelteDate` replaced** — these were sending browser-local time as if it were UTC.
4.**Closing hours comparisons** — The only place `Europe/London` conversion remains is in closing-hours validation (`CreateBookingHandler`, `ReserveSlotHandler`, `AdminReserveSlotHandler`). Closing times stored in DB as `TIME WITHOUT TIME ZONE` (e.g., "20:00") need London timezone context when comparing against the end of a booking. This is done via `localEnd.In(londonLocation)` — a deliberate conversion on the end time only.
**Key insight:** The backend now treats all `time.Time` values as UTC. JSON marshalling uses RFC3339 with `+00:00` or `Z` suffix. The frontend is responsible for converting between UTC and the browser's local timezone (always `Europe/London` since the app is UK-only).
### Docker Compose Stack
| Service | Image | Port | Purpose |
@@ -70,12 +84,16 @@ Backend (:8080)
| `RequireRole(roles...)` | Generic role check |
| `RateLimit(limit, window)` | IP-based rate limiting (supports CF-Connecting-IP header). Dev build tag (`dev`): no-op pass-through. |
| `ProgressiveRateLimit` | Per-IP dual-window rate limiter for bot-spam prevention. Burst: 30 req/5s, Sustained: 120 req/60s. Progressive delays (500ms–10s). Applied to login/register. Skips in dev build. |
| `JsonContentType` | Sets `Content-Type: application/json` on all API responses. Individual handlers that need to override (e.g. binary/image responses) set their own Content-Type header after this middleware runs. Applied globally in `main.go:182`. This replaced ~80+ individual `w.Header().Set("Content-Type", "application/json")` calls across all handlers. |
| `RespondJSON(w, status, data)` | Helper function (in `response.go`, not middleware) for consistent JSON responses. Always sets `Content-Type: application/json`. |
| `RespondError(w, status, message)` | Helper that wraps `RespondJSON` with `{"error": message}`. Replaces `http.Error()` in all new code for consistent JSON error format. |
- **Timezone**: All connections explicitly set `timezone = "UTC"` via `pgxpool.ParseConfig` + `RuntimeParams["timezone"] = "UTC"` (both `db.go` and `db_dev.go`). This ensures PostgreSQL's `NOW()`, `CURRENT_TIMESTAMP`, and all `TIMESTAMPTZ` operations are in UTC, matching `clock.Now()` on the Go side.
- **Test MaxConns**: 16 per pool (down from 60 — reduced during test migration to prevent connection exhaustion)
- **PoolProxy**: `db.Conn` is a `*PoolProxy` that checks `context.Context` for an active transaction via `TxFromContext()`. All `Exec`/`Query`/`QueryRow` calls route through the transaction if present, otherwise delegate to the pool.
@@ -94,7 +112,8 @@ The DAV service has a completely separate database connection from the rest of t
| Package | Purpose |
|---------|---------|
| `internal/validators` | ID validation (12-char hex format) |
| `clock` | `clock.Now()` returns `time.Now().UTC()`. Used everywhere for wall-clock consistency — all scheduling comparisons, timestamp recording, and duration calculations use this function so they're consistent with the database (TIMESTAMPTZ in UTC). Replaces all direct `time.Now()` calls in handlers. |
| `internal/s3` | S3/R2 storage abstraction (build tags: dev vs prod) |
| `internal/square` | Square client interface + dev mock + prod stub (build tags: `dev` vs `!dev`) |
@@ -197,7 +216,13 @@ src/lib/components/
-`formatTime()` — minutes-since-midnight to HH:MM
-`calculateEndTime()` — start + duration
-`getDayWithOrdinal()` — "January 15th"
-`formatLocalDateTime(date)` — converts local Date to UTC `+00:00` string. Replaces `.toISOString()` everywhere for sending times to the backend. The suffix is `+00:00` (not `Z`) because Go's `time.Time` JSON unmarshalling prefers the explicit offset form when constructing wall-clock timestamps that shouldn't be shifted by the backend's timezone.
-`parseWallClockDate(iso)` — parses a UTC ISO string from the backend and returns a Date whose `getHours()/getMinutes()` reflect the local wall-clock time. Replaces `new SvelteDate(iso)` everywhere for displaying booking times.
-`formatWallClockTime(iso)` — formats a UTC ISO string as HH:MM wall-clock time
-`formatWallClockDate(iso)` — formats a UTC ISO string as a readable wall-clock date
-`normalizeTime(time)` — improved padding for edge-case time strings
- Types: `DayHours`, `DayAvailability`
- All debug `console.log()` statements removed from slot generation functions
- **`lib/utils/format.ts`**: Formatting utilities
-`formatDuration(minutes)` — "1h 30m"
@@ -447,8 +472,8 @@ CORS uses `*` in local dev. In production behind Cloudflare, nginx handles CORS.
| `get_vat_return_data(start, end)` | VAT return summary for MTD. **Updated:** Now includes `till_sales` via `UNION ALL` — till sales (gift cards, merchandise, services) are counted alongside booking payments for VAT reporting. |
| `export_sales_transactions(start, end, include_vat)` | Tax-compatible transaction export. **Updated:** Uses dynamic `vat_rate` from the `payments` table (or `business_settings.default_vat_rate`) instead of hardcoded 1.20. |
no_show ──→ (no valid transitions — terminal state)
deposit_lapsed ──→ (no valid transitions — terminal state)
```
**Status Transition Validation (NEW):**`ProgressBookingHandler` now enforces a `validTransitions` map in Go code. Transitions not in the map are rejected with HTTP 400. This prevents accidental status corruption from API calls, race conditions, or manual DB changes:
**Duplicate Completion Guard:** Calling `ProgressBookingHandler` with `status = "completed"` on an already-completed booking is now idempotent. The handler checks `currentStatus == "completed"` before running any stamp-awarding or discount logic, preventing duplicate loyalty stamps, discount payments, or anniversary credit.
**No-Show Guard for Past Bookings:**`DeleteBookingHandler` now wraps the no-show penalty logic with `startTime.After(clock.Now())`. Past bookings that happen to still be "confirmed" (e.g., because the admin never completed them) are no longer retroactively penalised as no-shows when cancelled after the fact.
**Eviction mechanism:** Eviction is handled by the shared `EvictPendingReleaseOverlapping(ctx, tx, startTime, endTime)` function in `bookings.go`, which is called by all 4 handlers that can claim a slot: `CreateBookingHandler`, `ConfirmBookingHandler`, `AdminCreateBookingForUserHandler`, and `AdminRescheduleBookingHandler`. The function uses a single `UPDATE ... RETURNING` query to evict overlapping `pending_release` bookings and return the affected IDs and user IDs. A `NOT EXISTS` subquery on `time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || bookings.id` prevents evicting a booking that a user is currently paying for (the 5-minute payment lock window). Eviction is BAU, so no admin notification is generated — a TODO remains for user notification when that system is built.
**`pending_release` as overlap (NEW):** `AdminCreateBookingForUserHandler` and `AdminReserveSlotHandler` now treat `pending_release` bookings as overlapping — a `pending_release` booking blocks new bookings at the same time, preventing double-booking scenarios where two customers could claim the same slot. The `EvictPendingReleaseOverlapping` eviction strategy was removed from `AdminCreateBookingForUserHandler` — admin-created bookings now reject conflicts with `pending_release` instead of silently evicting them. This matches the `UpdateBookingServicesHandler` and `EditBookingHandler` patterns which also include `pending_release` in their overlap checks.
**24-Hour Late Cancellation Rule (no-show tracking):**
- < 24h without forgiveness → `no_show` status, `deposits_required = 3` (resets, not adds)
- < 24h with forgiveness → `client_cancelled`, no penalty
@@ -686,11 +749,28 @@ pending/confirmed ──(deadline passed)──→ pending_release ──(slot c
**Cancellation flow:**
1. Admin or user calls cancel endpoint
2.`ProcessCancellationRefund` queries booking + payments + start time
2.`ProcessCancellationRefund` queries booking + payments + start time — now uses an **explicit transaction** (`tx` variant) to ensure atomicity. The new `ProcessCancellationRefundTx` accepts an external `pgx.Tx` so the caller (e.g., `AdminCancelBookingHandler`) can share its transaction.
3.`CalculateRefundForCancellation` determines tier and amounts
4. Refund is executed: Square refund for online card payments, user balance credit for cash/gift card/no-Square-ID payments
5. Refund record created in `refunds` table
4. Refund is executed inside the transaction: Square refund for online card payments, user balance credit for cash/gift card/no-Square-ID payments
5. Refund record created in `refunds` table inside the same transaction
6. If a deposit payment was fully refunded, `deposit_paid` is set to false
7. Loyalty stamps are refunded inside the same transaction (10 stamps refunded if a loyalty discount was applied to the cancelled booking)
**Transaction atomicity across the board:** Multiple handlers were refactored to move status checks and idempotency verification inside their transactions:
-`CreateBookingPayment` — status check, idempotency check, payment-type duplicate guard, and discount application (`applyEligibleCampaignsAtPayment`) now all run inside the payment transaction
-`GetCheckoutStatus` — idempotency check moved inside the transaction
-`CreateTerminalPayment` (cash/giftcard) — status and idempotency checks inside the transaction
-`AdminCancelBookingHandler` — refund processing moved inside the cancel transaction (status update + refund are atomic)
-`ClaimExpiredBalance` — now uses a transaction with `FOR UPDATE` row lock
-`RefundPayment` — Square refund + DB record are atomically linked in a transaction
**`applyEligibleCampaignsAtPayment` refactored:** Now accepts `db.Querier` (instead of `context.Context`) so it runs inside the caller's transaction. The function no longer manages its own `Begin`/`Commit` — the caller owns the transaction lifecycle. This ensures discount writes roll back atomically with the payment writes if the payment commit fails.
**`FOR UPDATE` row locking added to:**
-`AdminCancelBookingHandler` — locks the booking row before reading status and updating
-`ProgressBookingHandler` — locks the booking row before reading status and validating transitions
-`ClaimExpiredBalance` — locks the expired balance row to prevent double-claim races
-`AdminCreateBookingForUserHandler` and `AdminReserveSlotHandler` — overlap checks use `SELECT 1 ... FOR UPDATE`
**Split payment dedup:** When split payment records share the same `square_payment_id` (one Square charge split into deposit + balance records), `ProcessCancellationRefund` tracks already-refunded Square IDs in a local map and only refunds each Square payment once. Subsequent records sharing the same ID skip the Square API call and credit the user's balance instead.
@@ -904,6 +984,8 @@ A record is only deleted when **both** applicable conditions are met — the 7-y
| `total_deposits` | `SUM(amount)` WHERE `payment_type = 'deposit'` |
| `total_balances` | `SUM(amount)` WHERE `payment_type = 'balance'` |
| `total_partials` | `SUM(amount)` WHERE `payment_type = 'partial'` |
| `total_vat_amount` | `SUM(p.vat_amount)` — new column, tracks VAT for MTD reporting |
| `total_net_amount` | `SUM(p.net_amount)` — new column, tracks net for MTD reporting |
**Idempotency:** Uses `ON CONFLICT (month) DO UPDATE` with additive upserts (`table.col + EXCLUDED.col`). Running the cleanup twice produces the same result — no double-counting.
@@ -1013,7 +1095,7 @@ A record is only deleted when **both** applicable conditions are met — the 7-y
- **Signing**: HS256 with secret from `JWT_SECRET_KEY` env var
**JTI Revocation:**
Every JWT carries a unique `jti` claim (UUID v4). Revoked JTIs are stored in the `revoked_jtis` PostgreSQL table with an expiry timestamp. `IsJTIRevoked()` is called by `VerifyToken()` on every request. A background cleanup runs every 30 minutes:
Every JWT carries a unique `jti` claim (UUID v4). Revoked JTIs are stored in the `revoked_jtis` PostgreSQL table with an expiry timestamp. `IsJTIRevoked()` is called by `VerifyToken()` on every request. A background cleanup runs every 30 minutes (with panic recovery via `defer recover()`):
```sql
DELETEFROMrevoked_jtisWHEREexpires_at<NOW();
@@ -1021,6 +1103,8 @@ DELETE FROM revoked_jtis WHERE expires_at < NOW();
This replaces the old in-memory map (pre-June 2026 security pass). The DB-backed approach survives server restarts and doesn't leak memory.
**All `time.Now()` replaced with `clock.Now()`** — token expiry, JTI revocation timestamps, login state cleanup, and account lockout checks all use `clock.Now()` to ensure UTC consistency.
**When JTIs are revoked:**
1.`POST /api/logout` — revokes current JTI with 1h expiry
2.`POST /api/refresh-token` — revokes old JTI before issuing new token (rotation)
@@ -1078,6 +1162,9 @@ Lockout state is stored in `users.failed_attempts` and `users.locked_until` colu
**Validation:**
-`voucher_type` must be `SPV` or `MPV`
-`gift_card_expiry_months` must be a positive integer
-`vat_registration_number` must be a valid UK VAT number: `GB` followed by 9 digits (standard) or 12 digits (branch). Previously allowed up to 20 arbitrary characters.
-`business_email` must be 254 characters or fewer
- Website URL validated with Go's `url.ParseRequestURI`
- Partial update — only provided fields are changed
**Decision:** The `gift_card_expiry_months` field is configurable but the actual expiry logic uses 24 months (hardcoded in `CleanupExpiredGiftCards`). The `gift_card_expiry_months` field exists for future flexibility but is not currently used by the expiry logic.
@@ -1155,7 +1242,7 @@ Files with this pattern: `bookings.go` (4 handlers), `custom_services.go`, `user
### Test Coverage
**953/957 tests passing** (8 skipped) across 18 packages.
**~1,142 test functions defined** across all packages (up from 981). Booking integration tests expanded significantly: duplicate completion guard, daily stamp cap (handler + SQL subquery), invalid status transitions, sequential edit, timezone independence, and past-booking no-show guard.
@@ -307,6 +307,26 @@ dbWeekday := int((localStart.Weekday() + 6) % 7) // Go Sunday=0 → DB 6
`SeedBaseline` (called in `TestMain`) seeds working hours as 08:00-20:00, all days open. Individual tests no longer need to call `seedDefaultWorkingHours`.
### Timezone in Tests
Tests no longer use `time.LoadLocation("Europe/London")`. The `nextWeekday()` helper was simplified — it no longer accepts a `*time.Location` parameter:
All test times are generated as UTC (via `time.Now()` or explicit `time.Date(..., time.UTC)`). The `londonLocation` variable is no longer imported in test files. `Time.Date()` calls in tests now use `time.UTC` instead of `londonLocation` or `ukLocation`. This aligns with the backend's UTC-normalised timezone architecture where `clock.Now()` returns `time.Now().UTC()`.
@@ -479,11 +499,21 @@ This appears in `TestAccount_DeleteGuest` and `TestLoyalty_Get`. The `dav.Servic
| Metric | Value |
|--------|-------|
| Quick check (`-count=1`) | **~9s** |
| Thorough (`-count=10`) | **~39s** |
| Strict serial (`-p 1`) | ~90s |
| Quick check (`-count=1`) | **~10s** |
| Thorough (`-count=10`) | **~42s** |
| Strict serial (`-p 1`) | ~95s |
| Packages | 19 tested, 0 failures |
| Tests | 960 run, 4 skipped, 0 failing (981 defined; 21 excluded by build tags in non-dev mode) |
| Tests | ~995 run, 4 skipped, 0 failing (~1,142 defined; ~147 excluded by build tags in non-dev mode) |
New test additions in this batch:
| Test | Coverage |
|------|----------|
| `TestProgressBooking_DuplicateCompletion` | Calls ProgressBookingHandler twice with "completed" — verifies second call is idempotent (no extra stamps) |
| `TestProgressBooking_DailyStampCap` | Completes two bookings for the same user on the same day — verifies only 1 stamp awarded |
| `TestProgressBooking_InvalidTransitions` | Tests `no_show→completed` and `client_cancelled→in_progress` → both rejected with 400 |
| `TestBookings_Edit_SequentialEdit` | Calls EditBookingHandler twice with different start times — verifies both edits take effect (no stale-duration bug) |
| `TestBookings_TimezoneIndependence` | Creates a booking with a UTC time, verifies the stored and retrieved times match exactly with no timezone shift |
| `TestDeleteBooking_PastConfirmed_NoNoShow` | Cancels a past confirmed booking — verifies no retroactive no-show penalty via `startTime.After(clock.Now())` guard |
### What Drives Test Time
@@ -599,7 +629,9 @@ This shouldn't appear anymore — the auth package's TestMain was updated to use
### Q: What's the total test count?
981 test functions defined across all `_test.go` files. `go test -tags "test,dev" -count=1` reports ~960 run + 4 skipped (17 are excluded by build tag combinations — some dev-only tests have `//go:build test && dev` and may not match every tag set). 0 failures across 19 packages.
~1,142 test functions defined across all `_test.go` files. `go test -tags "test,dev" -count=1` reports ~995 run + 4 skipped (~147 are excluded by build tag combinations — some dev-only tests have `//go:build test && dev` and may not match every tag set). 0 failures across 19 packages.
**Notable new tests:** 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`).
### Q: Why use `-count=10` for thorough verification?
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.