diff --git a/.sisyphus/plans/square-payment-integration.md b/.sisyphus/plans/square-payment-integration.md deleted file mode 100644 index 5387a3e..0000000 --- a/.sisyphus/plans/square-payment-integration.md +++ /dev/null @@ -1,931 +0,0 @@ -# Plan: Square Payment Integration + Mock - -## Context - -Crussell is a UK nail salon booking platform (Go 1.25 backend + SvelteKit 5 frontend + PostgreSQL). The business is a sole-trader operation — one admin, no staff. Currently there is zero payment infrastructure: no Square SDK imported, no payment handlers, no card storage. The "Take Payment" button on the /today page shows a toast saying "Coming soon". The booking flow's Step 4 has a TODO placeholder for payment. - -**What already exists:** -- `payments` table (init-script.sql line 373): `id`, `booking_id`, `payment_type`, `payment_method`, `vendor_code`, `invoice_number`, `status`, `amount`, VAT fields, timestamps, `created_by` -- `payment_type` enum: `'deposit'`, `'full'`, `'tip'`, `'balance'`, `'partial'` -- `payment_method` enum: `'online_square'`, `'in_person_card'`, `'cash'`, `'giftcard'`, `'discount'` -- `payment_status` enum: `'pending'`, `'completed'`, `'failed'`, `'refunded'` -- Deposit tracking: `users.deposits_required INT`, `bookings.deposit_required BOOLEAN`, `bookings.deposit_amount NUMERIC`, `bookings.deposit_deadline TIMESTAMPTZ` — all computed but no actual payment recording -- `user_notification_preferences` table with `email_enabled`, `sms_enabled`, `browser_push_enabled` booleans -- Idempotency key pattern already used for bookings (`idempotency_key VARCHAR(64) UNIQUE`) -- S3 mock pattern: `internal/s3/s3_dev.go` (build tag `dev`) + `internal/s3/s3.go` (build tag `!dev`) — mock connects to local Rustfs, prod connects to Cloudflare R2 - -**What does NOT exist:** -- No `internal/square/` directory -- No payment API handlers -- No payment modal UI -- No card-on-file storage -- No refund handling -- No webhook endpoints - -## User Requirements - -1. **In-person payment (Square Terminal)**: Admin clicks "Payment" on the /today page's CurrentAppointment card → shows a receipt-style modal with service breakdown and total → admin can override the price → click "Confirm" → sends payment request to Square Terminal → customer taps card on physical device → poll for result → store payment against booking → show receipt -2. **Online pre-payment (Web Payments SDK)**: Customer pays online for upcoming booking via BookingFlow Step 4 → enter card details or select saved card → first 1 and last 4 digits stored locally, full card stored with Square → faster checkout on return visits -3. **Deposits**: Same as online pre-payment but `payment_type='deposit'`, amount = booking's `deposit_amount`. On success, `deposit_paid` becomes true. -4. **Refunds**: Admin can refund a payment (full or partial) → Square processes refund → `refunds` table records it → if full deposit refund, reset `deposit_paid` -5. **Affiliate payouts** (TODO): Ledger-only table for tracking affiliate payouts. No Square interaction yet. - -**Additional requirements:** -- Square Terminal should prompt for tip after payment. If terminal doesn't support tips, show QR code linking to `/pay-tip/{booking_id}` for online tip payment. -- Receipt email/SMS after payment (TODO — driven by notification preferences, email system TBD). - -## Design Decisions - -### Mock Pattern (dev/prod split) - -Following the S3 pattern: -- `internal/square/square_dev.go` (`//go:build dev`): Full mock with simulated 1-3s delays, in-memory card storage, async checkout simulation -- `internal/square/square.go` (`//go:build !dev`): Prod stub returning "not implemented" until real Square credentials exist -- `internal/square/types.go` (no build tag): Shared types and interface definition - -**Why no Docker service**: Square is an outbound API (we call them), not an inbound service (they don't call us except webhooks). The mock lives entirely in Go code — no container needed. - -### Terminal Async Flow - -Square Terminal uses `CreateCheckout` → returns a `checkout_id` → customer taps card on device → poll `GetCheckout` for result. The mock simulates this: -1. `CreateCheckout` → returns `{checkout_id: "mock_checkout_", status: "PENDING"}` -2. Frontend polls `GET /api/admin/payments/{checkout_id}/status` every 2s -3. After 3s, mock returns `{status: "COMPLETED", card_details: {last_4: "4242", brand: "Visa"}, tip_amount: 5.00}` -4. Backend creates `payments` record with `payment_method='in_person_card'` - -### Card-on-File Storage - -Square's Web Payments SDK tokenizes cards server-side. We store a reference locally: -- `customer_payment_methods` table: `square_card_id` (Square's token), `brand`, `last_4`, `exp_month`, `exp_year`, `is_default` -- Full card details never touch our servers — Square holds them -- Mock generates fake card tokens (`mock_card_`) with `last_4: "4242"`, `brand: "Visa"` - -### Refund Model - -Separate `refunds` table (not `refunded_amount` on payments) — matches Square's API model and supports partial refunds cleanly: -- Each refund is a row linked to its parent payment -- Multiple partial refunds per payment supported -- `refunds.amount` = amount refunded in this transaction - -### Payment State Machine - -``` -pending → completed → (partially_refunded | fully_refunded) -pending → failed -``` - -- `payments.status` tracks the payment itself -- `refunds.status` tracks individual refunds -- A payment is "fully refunded" when SUM(refunds.amount) >= payments.amount - -### 3DS/SCA (UK Strong Customer Authentication) - -Square's Web Payments SDK handles the 3DS challenge on the frontend. Our backend: -1. Receives payment token from frontend after 3DS passes -2. Calls `square.CreatePayment(token)` — Square confirms 3DS was satisfied -3. Mock: always passes 3DS instantly - -### Idempotency - -Every payment call includes an `idempotency_key` (generated client-side via `crypto.randomUUID()`). Backend checks for existing payment with same key before calling Square — prevents double-charging on retry. - ---- - -## Database Schema Changes - -### 1. `customer_payment_methods` Table - -Stores references to customer's saved cards (Square holds the actual card data). - -```sql -CREATE TABLE customer_payment_methods ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('customer_payment_methods'), - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - square_card_id TEXT NOT NULL, - brand TEXT, - last_4 TEXT, - exp_month INT, - exp_year INT, - is_default BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_customer_payment_methods_user ON customer_payment_methods(user_id); -CREATE INDEX idx_customer_payment_methods_square ON customer_payment_methods(square_card_id); -``` - -**Lifecycle**: -1. Customer enters new card → Square tokenizes → we store reference -2. On next booking, customer sees saved cards → selects one → pays without re-entering details -3. Customer can delete saved card → row removed, Square card archived - -### 2. `refunds` Table - -Tracks individual refund transactions (supports partial refunds). - -```sql -CREATE TABLE refunds ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('refunds'), - payment_id CHAR(12) NOT NULL REFERENCES payments(id), - booking_id CHAR(12) NOT NULL REFERENCES bookings(id), - amount NUMERIC(10,2) NOT NULL, - square_refund_id TEXT, - status payment_status NOT NULL DEFAULT 'pending', - reason TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_refunds_payment ON refunds(payment_id); -CREATE INDEX idx_refunds_booking ON refunds(booking_id); -``` - -**Lifecycle**: -1. Admin initiates refund → `status='pending'` -2. Square processes → `status='completed'` -3. If refund covers full deposit: reset `bookings.deposit_paid = FALSE` - -### 3. `affiliate_payouts` Table (deferred) - -Ledger for affiliate payouts — no Square interaction yet. - -```sql -CREATE TABLE affiliate_payouts ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('affiliate_payouts'), - affiliate_id CHAR(12) REFERENCES users(id), - amount NUMERIC(10,2), - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id); -``` - -### 4. Alter `payments` Table - -```sql -ALTER TABLE payments ADD COLUMN idempotency_key VARCHAR(64) UNIQUE; -ALTER TABLE payments ADD COLUMN square_payment_id TEXT; -ALTER TABLE payments ADD COLUMN card_brand TEXT; -ALTER TABLE payments ADD COLUMN card_last_4 TEXT; -``` - -- `idempotency_key`: Prevents double-charging on retry -- `square_payment_id`: Square's payment ID for reconciliation -- `card_brand` / `card_last_4`: Receipt display, no sensitive data - ---- - -## Backend Implementation - -### Phase 1: Square Mock Infrastructure - -#### 1.1 Shared Types - -**File**: `backend/internal/square/types.go` (no build tag) - -```go -package square - -import "context" - -type CreatePaymentReq struct { - Amount int64 // in cents - Currency string // "GBP" - SourceID string // card token or checkout ID - IdempotencyKey string - ReferenceID string // booking ID - Note string -} - -type CreateCheckoutReq struct { - Amount int64 - Currency string - IdempotencyKey string - ReferenceID string - TipEnabled bool -} - -type RefundPaymentReq struct { - PaymentID string - Amount int64 // in cents (optional — full refund if omitted) - IdempotencyKey string - Reason string -} - -type PaymentResult struct { - ID string - Status string // "COMPLETED", "FAILED", "PENDING" - Amount int64 - CardBrand string - CardLast4 string - TipAmount int64 - ReceiptURL string -} - -type CheckoutResult struct { - ID string - Status string // "PENDING", "COMPLETED", "FAILED" -} - -type CardOnFile struct { - ID string - CardID string // Square's card-on-file token - Brand string - Last4 string - ExpMonth int - ExpYear int - IsDefault bool -} - -type SquareClient interface { - CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) - CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) - GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) - RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) - CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) - GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) - DeleteCardOnFile(ctx context.Context, cardID string) error -} - -type RefundResult struct { - ID string - Status string - Amount int64 -} -``` - -#### 1.2 Dev Mock - -**File**: `backend/internal/square/square_dev.go` (`//go:build dev`) - -```go -//go:build dev - -package square - -import ( - "context" - "fmt" - "sync" - "time" -) - -type DevClient struct { - mu sync.RWMutex - cards map[string][]CardOnFile // userID -> cards - checkouts map[string]*PaymentResult - payments map[string]*PaymentResult - refunds map[string]*RefundResult -} - -func NewDevClient() *DevClient { - return &DevClient{ - cards: make(map[string][]CardOnFile), - checkouts: make(map[string]*PaymentResult), - payments: make(map[string]*PaymentResult), - refunds: make(map[string]*RefundResult), - } -} - -func (c *DevClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) { - fmt.Printf("[SQUARE-MOCK] CreatePayment: amount=%d, idempotency=%s\n", req.Amount, req.IdempotencyKey) - time.Sleep(1 * time.Second) // simulate network - - result := &PaymentResult{ - ID: "mock_pay_" + generateID(), - Status: "COMPLETED", - Amount: req.Amount, - CardBrand: "Visa", - CardLast4: "4242", - TipAmount: 0, - ReceiptURL: "https://mock.square.com/receipt/" + generateID(), - } - c.mu.Lock() - c.payments[result.ID] = result - c.mu.Unlock() - return result, nil -} - -func (c *DevClient) CreateCheckout(ctx context.Context, req CreateCheckoutReq) (*CheckoutResult, error) { - fmt.Printf("[SQUARE-MOCK] CreateCheckout: amount=%d, tip_enabled=%v\n", req.Amount, req.TipEnabled) - checkoutID := "mock_checkout_" + generateID() - - // Schedule completion after 3 seconds - go func() { - time.Sleep(3 * time.Second) - tipAmount := int64(0) - if req.TipEnabled { - tipAmount = 500 // £5.00 mock tip - } - c.mu.Lock() - c.checkouts[checkoutID] = &PaymentResult{ - ID: "mock_pay_" + generateID(), - Status: "COMPLETED", - Amount: req.Amount, - CardBrand: "Visa", - CardLast4: "4242", - TipAmount: tipAmount, - ReceiptURL: "https://mock.square.com/receipt/" + generateID(), - } - c.mu.Unlock() - }() - - return &CheckoutResult{ID: checkoutID, Status: "PENDING"}, nil -} - -func (c *DevClient) GetCheckout(ctx context.Context, checkoutID string) (*PaymentResult, error) { - c.mu.RLock() - defer c.mu.RUnlock() - result, ok := c.checkouts[checkoutID] - if !ok { - return nil, fmt.Errorf("checkout not found: %s", checkoutID) - } - return result, nil -} - -func (c *DevClient) RefundPayment(ctx context.Context, req RefundPaymentReq) (*RefundResult, error) { - fmt.Printf("[SQUARE-MOCK] RefundPayment: payment=%s, amount=%d, reason=%s\n", req.PaymentID, req.Amount, req.Reason) - time.Sleep(1 * time.Second) - - result := &RefundResult{ - ID: "mock_refund_" + generateID(), - Status: "COMPLETED", - Amount: req.Amount, - } - c.mu.Lock() - c.refunds[result.ID] = result - c.mu.Unlock() - return result, nil -} - -func (c *DevClient) CreateCardOnFile(ctx context.Context, userID, cardToken string) (*CardOnFile, error) { - fmt.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s\n", userID) - card := CardOnFile{ - ID: "mock_card_" + generateID(), - CardID: cardToken, - Brand: "Visa", - Last4: "4242", - ExpMonth: 12, - ExpYear: 2030, - IsDefault: false, - } - c.mu.Lock() - c.cards[userID] = append(c.cards[userID], card) - c.mu.Unlock() - return &card, nil -} - -func (c *DevClient) GetCardsOnFile(ctx context.Context, userID string) ([]CardOnFile, error) { - c.mu.RLock() - defer c.mu.RUnlock() - return c.cards[userID], nil -} - -func (c *DevClient) DeleteCardOnFile(ctx context.Context, cardID string) error { - fmt.Printf("[SQUARE-MOCK] DeleteCardOnFile: card=%s\n", cardID) - // Remove from all users - c.mu.Lock() - defer c.mu.Unlock() - for uid, cards := range c.cards { - for i, c := range cards { - if c.ID == cardID { - c.cards[uid] = append(cards[:i], cards[i+1:]...) - return nil - } - } - } - return fmt.Errorf("card not found: %s", cardID) -} - -func generateID() string { - return fmt.Sprintf("%d", time.Now().UnixNano()) -} -``` - -#### 1.3 Prod Stub - -**File**: `backend/internal/square/square.go` (`//go:build !dev`) - -```go -//go:build !dev - -package square - -import ( - "context" - "errors" -) - -type ProdClient struct{} - -func NewProdClient() *ProdClient { return &ProdClient{} } - -func (c *ProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) { - return nil, errors.New("square payments not yet implemented — enable dev build tag for mock") -} -// ... all other methods return same error -``` - -#### 1.4 Initialization - -**File**: `backend/main.go` — add `initSquare()`: - -```go -var squareClient square.SquareClient - -func initSquare() { - squareClient = square.NewDevClient() // dev build tag - fmt.Println("Square client initialized (mock)") -} -``` - ---- - -### Phase 2: Payment Handlers - -**File**: `backend/handlers/payments/payments.go` (new) - -#### 2.1 In-Person Payment (Square Terminal) - -**Endpoint**: `POST /api/admin/bookings/{id}/payment` - -Request body: -```json -{ - "amount": 5000, - "payment_type": "full", - "override_amount": null, - "tip_enabled": true -} -``` - -Flow: -1. Validate booking exists, status = `in_progress` or `completed` -2. If `override_amount` provided, use it instead of booking total -3. Check idempotency: `SELECT 1 FROM payments WHERE idempotency_key = $1` — if exists, return existing payment -4. Call `squareClient.CreateCheckout()` with `TipEnabled: true` -5. Return `{checkout_id, status: "PENDING"}` immediately (async) - -**Endpoint**: `GET /api/admin/payments/{checkout_id}/status` - -Flow: -1. Call `squareClient.GetCheckout(checkoutID)` -2. If status = `COMPLETED`: - - Insert `payments` record: `payment_method='in_person_card'`, `square_payment_id`, `card_brand`, `card_last_4`, `idempotency_key` - - If tip_amount > 0: insert separate `payments` record with `payment_type='tip'` - - Return payment details -3. If status = `PENDING`: return `{status: "PENDING"}` -4. If status = `FAILED`: return error - -#### 2.2 Online Payment (Web Payments SDK) - -**Endpoint**: `POST /api/bookings/{id}/payment` - -Request body: -```json -{ - "amount": 2500, - "payment_type": "deposit", - "card_id": null, - "new_card_token": "cnon:card-nonce-ok", - "save_card": true -} -``` - -Flow: -1. Validate booking belongs to authenticated user -2. If `new_card_token` provided: - - Call `squareClient.CreateCardOnFile(userID, new_card_token)` - - If `save_card`: insert into `customer_payment_methods` -3. Call `squareClient.CreatePayment()` with card token -4. Insert `payments` record: `payment_method='online_square'` -5. If `payment_type='deposit'`: update booking's deposit tracking (already computed by backend) -6. Return payment result - -**Endpoint**: `GET /api/user/payment-methods` - -Returns user's saved cards from `customer_payment_methods`. - -**Endpoint**: `DELETE /api/user/payment-methods/{id}` - -Deletes a saved card. - -#### 2.3 Refunds - -**Endpoint**: `POST /api/admin/payments/{payment_id}/refund` - -Request body: -```json -{ - "amount": 2500, - "reason": "Customer requested refund" -} -``` - -Flow: -1. Lookup payment, verify `status='completed'` -2. Calculate already-refunded amount: `SELECT COALESCE(SUM(amount), 0) FROM refunds WHERE payment_id = $1 AND status = 'completed'` -3. If `requested_amount + already_refunded > payment.amount`: reject (over-refund) -4. Call `squareClient.RefundPayment()` -5. Insert `refunds` record -6. If full refund of a deposit payment: `UPDATE bookings SET deposit_paid = FALSE WHERE id = $1` -7. Return refund result - -#### 2.4 Tip Payment (QR Code Flow) - -**Endpoint**: `POST /api/bookings/{id}/tip` - -Request body: -```json -{ - "amount": 500, - "card_token": "cnon:card-nonce-ok" -} -``` - -Flow: -1. Validate booking exists and has a completed payment -2. Call `squareClient.CreatePayment()` with `payment_type='tip'` -3. Insert `payments` record -4. Return result - ---- - -### Phase 3: Webhook Handler (stub) - -**File**: `backend/handlers/webhooks/square.go` (new) - -**Endpoint**: `POST /api/webhooks/square` - -Flow: -1. Read `x-square-signature` header -2. Dev mode: skip verification -3. Prod mode: verify HMAC signature against request body -4. Parse event type: - - `payment.updated`: update `payments.status` if changed - - `refund.updated`: update `refunds.status` if changed - - `dispute.created`: log warning, update payment status -5. Return 200 - ---- - -### Phase 4: Route Wiring - -**File**: `backend/main.go` - -```go -// User payment routes (authenticated) -r.Group(func(r chi.Router) { - r.Use(middleware.RequireAuth) - r.Post("/bookings/{id}/payment", payments.CreateBookingPayment) - r.Get("/user/payment-methods", payments.GetUserPaymentMethods) - r.Delete("/user/payment-methods/{id}", payments.DeletePaymentMethod) - r.Post("/bookings/{id}/tip", payments.CreateTipPayment) -}) - -// Admin payment routes -r.Group(func(r Router) { - r.Use(middleware.RequireAuth) - r.Use(middleware.RequireAdmin) - r.Post("/admin/bookings/{id}/payment", payments.CreateTerminalPayment) - r.Get("/admin/payments/{checkout_id}/status", payments.GetCheckoutStatus) - r.Post("/admin/payments/{payment_id}/refund", payments.RefundPayment) -}) - -// Webhooks (no auth) -r.Post("/webhooks/square", webhooks.HandleSquareWebhook) -``` - ---- - -## Frontend Implementation - -### Phase 1: PaymentModal Component - -**File**: `frontend/src/lib/components/payments/PaymentModal.svelte` (new) - -Props: -- `booking: Booking` -- `onClose: () => void` -- `onPaymentComplete: (payment: Payment) => void` - -UI: -- Header: "Take Payment" -- Service breakdown table (services, prices, duration) -- Total amount display -- Price override input (admin can adjust) -- "Confirm Payment" button -- On click: calls `POST /api/admin/bookings/{id}/payment` → polls `GET /api/admin/payments/{checkout_id}/status` every 2s -- Polling UI: spinner with "Waiting for customer to tap card..." -- On success: receipt display with card brand, last 4, amount, tip -- On failure: error message + retry button - -### Phase 2: Wire CurrentAppointment Payment Button - -**File**: `frontend/src/lib/components/today/CurrentAppointment.svelte` - -Replace: -```typescript -function handleTakePayment() { - toast.info('Take payment - Coming soon'); -} -``` - -With: -```typescript -let showPaymentModal = $state(false); - -function handleTakePayment() { - showPaymentModal = true; -} -``` - -Add `` at bottom of component, conditionally rendered. - -### Phase 3: BookingFlow Step 4 — Payment UI - -**File**: `frontend/src/lib/components/booking/BookingFlow.svelte` (line ~1242) - -Replace TODO comment with: -- "Payment" step with two options: "Pay deposit now" or "Pay at appointment" -- If "Pay deposit now": - - Show saved cards (fetched from `GET /api/user/payment-methods`) - - "Add new card" form (Square Web Payments SDK card element) - - On submit: calls `POST /api/bookings/{id}/payment` with `payment_type='deposit'` - - On success: proceed to confirmation -- If "Pay at appointment": skip payment, proceed to confirmation - -### Phase 4: Tip Payment Page (QR Code) - -**File**: `frontend/src/routes/pay-tip/[id]/+page.svelte` (new) - -- Public page (no auth required) -- Shows booking details and "Leave a tip" form -- Card input (Square Web Payments SDK) -- On submit: calls `POST /api/bookings/{id}/tip` -- On success: thank you message - -### Phase 5: EditBookingModal — Refund Button - -**File**: `frontend/src/lib/components/admin/EditBookingModal.svelte` - -- In the payments section, add "Refund" button next to each completed payment -- Clicking opens a refund dialog: amount input (defaults to full), reason text -- On submit: calls `POST /api/admin/payments/{id}/refund` -- On success: refreshes payment list, shows toast - ---- - -## Migration SQL - -```sql --- 1. Customer payment methods -CREATE TABLE customer_payment_methods ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('customer_payment_methods'), - user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - square_card_id TEXT NOT NULL, - brand TEXT, - last_4 TEXT, - exp_month INT, - exp_year INT, - is_default BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_customer_payment_methods_user ON customer_payment_methods(user_id); -CREATE INDEX idx_customer_payment_methods_square ON customer_payment_methods(square_card_id); - --- 2. Refunds -CREATE TABLE refunds ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('refunds'), - payment_id CHAR(12) NOT NULL REFERENCES payments(id), - booking_id CHAR(12) NOT NULL REFERENCES bookings(id), - amount NUMERIC(10,2) NOT NULL, - square_refund_id TEXT, - status payment_status NOT NULL DEFAULT 'pending', - reason TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_refunds_payment ON refunds(payment_id); -CREATE INDEX idx_refunds_booking ON refunds(booking_id); - --- 3. Affiliate payouts (deferred) -CREATE TABLE affiliate_payouts ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('affiliate_payouts'), - affiliate_id CHAR(12) REFERENCES users(id), - amount NUMERIC(10,2), - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id); - --- 4. Alter payments table -ALTER TABLE payments ADD COLUMN idempotency_key VARCHAR(64) UNIQUE; -ALTER TABLE payments ADD COLUMN square_payment_id TEXT; -ALTER TABLE payments ADD COLUMN card_brand TEXT; -ALTER TABLE payments ADD COLUMN card_last_4 TEXT; -``` - ---- - -## File Change Summary - -| File | Change | -|------|--------| -| `init-scripts/init-script.sql` | Add 3 new tables + 4 column alters on payments | -| `backend/internal/square/types.go` | NEW — shared types and SquareClient interface | -| `backend/internal/square/square_dev.go` | NEW — dev mock with simulated delays, in-memory storage | -| `backend/internal/square/square.go` | NEW — prod stub (not implemented) | -| `backend/handlers/payments/payments.go` | NEW — all payment handlers (terminal, online, refund, tip) | -| `backend/handlers/payments/payments_test.go` | NEW — integration tests | -| `backend/handlers/webhooks/square.go` | NEW — webhook stub | -| `backend/main.go` | Wire payment + webhook routes, initSquare() | -| `frontend/src/lib/components/payments/PaymentModal.svelte` | NEW — receipt modal with price override and polling | -| `frontend/src/lib/components/today/CurrentAppointment.svelte` | Wire payment button to PaymentModal | -| `frontend/src/lib/components/booking/BookingFlow.svelte` | Replace Step 4 TODO with payment UI (deposit, saved cards, new card) | -| `frontend/src/routes/pay-tip/[id]/+page.svelte` | NEW — public tip payment page (QR code flow) | -| `frontend/src/lib/components/admin/EditBookingModal.svelte` | Add refund button to payment section | -| `frontend/src/lib/types/` | Add TypeScript types for Payment, Refund, CardOnFile | -| `.env.example` | Add `SQUARE_ACCESS_TOKEN`, `SQUARE_LOCATION_ID`, `SQUARE_ENVIRONMENT` | -| `local-dev-2.sh` | Pass Square env vars to tmux session | - ---- - -## Testing Plan - -### File: `backend/internal/square/square_dev_test.go` (mock client unit tests) - -1. **TestDevClient_CreatePayment_ReturnsCompleted** — Call `CreatePayment` → verify returns `Status: "COMPLETED"`, `ID` starts with `mock_pay_`, `CardBrand: "Visa"`, `CardLast4: "4242"`, delay ~1s -2. **TestDevClient_CreateCheckout_PendingThenCompleted** — Call `CreateCheckout` → verify returns `Status: "PENDING"` immediately → poll `GetCheckout` at 0s (PENDING), 1s (PENDING), 3s (COMPLETED) → verify `TipAmount: 500` when `TipEnabled: true` -3. **TestDevClient_CreateCheckout_NoTip** — Call `CreateCheckout` with `TipEnabled: false` → after completion, verify `TipAmount: 0` -4. **TestDevClient_RefundPayment_ReturnsCompleted** — Call `RefundPayment` → verify returns `Status: "COMPLETED"`, `ID` starts with `mock_refund_` -5. **TestDevClient_CardOnFile_CreateAndGet** — Call `CreateCardOnFile("user1", "token1")` → call `GetCardsOnFile("user1")` → verify returns 1 card with `Last4: "4242"`, `Brand: "Visa"`, `CardID: "token1"` -6. **TestDevClient_CardOnFile_MultipleCards** — Create 2 cards for same user → `GetCardsOnFile` returns both -7. **TestDevClient_CardOnFile_Delete** — Create card → delete → `GetCardsOnFile` returns empty for that user -8. **TestDevClient_CardOnFile_DeleteNotFound** — Delete non-existent card → returns error -9. **TestDevClient_GetCheckout_NotFound** — Poll non-existent checkout → returns error -10. **TestDevClient_ConcurrentPayments** — Fire 5 `CreatePayment` calls concurrently → all succeed with unique IDs, no data race (verify with `-race` flag) - -### File: `backend/handlers/payments/payments_test.go` (handler integration tests) - -#### Terminal Payment (Use Case 1) - -11. **TestTerminalPayment_HappyPath** — Create booking with `status='in_progress'` → POST `/api/admin/bookings/{id}/payment` with `{amount: 5000, payment_type: "full"}` → returns `200` with `checkout_id`, `status: "PENDING"` → wait 3s → GET `/api/admin/payments/{checkout_id}/status` → returns `status: "COMPLETED"` with `card_brand: "Visa"`, `card_last_4: "4242"` → verify `payments` table has 1 row with `payment_method='in_person_card'`, `payment_type='full'`, `amount=50.00` - -12. **TestTerminalPayment_WithTip** — Same as above but mock returns `tip_amount: 500` → verify 2 payment records: one `payment_type='full'` (£50.00), one `payment_type='tip'` (£5.00) - -13. **TestTerminalPayment_PriceOverride** — POST payment with `{override_amount: 6000}` → verify payment recorded at £60.00, not booking total - -14. **TestTerminalPayment_BookingNotInProgress** — Create booking with `status='pending'` → POST payment → returns `400` with error "booking must be in_progress or completed" - -15. **TestTerminalPayment_BookingNotFound** — POST payment for non-existent booking ID → returns `404` - -16. **TestTerminalPayment_NonAdmin** — POST payment with user token (not admin) → returns `403` - -17. **TestTerminalCheckoutPoll_NotFound** — GET `/api/admin/payments/nonexistent/status` → returns `404` - -#### Online Payment (Use Case 2) - -18. **TestOnlinePayment_NewCard_Deposit** — Create booking with `deposit_amount=25.00` → POST `/api/bookings/{id}/payment` with `{amount: 2500, payment_type: "deposit", new_card_token: "cnon:xxx", save_card: true}` → verify: (a) payment record with `payment_method='online_square'`, `payment_type='deposit'`, `amount=25.00`, (b) `customer_payment_methods` row with `last_4='4242'`, `brand='Visa'`, (c) `idempotency_key` stored - -19. **TestOnlinePayment_NewCard_Full** — Same as above with `payment_type='full'`, `amount=5000` → verify payment record with `payment_type='full'` - -20. **TestOnlinePayment_SavedCard** — Create saved card for user → POST payment with `{card_id: "", amount: 2500, payment_type: "deposit"}` → verify payment succeeds, no new card created in `customer_payment_methods` - -21. **TestOnlinePayment_BookingNotOwned** — User A tries to pay for User B's booking → returns `403` or `404` - -22. **TestOnlinePayment_InvalidAmount** — POST payment with `amount: 0` → returns `400` - -23. **TestOnlinePayment_MissingCardInfo** — POST payment with neither `card_id` nor `new_card_token` → returns `400` - -#### Saved Card Management - -24. **TestGetUserPaymentMethods_Empty** — User with no saved cards → GET `/api/user/payment-methods` → returns `200` with empty array - -25. **TestGetUserPaymentMethods_HasCards** — User with 2 saved cards → GET returns both with `brand`, `last_4`, `exp_month`, `exp_year`, `is_default` - -26. **TestGetUserPaymentMethods_OtherUserCards** — User A GETs payment methods → does NOT see User B's cards - -27. **TestDeletePaymentMethod** — Create saved card → DELETE `/api/user/payment-methods/{id}` → verify row removed → GET returns empty - -28. **TestDeletePaymentMethod_NotFound** — DELETE non-existent card → returns `404` - -29. **TestDeletePaymentMethod_OtherUserCard** — User A tries to delete User B's card → returns `403` or `404` - -#### Refunds (Use Case 4) - -30. **TestRefund_FullRefund** — Create completed payment (£50.00) → POST `/api/admin/payments/{id}/refund` with `{amount: 5000, reason: "Customer request"}` → verify: (a) `refunds` row with `amount=50.00`, `status='completed'`, (b) payment status unchanged (`completed`), (c) total refunded = payment amount - -31. **TestRefund_PartialRefund** — Create completed payment (£50.00) → POST refund with `{amount: 2500}` → verify `refunds` row with `amount=25.00` → POST another refund with `{amount: 1500}` → verify second `refunds` row → total refunded = £40.00 - -32. **TestRefund_OverRefundRejected** — Create completed payment (£50.00) → refund £30.00 → try to refund £25.00 → returns `400` with error "refund amount exceeds remaining balance" - -33. **TestRefund_DepositReset** — Create deposit payment (£25.00) for booking → refund full amount → verify `bookings.deposit_paid` resets to `FALSE` - -34. **TestRefund_NonDepositNoReset** — Create full payment (£50.00) → refund full amount → verify `bookings.deposit_paid` unchanged (was TRUE, stays TRUE) - -35. **TestRefund_PaymentNotFound** — POST refund for non-existent payment → returns `404` - -36. **TestRefund_PendingPaymentRejected** — Create payment with `status='pending'` → POST refund → returns `400` with error "cannot refund pending payment" - -37. **TestRefund_NonAdmin** — POST refund with user token → returns `403` - -#### Tip Payment - -38. **TestTipPayment_HappyPath** — Create booking with completed payment → POST `/api/bookings/{id}/tip` with `{amount: 500, card_token: "cnon:xxx"}` → verify `payments` row with `payment_type='tip'`, `amount=5.00` - -39. **TestTipPayment_NoPriorPayment** — Booking with no completed payment → POST tip → returns `400` with error "no completed payment found for this booking" - -40. **TestTipPayment_BookingNotFound** → returns `404` - -#### Idempotency - -41. **TestIdempotency_SameKeyReturnsExisting** — POST payment with `idempotency_key: "abc123"` → returns payment ID `pay_001` → POST again with same key → returns same payment ID `pay_001`, no duplicate row in `payments` - -42. **TestIdempotency_DifferentKeyCreatesNew** — POST payment with `idempotency_key: "abc123"` → POST with `idempotency_key: "def456"` → returns different payment ID, 2 rows in `payments` - -43. **TestIdempotency_KeyCollisionDifferentBooking** — POST payment for booking A with key `abc123` → POST payment for booking B with same key → returns booking A's payment (first match wins) - -#### Webhook - -44. **TestSquareWebhook_DevMode_NoSignature** — POST `/api/webhooks/square` with `{type: "payment.updated", data: {...}}` in dev mode → returns `200`, no crash - -45. **TestSquareWebhook_PaymentUpdated** — POST webhook with `type: "payment.updated"`, `data.object.status: "COMPLETED"` → verify `payments.status` updated if status changed - -46. **TestSquareWebhook_RefundUpdated** — POST webhook with `type: "refund.updated"` → verify `refunds.status` updated - -47. **TestSquareWebhook_UnknownEventType** — POST webhook with `type: "unknown.event"` → returns `200`, logs warning, no crash - -### File: `backend/handlers/bookings/bookings_test.go` (existing file, add payment-related tests) - -48. **TestCreateBooking_DepaidPaid_AfterOnlinePayment** — Create booking → POST online deposit payment → verify booking's deposit tracking reflects paid status - -49. **TestCreateBooking_DepositDeadline_StillPending** — Create booking with deposit deadline in past, no payment → verify booking cannot proceed (existing logic, verify still works) - -### Edge Cases & Error Paths - -50. **TestTerminalPayment_ZeroAmount** — POST payment with `amount: 0` → returns `400` - -51. **TestTerminalPayment_NegativeAmount** → returns `400` - -52. **TestOnlinePayment_ExpiredCard** — Saved card with `exp_year < current_year` → POST payment → returns `400` with error "card has expired" - -53. **TestRefund_EmptyReason** — POST refund with empty `reason` → returns `400` (reason required for audit trail) - -54. **TestRefund_MinimumAmount** — POST refund with `amount: 1` (£0.01) → succeeds (minimum refund is 1p) - -55. **TestTipPayment_ZeroTip** — POST tip with `amount: 0` → returns `400` - -56. **TestGetCheckoutStatus_PaymentAlreadyRecorded** — Poll checkout that already had payment recorded → returns existing payment, no duplicate - -57. **TestOnlinePayment_DuplicateSavedCard** — Save same card token twice for same user → second save creates duplicate row (acceptable — Square may issue different tokens for same card) - -### Test Data Setup Helpers - -New helper functions in `backend/testutils/fixtures/fixtures.go`: - -```go -func CreateTestPayment(db *pgxpool.Pool, bookingID string, amount float64, method string, ptype string, status string) (string, error) -func CreateTestRefund(db *pgxpool.Pool, paymentID string, bookingID string, amount float64) (string, error) -func CreateTestPaymentMethod(db *pgxpool.Pool, userID string, squareCardID string, brand string, last4 string) (string, error) -``` - -### Test Execution - -All tests run with: -```bash -go test -tags test -v -p 1 -count=1 ./handlers/payments/ -go test -tags test -v -p 1 -count=1 ./internal/square/ -``` - -Mock client tests (`square_dev_test.go`) run without DB — pure unit tests. -Handler tests (`payments_test.go`) require test DB — use existing `resetTestData(t)` + `fixtures` pattern. - ---- - -## TODOs (noted, not implemented) - -- [ ] Receipt email/SMS after payment (driven by `user_notification_preferences`, requires SMTP provider — E5) -- [ ] Deposit deadline auto-cancel cron job (booking auto-cancels if deposit not paid by deadline) -- [ ] Payment reconciliation job (periodic sync with Square ledger to catch mismatches) -- [ ] Real Square SDK integration (prod stub until credentials exist) -- [ ] Affiliate payout processing (ledger table exists, no Square interaction yet) -- [ ] Card expiry sync from Square webhook (update `exp_month`/`exp_year` when card expires) -- [ ] Dispute handling (Square webhook `dispute.created` → admin notification + payment flag) - ---- - -## Risks & Mitigations - -| Risk | Mitigation | -|------|-----------| -| Double-charging on network retry | Idempotency key on every payment call — checked before Square API call | -| Terminal payment hangs (customer never taps card) | Checkout has 5-minute TTL. Polling times out after 5 minutes. Admin can cancel and retry. | -| Refund exceeds payment amount | Backend validates: `SUM(refunds) + new_refund <= payment.amount` | -| Card-on-file expires | Store `exp_month`/`exp_year`. Frontend shows expired cards as unusable. TODO: sync from Square webhook. | -| Webhook signature forgery | Prod mode verifies HMAC signature. Dev mode skips for convenience. | -| Mock diverges from real Square API | Interface is designed to match Square's actual API shapes. When prod SDK is wired, only the implementation changes — handlers stay the same. | -| Payment recorded but Square fails | All operations in single transaction. If Square call fails, no payment record created. | -| Partial refund accounting | `refunds` table tracks each refund separately. Total refunded = SUM(refunds.amount). Payment status stays `completed` until fully refunded. | diff --git a/.sisyphus/plans/test-db-optimization.md b/.sisyphus/plans/test-db-optimization.md deleted file mode 100644 index 9ea6376..0000000 --- a/.sisyphus/plans/test-db-optimization.md +++ /dev/null @@ -1,206 +0,0 @@ -# Test DB Reset Optimization — Deep Analysis & Updated Plan - -## Current State: The Problem - -Every test (288 total) does a **full schema DROP + CREATE + TRUNCATE**: - -``` -setupTestDB(t) -├── testdb.Pool(t) → New pgxpool connection (expensive) -├── testdb.Migrate(t, pool) → Full schema DROP + CREATE -│ ├── Check typeCount → ALWAYS > 0 (shared crussell_test DB) -│ ├── DROP 14 types CASCADE -│ ├── DROP 24 tables CASCADE -│ ├── DROP 4 sequences -│ └── Run init-script.sql → CREATE all tables, types, indexes, stored procs -├── testdb.TruncateTables() → TRUNCATE 21 tables CASCADE -├── db.DB = pool → Replace global -├── jwt.Init() → Re-init JWT -└── defer: pool.Close() → Close connection -``` - -**227 tests** call `setupTestDB` directly. The remaining **61 tests** use custom setup functions (`setupTest`, `setupReserveTestDB`, `setupTimeBlockersTestDB`) that do the exact same thing — Pool + Migrate + Truncate. - -**All 288 tests do full schema destruction and rebuild.** - ---- - -## Deep Analysis: Cross-Test Safety Verification - -### ✅ VERIFIED SAFE: No Schema Creation by Tests - -Grep for `CREATE TABLE|DROP TABLE|ALTER TABLE|CREATE TYPE|DROP TYPE|CREATE INDEX|CREATE FUNCTION` in all `_test.go` files: **zero matches**. No test creates, alters, or drops any schema objects. All tests only INSERT/UPDATE/DELETE row data. - -### ✅ VERIFIED SAFE: No Cross-Test Dependencies - -Every test is self-contained. Each creates its own data via fixtures or direct SQL, and either: -- Uses `defer fixtures.Delete...` for cleanup, OR -- Relies on `TruncateTables` to clean up before the next test - -No test reads data created by a previous test. No test depends on sequential ordering. - -### ✅ VERIFIED SAFE: Empty-State Tests - -5 tests explicitly check for empty results: -- `TestPortfolio_ListImages_Empty` — expects 0 images -- `TestPortfolio_ListTags_Empty` — expects 0 tags -- `TestPortfolio_ListFilters_Empty` — expects 0 filters -- `TestNotifications_ListEmpty` — expects 0 notifications -- `TestCustomerRelationship_NoBookings` — expects user with no bookings - -All are safe with TRUNCATE — truncation produces the empty state these tests expect. - -### ✅ VERIFIED SAFE: `seedDefaultWorkingHours` - -Used by 38 tests across 4 files. Uses `ON CONFLICT (weekday) DO UPDATE` — fully idempotent. Safe to call multiple times. - -### ⚠️ FINDING: `TruncateTables` is missing 3 tables - -The truncate list has 21 tables, but the schema has 24. Missing: -- `booking_edit_requests` — used by 11 tests in bookings/admin -- `exceptional_group_applications` — used by 3 tests in bookings/scheduling -- `business_settings` — 1 row, never modified by tests - -**Why this hasn't broken things**: CASCADE foreign keys handle cleanup: -- `booking_edit_requests.booking_id → bookings.id` (CASCADE) → cleaned when `bookings` truncated -- `exceptional_group_applications.group_id → exceptional_working_hours_groups.id` (CASCADE) → cleaned when groups truncated -- `business_settings` — static seed data, never modified - -**Action needed**: Add these 3 tables to `TruncateTables` for correctness. Currently relying on implicit CASCADE behavior. - -### ⚠️ FINDING: `handlers/handlers_test.go` has no setup wrapper - -`TestIntegration_UserFlow` uses `testdb.Pool(t)` directly (no Migrate, no Truncate) with `defer fixtures.DeleteUser` for cleanup. The other 3 tests in this file don't use the DB at all. This package needs a TestMain that runs Migrate once. - -### ⚠️ FINDING: `discount_test.go` has dead code - -`truncateDiscountTables()` helper is defined but never called. The file uses `setupTestDB(t)` which already calls the full `TruncateTables`. Safe to delete. - -### ✅ VERIFIED SAFE: Global state - -- `auth.TokenAuth` — set by `jwt.Init()`, called per-test currently, will be once-per-package in TestMain. No test modifies it. -- `loginInProgress` / `loginAttempts` — package-level maps in `local.go`. Handler cleans up via defer/delete. No test modifies them directly. -- `dav.Service` — set to `&dav.BaseService{}` in auth_test.go's setup. Persists across tests but is a read-only mock. Safe. -- `fixtures.testEmailCounter` — increments for unique emails. Monotonically increasing, never resets. Safe (designed for this). - ---- - -## Updated Plan - -### Phase 1: Add TestMain to Each Package + Fix TruncateTables - -**Step 1A: Fix `TruncateTables` to include all 24 tables** - -Add to `testutils/testdb/testdb.go`: -```go -tables := []string{ - // ... existing 21 tables ... - "booking_edit_requests", // NEW - "exceptional_group_applications", // NEW - "business_settings", // NEW -} -``` - -**Step 1B: Add TestMain to each package** - -Each package gets ONE `TestMain` (not per file — per package). In Go, if multiple files in the same package define `TestMain`, it's a compile error. So we need ONE file per package with TestMain. - -| Package | TestMain goes in | Tests covered | -|---------|-----------------|---------------| -| `handlers/bookings` | New file `bookings_testmain_test.go` | 98 (4 files) | -| `handlers/admin` | New file `admin_testmain_test.go` | 65 (4 files) | -| `handlers/auth` | `auth_test.go` (add to existing) | 25 | -| `handlers/scheduling` | New file `scheduling_testmain_test.go` | 34 (2 files) | -| `handlers/portfolio` | `images_test.go` (add to existing) | 16 | -| `handlers/notifications` | `notifications_test.go` (add to existing) | 12 | -| `handlers/services` | `services_test.go` (add to existing) | 5 | -| `handlers/user` | New file `user_testmain_test.go` | 21 (3 files) | -| `handlers` | `handlers_test.go` (add to existing) | 4 | -| `main` | `main_test.go` (add to existing) | 2 | - -**TestMain template** (varies slightly per package): -```go -func TestMain(m *testing.M) { - pool := testdb.NewPool("") - testdb.Migrate(&testing.T{}, pool) - db.DB = pool - jwt.Init() - // auth_test.go also needs: dav.Service = &dav.BaseService{} - code := m.Run() - pool.Close() - os.Exit(code) -} -``` - -**Step 1C: Convert `setupTestDB(t)` to `resetTestData(t)`** - -Each package's setup function becomes: -```go -func resetTestData(t *testing.T) { - t.Helper() - testdb.TruncateTables(t, db.DB) - // For packages that need working hours: - // seedDefaultWorkingHours(t) -} -``` - -**Step 1D: Remove redundant per-test operations** - -- Remove `db.DB = pool` swap (pool is now global) -- Remove `jwt.Init()` from per-test setup (done once in TestMain) -- Remove `pool.Close()` from defer (pool is shared, closed in TestMain) -- Remove `testdb.Pool(t)` calls (use global `db.DB`) -- Remove `testdb.Migrate(t, pool)` calls (done once in TestMain) - -### Phase 2: Clean Up - -- Delete dead `truncateDiscountTables()` in discount_test.go -- Consolidate `seedDefaultWorkingHours` into one shared function (currently duplicated in 4 files with slight variations) -- Add `dav.Service` mock to TestMain for auth package only - -### Phase 3: Smart Truncate (Optional, Later) - -Identify which tables each test actually touches and truncate only those. Low priority — Phase 1 gives 95% of the benefit. - ---- - -## Risk Assessment (Updated) - -| Risk | Likelihood | Severity | Mitigation | -|------|-----------|----------|------------| -| Test pollution (data leaking) | Low | High | `TRUNCATE CASCADE` is reliable; verify with `-count=2` | -| Missing tables in TruncateTables | **Confirmed** | Medium | **Fix in Phase 1A** — add 3 missing tables | -| `TestMain` compile conflict | Low | High | ONE TestMain per package, not per file | -| `db.DB` global race | Low | High | Tests run sequentially (`-p 1`) | -| `jwt.Init()` called once vs per-test | Low | Medium | JWT state is idempotent; no test modifies TokenAuth | -| `dav.Service` mock persistence | Low | Low | Only auth tests use it; mock is stateless | - -### Verification Strategy: -1. Run `go test -tags test -v -p 1 -count=1 ./...` — record baseline count/timing -2. Apply Phase 1A (fix TruncateTables) -3. Run tests — should still pass (no logic change) -4. Apply Phase 1B-1D (TestMain + refactor) -5. Run `go test -tags test -v -p 1 -count=2 ./...` — double-run catches state leakage -6. Compare: 288 tests, same pass/fail, faster execution - ---- - -## Expected Impact - -| Metric | Before | After Phase 1 | After Phase 3 | -|--------|--------|---------------|---------------| -| Schema DROP+CREATE | 288 | 10 (one per package) | 10 | -| TRUNCATE per test | 21 tables | 21 tables | ~3-5 tables | -| Connection pools created | 288 | 10 | 10 | -| **Estimated total time** | **~144s** | **~58s** | **~30s** | - -> Estimates based on: DROP+CREATE ~400ms, TRUNCATE 21 tables ~100ms, pool connect ~50ms. Actual timing depends on PostgreSQL container performance. - ---- - -## What NOT to Change - -- **Don't add `t.Parallel()`** — requires per-test transactions or separate databases -- **Don't use transaction rollback** — some tests verify side effects needing committed data -- **Don't change `init-script.sql`** — schema is correct, we're just running it too many times -- **Don't touch `fixtures.testEmailCounter`** — it's designed to be monotonic