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:
paymentstable (init-script.sql line 373):id,booking_id,payment_type,payment_method,vendor_code,invoice_number,status,amount, VAT fields, timestamps,created_bypayment_typeenum:'deposit','full','tip','balance','partial'payment_methodenum:'online_square','in_person_card','cash','giftcard','discount'payment_statusenum:'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_preferencestable withemail_enabled,sms_enabled,browser_push_enabledbooleans- Idempotency key pattern already used for bookings (
idempotency_key VARCHAR(64) UNIQUE) - S3 mock pattern:
internal/s3/s3_dev.go(build tagdev) +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
- 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
- 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
- Deposits: Same as online pre-payment but
payment_type='deposit', amount = booking'sdeposit_amount. On success,deposit_paidbecomes true. - Refunds: Admin can refund a payment (full or partial) → Square processes refund →
refundstable records it → if full deposit refund, resetdeposit_paid - 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 simulationinternal/square/square.go(//go:build !dev): Prod stub returning "not implemented" until real Square credentials existinternal/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:
CreateCheckout→ returns{checkout_id: "mock_checkout_<nanoid>", status: "PENDING"}- Frontend polls
GET /api/admin/payments/{checkout_id}/statusevery 2s - After 3s, mock returns
{status: "COMPLETED", card_details: {last_4: "4242", brand: "Visa"}, tip_amount: 5.00} - Backend creates
paymentsrecord withpayment_method='in_person_card'
Card-on-File Storage
Square's Web Payments SDK tokenizes cards server-side. We store a reference locally:
customer_payment_methodstable: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>) withlast_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.statustracks the payment itselfrefunds.statustracks 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:
- Receives payment token from frontend after 3DS passes
- Calls
square.CreatePayment(token)— Square confirms 3DS was satisfied - 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:
- Customer enters new card → Square tokenizes → we store reference
- On next booking, customer sees saved cards → selects one → pays without re-entering details
- 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:
- Admin initiates refund →
status='pending' - Square processes →
status='completed' - 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 retrysquare_payment_id: Square's payment ID for reconciliationcard_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:
- Validate booking exists, status =
in_progressorcompleted - If
override_amountprovided, use it instead of booking total - Check idempotency:
SELECT 1 FROM payments WHERE idempotency_key = $1— if exists, return existing payment - Call
squareClient.CreateCheckout()withTipEnabled: true - Return
{checkout_id, status: "PENDING"}immediately (async)
Endpoint: GET /api/admin/payments/{checkout_id}/status
Flow:
- Call
squareClient.GetCheckout(checkoutID) - If status =
COMPLETED:- Insert
paymentsrecord:payment_method='in_person_card',square_payment_id,card_brand,card_last_4,idempotency_key - If tip_amount > 0: insert separate
paymentsrecord withpayment_type='tip' - Return payment details
- Insert
- If status =
PENDING: return{status: "PENDING"} - 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:
- Validate booking belongs to authenticated user
- If
new_card_tokenprovided:- Call
squareClient.CreateCardOnFile(userID, new_card_token) - If
save_card: insert intocustomer_payment_methods
- Call
- Call
squareClient.CreatePayment()with card token - Insert
paymentsrecord:payment_method='online_square' - If
payment_type='deposit': update booking's deposit tracking (already computed by backend) - 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:
- Lookup payment, verify
status='completed' - Calculate already-refunded amount:
SELECT COALESCE(SUM(amount), 0) FROM refunds WHERE payment_id = $1 AND status = 'completed' - If
requested_amount + already_refunded > payment.amount: reject (over-refund) - Call
squareClient.RefundPayment() - Insert
refundsrecord - If full refund of a deposit payment:
UPDATE bookings SET deposit_paid = FALSE WHERE id = $1 - 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:
- Validate booking exists and has a completed payment
- Call
squareClient.CreatePayment()withpayment_type='tip' - Insert
paymentsrecord - Return result
Phase 3: Webhook Handler (stub)
File: backend/handlers/webhooks/square.go (new)
Endpoint: POST /api/webhooks/square
Flow:
- Read
x-square-signatureheader - Dev mode: skip verification
- Prod mode: verify HMAC signature against request body
- Parse event type:
payment.updated: updatepayments.statusif changedrefund.updated: updaterefunds.statusif changeddispute.created: log warning, update payment status
- 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: BookingonClose: () => voidonPaymentComplete: (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→ pollsGET /api/admin/payments/{checkout_id}/statusevery 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}/paymentwithpayment_type='deposit' - On success: proceed to confirmation
- Show saved cards (fetched from
- 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)
- TestDevClient_CreatePayment_ReturnsCompleted — Call
CreatePayment→ verify returnsStatus: "COMPLETED",IDstarts withmock_pay_,CardBrand: "Visa",CardLast4: "4242", delay ~1s - TestDevClient_CreateCheckout_PendingThenCompleted — Call
CreateCheckout→ verify returnsStatus: "PENDING"immediately → pollGetCheckoutat 0s (PENDING), 1s (PENDING), 3s (COMPLETED) → verifyTipAmount: 500whenTipEnabled: true - TestDevClient_CreateCheckout_NoTip — Call
CreateCheckoutwithTipEnabled: false→ after completion, verifyTipAmount: 0 - TestDevClient_RefundPayment_ReturnsCompleted — Call
RefundPayment→ verify returnsStatus: "COMPLETED",IDstarts withmock_refund_ - TestDevClient_CardOnFile_CreateAndGet — Call
CreateCardOnFile("user1", "token1")→ callGetCardsOnFile("user1")→ verify returns 1 card withLast4: "4242",Brand: "Visa",CardID: "token1" - TestDevClient_CardOnFile_MultipleCards — Create 2 cards for same user →
GetCardsOnFilereturns both - TestDevClient_CardOnFile_Delete — Create card → delete →
GetCardsOnFilereturns empty for that user - TestDevClient_CardOnFile_DeleteNotFound — Delete non-existent card → returns error
- TestDevClient_GetCheckout_NotFound — Poll non-existent checkout → returns error
- TestDevClient_ConcurrentPayments — Fire 5
CreatePaymentcalls concurrently → all succeed with unique IDs, no data race (verify with-raceflag)
File: backend/handlers/payments/payments_test.go (handler integration tests)
Terminal Payment (Use Case 1)
-
TestTerminalPayment_HappyPath — Create booking with
status='in_progress'→ POST/api/admin/bookings/{id}/paymentwith{amount: 5000, payment_type: "full"}→ returns200withcheckout_id,status: "PENDING"→ wait 3s → GET/api/admin/payments/{checkout_id}/status→ returnsstatus: "COMPLETED"withcard_brand: "Visa",card_last_4: "4242"→ verifypaymentstable has 1 row withpayment_method='in_person_card',payment_type='full',amount=50.00 -
TestTerminalPayment_WithTip — Same as above but mock returns
tip_amount: 500→ verify 2 payment records: onepayment_type='full'(£50.00), onepayment_type='tip'(£5.00) -
TestTerminalPayment_PriceOverride — POST payment with
{override_amount: 6000}→ verify payment recorded at £60.00, not booking total -
TestTerminalPayment_BookingNotInProgress — Create booking with
status='pending'→ POST payment → returns400with error "booking must be in_progress or completed" -
TestTerminalPayment_BookingNotFound — POST payment for non-existent booking ID → returns
404 -
TestTerminalPayment_NonAdmin — POST payment with user token (not admin) → returns
403 -
TestTerminalCheckoutPoll_NotFound — GET
/api/admin/payments/nonexistent/status→ returns404
Online Payment (Use Case 2)
-
TestOnlinePayment_NewCard_Deposit — Create booking with
deposit_amount=25.00→ POST/api/bookings/{id}/paymentwith{amount: 2500, payment_type: "deposit", new_card_token: "cnon:xxx", save_card: true}→ verify: (a) payment record withpayment_method='online_square',payment_type='deposit',amount=25.00, (b)customer_payment_methodsrow withlast_4='4242',brand='Visa', (c)idempotency_keystored -
TestOnlinePayment_NewCard_Full — Same as above with
payment_type='full',amount=5000→ verify payment record withpayment_type='full' -
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 incustomer_payment_methods -
TestOnlinePayment_BookingNotOwned — User A tries to pay for User B's booking → returns
403or404 -
TestOnlinePayment_InvalidAmount — POST payment with
amount: 0→ returns400 -
TestOnlinePayment_MissingCardInfo — POST payment with neither
card_idnornew_card_token→ returns400
Saved Card Management
-
TestGetUserPaymentMethods_Empty — User with no saved cards → GET
/api/user/payment-methods→ returns200with empty array -
TestGetUserPaymentMethods_HasCards — User with 2 saved cards → GET returns both with
brand,last_4,exp_month,exp_year,is_default -
TestGetUserPaymentMethods_OtherUserCards — User A GETs payment methods → does NOT see User B's cards
-
TestDeletePaymentMethod — Create saved card → DELETE
/api/user/payment-methods/{id}→ verify row removed → GET returns empty -
TestDeletePaymentMethod_NotFound — DELETE non-existent card → returns
404 -
TestDeletePaymentMethod_OtherUserCard — User A tries to delete User B's card → returns
403or404
Refunds (Use Case 4)
-
TestRefund_FullRefund — Create completed payment (£50.00) → POST
/api/admin/payments/{id}/refundwith{amount: 5000, reason: "Customer request"}→ verify: (a)refundsrow withamount=50.00,status='completed', (b) payment status unchanged (completed), (c) total refunded = payment amount -
TestRefund_PartialRefund — Create completed payment (£50.00) → POST refund with
{amount: 2500}→ verifyrefundsrow withamount=25.00→ POST another refund with{amount: 1500}→ verify secondrefundsrow → total refunded = £40.00 -
TestRefund_OverRefundRejected — Create completed payment (£50.00) → refund £30.00 → try to refund £25.00 → returns
400with error "refund amount exceeds remaining balance" -
TestRefund_DepositReset — Create deposit payment (£25.00) for booking → refund full amount → verify
bookings.deposit_paidresets toFALSE -
TestRefund_NonDepositNoReset — Create full payment (£50.00) → refund full amount → verify
bookings.deposit_paidunchanged (was TRUE, stays TRUE) -
TestRefund_PaymentNotFound — POST refund for non-existent payment → returns
404 -
TestRefund_PendingPaymentRejected — Create payment with
status='pending'→ POST refund → returns400with error "cannot refund pending payment" -
TestRefund_NonAdmin — POST refund with user token → returns
403
Tip Payment
-
TestTipPayment_HappyPath — Create booking with completed payment → POST
/api/bookings/{id}/tipwith{amount: 500, card_token: "cnon:xxx"}→ verifypaymentsrow withpayment_type='tip',amount=5.00 -
TestTipPayment_NoPriorPayment — Booking with no completed payment → POST tip → returns
400with error "no completed payment found for this booking" -
TestTipPayment_BookingNotFound → returns
404
Idempotency
-
TestIdempotency_SameKeyReturnsExisting — POST payment with
idempotency_key: "abc123"→ returns payment IDpay_001→ POST again with same key → returns same payment IDpay_001, no duplicate row inpayments -
TestIdempotency_DifferentKeyCreatesNew — POST payment with
idempotency_key: "abc123"→ POST withidempotency_key: "def456"→ returns different payment ID, 2 rows inpayments -
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
-
TestSquareWebhook_DevMode_NoSignature — POST
/api/webhooks/squarewith{type: "payment.updated", data: {...}}in dev mode → returns200, no crash -
TestSquareWebhook_PaymentUpdated — POST webhook with
type: "payment.updated",data.object.status: "COMPLETED"→ verifypayments.statusupdated if status changed -
TestSquareWebhook_RefundUpdated — POST webhook with
type: "refund.updated"→ verifyrefunds.statusupdated -
TestSquareWebhook_UnknownEventType — POST webhook with
type: "unknown.event"→ returns200, logs warning, no crash
File: backend/handlers/bookings/bookings_test.go (existing file, add payment-related tests)
-
TestCreateBooking_DepaidPaid_AfterOnlinePayment — Create booking → POST online deposit payment → verify booking's deposit tracking reflects paid status
-
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
-
TestTerminalPayment_ZeroAmount — POST payment with
amount: 0→ returns400 -
TestTerminalPayment_NegativeAmount → returns
400 -
TestOnlinePayment_ExpiredCard — Saved card with
exp_year < current_year→ POST payment → returns400with error "card has expired" -
TestRefund_EmptyReason — POST refund with empty
reason→ returns400(reason required for audit trail) -
TestRefund_MinimumAmount — POST refund with
amount: 1(£0.01) → succeeds (minimum refund is 1p) -
TestTipPayment_ZeroTip — POST tip with
amount: 0→ returns400 -
TestGetCheckoutStatus_PaymentAlreadyRecorded — Poll checkout that already had payment recorded → returns existing payment, no duplicate
-
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_yearwhen 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. |