square mock plan
This commit is contained in:
@@ -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_<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).
|
||||
|
||||
```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 `<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
|
||||
|
||||
```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: "<saved_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. |
|
||||
Reference in New Issue
Block a user