diff --git a/.sisyphus/plans/square-payment-integration.md b/.sisyphus/plans/square-payment-integration.md new file mode 100644 index 0000000..5387a3e --- /dev/null +++ b/.sisyphus/plans/square-payment-integration.md @@ -0,0 +1,931 @@ +# 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. |