Files
Crussell/.sisyphus/plans/square-payment-integration.md
T
2026-05-17 11:03:05 +01:00

37 KiB

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_<nanoid>", 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_<nanoid>) 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).

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).

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.

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;
  • 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)

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: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: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():

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:

{
    "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:

{
    "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:

{
    "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:

{
    "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

// 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:

function handleTakePayment() {
    toast.info('Take payment - Coming soon');
}

With:

let showPaymentModal = $state(false);

function handleTakePayment() {
    showPaymentModal = true;
}

Add <PaymentModal> 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

-- 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)

  1. 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

  2. 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)

  3. TestTerminalPayment_PriceOverride — POST payment with {override_amount: 6000} → verify payment recorded at £60.00, not booking total

  4. TestTerminalPayment_BookingNotInProgress — Create booking with status='pending' → POST payment → returns 400 with error "booking must be in_progress or completed"

  5. TestTerminalPayment_BookingNotFound — POST payment for non-existent booking ID → returns 404

  6. TestTerminalPayment_NonAdmin — POST payment with user token (not admin) → returns 403

  7. TestTerminalCheckoutPoll_NotFound — GET /api/admin/payments/nonexistent/status → returns 404

Online Payment (Use Case 2)

  1. 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

  2. TestOnlinePayment_NewCard_Full — Same as above with payment_type='full', amount=5000 → verify payment record with payment_type='full'

  3. TestOnlinePayment_SavedCard — Create saved card for user → POST payment with {card_id: "<saved_card_id>", amount: 2500, payment_type: "deposit"} → verify payment succeeds, no new card created in customer_payment_methods

  4. TestOnlinePayment_BookingNotOwned — User A tries to pay for User B's booking → returns 403 or 404

  5. TestOnlinePayment_InvalidAmount — POST payment with amount: 0 → returns 400

  6. TestOnlinePayment_MissingCardInfo — POST payment with neither card_id nor new_card_token → returns 400

Saved Card Management

  1. TestGetUserPaymentMethods_Empty — User with no saved cards → GET /api/user/payment-methods → returns 200 with empty array

  2. TestGetUserPaymentMethods_HasCards — User with 2 saved cards → GET returns both with brand, last_4, exp_month, exp_year, is_default

  3. TestGetUserPaymentMethods_OtherUserCards — User A GETs payment methods → does NOT see User B's cards

  4. TestDeletePaymentMethod — Create saved card → DELETE /api/user/payment-methods/{id} → verify row removed → GET returns empty

  5. TestDeletePaymentMethod_NotFound — DELETE non-existent card → returns 404

  6. TestDeletePaymentMethod_OtherUserCard — User A tries to delete User B's card → returns 403 or 404

Refunds (Use Case 4)

  1. 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

  2. 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

  3. TestRefund_OverRefundRejected — Create completed payment (£50.00) → refund £30.00 → try to refund £25.00 → returns 400 with error "refund amount exceeds remaining balance"

  4. TestRefund_DepositReset — Create deposit payment (£25.00) for booking → refund full amount → verify bookings.deposit_paid resets to FALSE

  5. TestRefund_NonDepositNoReset — Create full payment (£50.00) → refund full amount → verify bookings.deposit_paid unchanged (was TRUE, stays TRUE)

  6. TestRefund_PaymentNotFound — POST refund for non-existent payment → returns 404

  7. TestRefund_PendingPaymentRejected — Create payment with status='pending' → POST refund → returns 400 with error "cannot refund pending payment"

  8. TestRefund_NonAdmin — POST refund with user token → returns 403

Tip Payment

  1. 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

  2. TestTipPayment_NoPriorPayment — Booking with no completed payment → POST tip → returns 400 with error "no completed payment found for this booking"

  3. TestTipPayment_BookingNotFound → returns 404

Idempotency

  1. 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

  2. TestIdempotency_DifferentKeyCreatesNew — POST payment with idempotency_key: "abc123" → POST with idempotency_key: "def456" → returns different payment ID, 2 rows in payments

  3. 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

  1. TestSquareWebhook_DevMode_NoSignature — POST /api/webhooks/square with {type: "payment.updated", data: {...}} in dev mode → returns 200, no crash

  2. TestSquareWebhook_PaymentUpdated — POST webhook with type: "payment.updated", data.object.status: "COMPLETED" → verify payments.status updated if status changed

  3. TestSquareWebhook_RefundUpdated — POST webhook with type: "refund.updated" → verify refunds.status updated

  4. TestSquareWebhook_UnknownEventType — POST webhook with type: "unknown.event" → returns 200, logs warning, no crash

  1. TestCreateBooking_DepaidPaid_AfterOnlinePayment — Create booking → POST online deposit payment → verify booking's deposit tracking reflects paid status

  2. 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

  1. TestTerminalPayment_ZeroAmount — POST payment with amount: 0 → returns 400

  2. TestTerminalPayment_NegativeAmount → returns 400

  3. TestOnlinePayment_ExpiredCard — Saved card with exp_year < current_year → POST payment → returns 400 with error "card has expired"

  4. TestRefund_EmptyReason — POST refund with empty reason → returns 400 (reason required for audit trail)

  5. TestRefund_MinimumAmount — POST refund with amount: 1 (£0.01) → succeeds (minimum refund is 1p)

  6. TestTipPayment_ZeroTip — POST tip with amount: 0 → returns 400

  7. TestGetCheckoutStatus_PaymentAlreadyRecorded — Poll checkout that already had payment recorded → returns existing payment, no duplicate

  8. 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:

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:

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.