Files
popertots 1429eddd34 fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
2026-08-22 00:34:50 +01:00

4661 lines
158 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test && dev
package payments
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makePaymentRequest(handler http.HandlerFunc, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder {
return makePaymentAuthRequest(handler, method, path, body, token, "", ctx)
}
// adminRequestCtx wraps a request context with the admin role so handlers that
// run a defense-in-depth isAdminRequest check (S-1) work when called directly
// (bypassing the mw.RequireAdmin middleware that normally injects the role).
func adminRequestCtx(r *http.Request) *http.Request {
reqCtx := context.WithValue(r.Context(), mw.UserRoleKey, "admin")
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "000000000001")
return r.WithContext(reqCtx)
}
func TestValidateCardInfo(t *testing.T) {
empty := ""
cardID := "card_123"
savedCardID := "card_456"
token := "cnon:test"
tests := []struct {
name string
cardID *string
savedCardID *string
newToken *string
wantError bool
}{
{"card_id + token ok (SCA tokenize-result)", &cardID, nil, &token, false},
{"saved_card_id + token ok (SCA tokenize-result)", nil, &savedCardID, &token, false},
{"card_id only ok", &cardID, nil, nil, false},
{"token only ok", nil, nil, &token, false},
{"neither set rejected", nil, nil, nil, true},
{"empty card_id rejected", &empty, nil, nil, true},
{"empty token rejected", nil, nil, &empty, true},
{"both empty rejected", &empty, nil, &empty, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCardInfo(tt.cardID, tt.savedCardID, tt.newToken)
if tt.wantError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func makePaymentAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, token, userIDOverride string, ctx context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rctx := chi.NewRouteContext()
if id, paramName := extractPaymentIDFromPath(path); id != "" {
rctx.URLParams.Add(paramName, id)
}
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
var userID, userRole string
if userIDOverride != "" {
userID = userIDOverride
userRole = "verified_email"
} else if token != "" {
if info := extractUserFromTestJWT(token); info != nil {
userID = info.userID
userRole = info.role
}
}
if userID != "" {
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, userRole)
}
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler(w, req)
return w
}
type paymentUserInfo struct {
userID string
role string
}
func extractUserFromTestJWT(token string) *paymentUserInfo {
parts := splitToken(token)
if len(parts) != 3 {
return nil
}
decoded, err := base64URLDecode(parts[1])
if err != nil {
return nil
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil
}
userID, _ := claims["user_id"].(string)
role, _ := claims["role"].(string)
if userID == "" {
return nil
}
return &paymentUserInfo{userID: userID, role: role}
}
func splitToken(token string) []string {
var result []string
var current []byte
for _, c := range token {
if c == '.' {
result = append(result, string(current))
current = nil
} else {
current = append(current, byte(c))
}
}
if len(current) > 0 {
result = append(result, string(current))
}
return result
}
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
func extractPaymentIDFromPath(path string) (string, string) {
patterns := []struct {
prefix string
paramName string
}{
{"/api/admin/payments/", "payment_id"},
{"/api/admin/bookings/", "id"},
{"/api/bookings/", "id"},
{"/api/user/payment-methods/", "id"},
}
for _, p := range patterns {
if idx := findPaymentLastSegment(path, p.prefix); idx >= 0 {
endIdx := len(path)
for i := idx; i < len(path); i++ {
if path[i] == '/' {
endIdx = i
break
}
}
return path[idx:endIdx], p.paramName
}
}
return "", ""
}
func findPaymentLastSegment(path, prefix string) int {
for i := len(path) - 1; i >= len(prefix); i-- {
if len(path) > i && path[i-len(prefix):i] == prefix {
return i
}
}
return -1
}
func parsePaymentResponseBody(w *httptest.ResponseRecorder, dest interface{}) error {
return json.Unmarshal(w.Body.Bytes(), dest)
}
func TestTerminalPayment_HappyPath(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
TipEnabled: true,
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp CheckoutResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.CheckoutID == "" {
t.Error("expected checkout ID to be set")
}
if resp.Status != "PENDING" {
t.Errorf("expected status PENDING, got %s", resp.Status)
}
var count int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 0 {
t.Errorf("expected 0 payments (created on completion), got %d", count)
}
}
func setupTestData(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) {
return setupTestDataAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
}
// setupTestDataPast creates a booking with start_time in the past (1 hour ago)
// to prevent payment-split logic from triggering. Used by tests that verify
// payment sequencing or idempotency rather than deposit allocation.
func setupTestDataPast(t *testing.T, ctx context.Context, q db.Querier) (string, string, string) {
return setupTestDataAtTime(t, ctx, q, clock.Now().Add(-1*time.Hour))
}
func setupTestDataAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string, string) {
userID, err := fixtures.CreateTestUser(q)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(q)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
_, err = q.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to update booking status: %v", err)
}
return userID, bookingID, serviceID
}
func TestTerminalPayment_PriceOverride(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
overrideAmount := int64(3000)
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
OverrideAmount: &overrideAmount,
TipEnabled: false,
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp CheckoutResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
}
func TestTerminalPayment_BookingNotInProgress(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
_ = serviceID
}
func TestTerminalPayment_BookingNotFound(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/non-existent/payment", req, adminToken, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestOnlinePayment_NewCard_Deposit(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: true,
IdempotencyKey: "deposit-key-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.ID == "" {
t.Error("expected payment ID to be set")
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
if resp.Amount != 2500 {
t.Errorf("expected amount 2500, got %d", resp.Amount)
}
var count int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 payment, got %d", count)
}
}
func TestOnlinePayment_SavedCard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
CardID: &cardID,
IdempotencyKey: "saved-card-key-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
}
func TestOnlinePayment_BookingNotOwned(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
otherUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
userToken := jwt.GenerateUserToken(otherUserID)
cardToken := "cnon:test-card-nonce"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "not-owned-key-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetUserPaymentMethods_HasCards(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
_, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_1", "VISA", "1111")
if err != nil {
t.Fatalf("failed to create payment method 1: %v", err)
}
_, err = fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_2", "MASTERCARD", "2222")
if err != nil {
t.Fatalf("failed to create payment method 2: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
handler := GetUserPaymentMethods
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var cards []SavedCard
if err := parsePaymentResponseBody(w, &cards); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(cards) != 2 {
t.Errorf("expected 2 cards, got %d", len(cards))
}
}
func TestDeletePaymentMethod(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_card_delete", "VISA", "9999")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
handler := DeletePaymentMethod
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp["status"] != "deleted" {
t.Errorf("expected status deleted, got %s", resp["status"])
}
}
func TestRefund_FullRefund(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
squarePaymentID := "sqp_test_123"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
req := RefundRequest{
Amount: 5000,
Reason: "customer request",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp RefundResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Amount != 5000 {
t.Errorf("expected amount 5000, got %d", resp.Amount)
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
}
func TestRefund_SameKeyRetry_Dedups(t *testing.T) {
// A retry of the same refund (network timeout, double-click) must not
// create a second Square refund or a second DB row. Retry dedup runs on
// the CLIENT-supplied idempotency key — a UUID generated per refund
// attempt and REUSED on retry (hashed+truncated into the stored key), so
// the same key dedups. A NO-key refund now carries a fresh random fallback
// key per attempt and never dedups against a prior no-key refund — that is
// deliberate (see TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide).
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_dedup' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
req := RefundRequest{
Amount: 5000,
Reason: "customer request",
IdempotencyKey: "refund-same-key-retry-uuid",
}
handler := RefundPayment
// First refund — completes.
w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Same-key retry (identical client key, amount, reason) — must dedup, not
// double-refund.
w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("retry refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Exactly one refund row for this payment.
var refundCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund row (deduped), got %d", refundCount)
}
}
// TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide verifies the P2
// security fix for the DEFAULT (no client key) path: the fallback idempotency
// key is now unique per refund attempt, so two DISTINCT partial refunds of the
// SAME amount against the SAME payment each get their own key and both
// complete. With the old amount-derived default key (paymentID + "-refund-" +
// amount) the second would collide with the first and be silently swallowed by
// the dedup lookup.
func TestRefund_TwoEqualPartialRefunds_NoClientKey_DoNotCollide(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_no_key_equal_refunds' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
handler := RefundPayment
// Two distinct £20 partial refunds of the same £50 payment, NO client
// idempotency key on either — the exact case that collided on the
// amount-derived default key before the fix.
refund1 := RefundRequest{Amount: 2000, Reason: "partial one"}
w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
refund2 := RefundRequest{Amount: 2000, Reason: "partial two"}
w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund2, adminToken, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("second equal partial refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Both refunds must exist as separate completed rows with DIFFERENT
// fallback keys — the second was NOT swallowed by the first's dedup lookup.
var completedCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&completedCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if completedCount != 2 {
t.Errorf("expected 2 completed refund rows, got %d (second was swallowed!)", completedCount)
}
var distinctKeys int
err = tx.QueryRow(ctx, `SELECT COUNT(DISTINCT idempotency_key) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&distinctKeys)
if err != nil {
t.Fatalf("failed to count distinct refund keys: %v", err)
}
if distinctKeys != 2 {
t.Errorf("expected 2 DISTINCT fallback keys for the two same-amount refunds, got %d", distinctKeys)
}
}
// TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates verifies the P2 fix:
// two DISTINCT partial refunds of the same amount against the same payment
// must both complete. With only the amount-derived key (paymentID-amount) the
// second would collide with the first and be silently swallowed as a dedup.
// A client-supplied idempotency key per attempt disambiguates them, while a
// same-key retry still dedups.
func TestRefund_TwoEqualPartialRefunds_ClientKeyDisambiguates(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_test_equal_refunds' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
handler := RefundPayment
// Two distinct £20 partial refunds of the same £50 payment — the exact
// case that collided on the amount-derived key before the fix.
refund1 := RefundRequest{Amount: 2000, Reason: "partial one", IdempotencyKey: "refund-uuid-1"}
w1 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first refund: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
refund2 := RefundRequest{Amount: 2000, Reason: "partial two", IdempotencyKey: "refund-uuid-2"}
w2 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund2, adminToken, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("second equal partial refund: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Both refunds must exist as separate completed rows — the second was NOT
// swallowed by the first's dedup lookup.
var completedCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&completedCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if completedCount != 2 {
t.Errorf("expected 2 completed refund rows, got %d (second was swallowed!)", completedCount)
}
// Same-key retry of refund1 must STILL dedup (retry semantics preserved).
w3 := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", refund1, adminToken, ctx)
if w3.Code != http.StatusOK {
t.Fatalf("retry of first refund: expected 200, got %d. body: %s", w3.Code, w3.Body.String())
}
var totalCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'completed'`, paymentID).Scan(&totalCount)
if err != nil {
t.Fatalf("failed to query refunds after retry: %v", err)
}
if totalCount != 2 {
t.Errorf("expected still 2 completed refund rows after same-key retry, got %d", totalCount)
}
}
// TestRefund_PendingResume_NewKeyAfterModalReopen verifies the P2 pending-resume
// regression fix: when a refund attempt left a PENDING row (Square call failed
// ambiguously) and the admin reopens the modal — generating a NEW idempotency
// key — the retry must RESUME the pending row with its stored key, not create
// a second pending row. Without the fallback, the sweep would process both
// pending rows and move twice the intended money.
func TestRefund_PendingResume_NewKeyAfterModalReopen(t *testing.T) {
// NOT t.Parallel: it swaps the package-level SquareClient (the counting
// client below), and a concurrent parallel test reading SquareClient would
// observe the swapped instance mid-test.
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed the pending row from the first (failed) attempt. Its stored key is
// the ORIGINAL attempt's UUID; the retry below uses a different one.
origKey := paymentID + "-refund-uuid-A"
var pendingRefundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 20, 'pending', 'customer request', $3, 'manual', NOW())
RETURNING id
`, paymentID, bookingID, origKey).Scan(&pendingRefundID)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
// Swap in a counting client to verify Square is called with the STORED key.
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
handler := RefundPayment
// Retry with a fresh key (modal reopened) — must resume, not duplicate.
newKeyReq := RefundRequest{Amount: 2000, Reason: "customer request", IdempotencyKey: "refund-uuid-B"}
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", newKeyReq, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("retry with new key: expected 200, got %d. body: %s", w.Code, w.Body.String())
}
// Still exactly ONE refund row for the payment — no second pending row.
var refundCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to count refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected exactly 1 refund row (resumed, not duplicated), got %d — double-refund path!", refundCount)
}
// The single row was resumed to completed.
var status string
err = tx.QueryRow(ctx, `SELECT status FROM refunds WHERE id = $1`, pendingRefundID).Scan(&status)
if err != nil {
t.Fatalf("failed to query refund status: %v", err)
}
if status != "completed" {
t.Errorf("expected the pending refund to be resumed to completed, got %q", status)
}
// Square was called with the STORED key (uuid-A), never the new key — so
// Square's key dedup returns the original refund instead of issuing a second.
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call, got %d", len(calls))
}
if calls[0].IdempotencyKey != origKey {
t.Errorf("expected Square call to use the stored key %q, got %q (fresh key would double-refund)", origKey, calls[0].IdempotencyKey)
}
}
// TestRefund_PendingResume_DifferentAmountRejected verifies the P3 hardening:
// when a pending refund exists for the payment but the retry is for a DIFFERENT
// amount (admin changed it after reopening the modal), the request is rejected
// with 409 instead of creating a second pending row — which the sweep would
// otherwise process alongside the first, moving more money than intended.
func TestRefund_PendingResume_DifferentAmountRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_diff_amount' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed a pending £20 refund (first attempt failed ambiguously).
origKey := paymentID + "-refund-2000"
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 20, 'pending', 'customer request', $3, 'manual', NOW())
`, paymentID, bookingID, origKey)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
handler := RefundPayment
// Retry with a DIFFERENT amount (£30) and a fresh key — must be rejected
// with 409, never creating a second pending row.
req := RefundRequest{Amount: 3000, Reason: "customer request", IdempotencyKey: "refund-uuid-B-diff"}
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusConflict {
t.Fatalf("different-amount retry while pending: expected 409, got %d. body: %s", w.Code, w.Body.String())
}
// Still exactly ONE pending refund row — no second row was created.
var pendingCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1 AND status = 'pending'`, paymentID).Scan(&pendingCount)
if err != nil {
t.Fatalf("failed to count pending refunds: %v", err)
}
if pendingCount != 1 {
t.Errorf("expected exactly 1 pending refund row, got %d (second pending row would double-refund)", pendingCount)
}
}
func TestRefund_PartialRefund(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
squarePaymentID := "sqp_test_456"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
req := RefundRequest{
Amount: 2500,
Reason: "partial refund",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp RefundResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.Amount != 2500 {
t.Errorf("expected amount 2500, got %d", resp.Amount)
}
}
func TestRefund_OverRefundRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
squarePaymentID := "sqp_test_789"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePaymentID, paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
req := RefundRequest{
Amount: 6000,
Reason: "over refund attempt",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PaymentNotFound(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
adminToken := jwt.GenerateAdminToken()
req := RefundRequest{
Amount: 1000,
Reason: "test",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/non-existent/refund", req, adminToken, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PendingPaymentRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "pending")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
req := RefundRequest{
Amount: 5000,
Reason: "test",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PendingSameKeyRetry_Resumes(t *testing.T) {
// A retry of an in-flight refund (DB row pending, Square call never
// completed) must resume rather than insert a second row or issue a second
// Square refund. The handler retries Square with the same idempotency key
// and completes the pending row.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_pending_resume' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed a pending refund with the key the handler derives from the client
// key below (sha256-hashed + truncated to 24 hex chars). The retry sends
// the SAME client key, so the exact-key dedup resumes the row.
clientKey := "refund-pending-resume-uuid"
ikHash := sha256.Sum256([]byte(clientKey))
key := paymentID + "-refund-" + fmt.Sprintf("%x", ikHash)[:24]
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_at)
VALUES ($1, $2, 25, 'pending', 'customer request', $3, NOW())
`, paymentID, bookingID, key)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
req := RefundRequest{
Amount: 2500,
Reason: "customer request",
IdempotencyKey: clientKey,
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// The pending row must now be completed with a Square refund ID — and no
// second refund row inserted.
var refundCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to query refunds: %v", err)
}
if refundCount != 1 {
t.Errorf("expected 1 refund row (resumed, not duplicated), got %d", refundCount)
}
var status string
var squareRefundID *string
err = tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE idempotency_key = $1`, key).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected status completed, got %q", status)
}
if squareRefundID == nil || *squareRefundID == "" {
t.Error("expected square_refund_id to be set after resume")
}
}
func TestRefund_GuardCountsPendingRefunds(t *testing.T) {
// A pending refund (in-flight Square call) blocks any further manual refund
// of the payment: same amount resumes it, a different amount is rejected
// with 409 (in-flight guard) — so the over-refund guard can never be
// bypassed by piling a second pending row on top of an unresolved one.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminToken := jwt.GenerateAdminToken()
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_guard_pending' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to update payment: %v", err)
}
// Seed a pending refund of £70 (as if a previous attempt's Square call is in flight).
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 70, 'pending', 'in flight', NOW())
`, paymentID, bookingID)
if err != nil {
t.Fatalf("failed to seed pending refund: %v", err)
}
// A further £40 refund would total £110 > £100. The in-flight guard rejects
// it with 409 before the over-refund guard is even reached — the pending
// row's money state is unknown, so no new refund is issued.
req := RefundRequest{
Amount: 4000,
Reason: "over refund attempt",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 (in-flight guard), got %d. body: %s", w.Code, w.Body.String())
}
}
func TestRefund_PaymentAlreadyRefunded_MarksCompleted(t *testing.T) {
// Square reports PAYMENT_ALREADY_REFUNDED — the money has already moved.
// The refund row must resolve to 'completed' (NOT 'failed', which would let
// the over-refund guard re-issue money on top of it), square_refund_id
// stays NULL, and the API returns 200.
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_already_refunded' WHERE id = $1", paymentID)
if err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
mock.FailRefundCode = "PAYMENT_ALREADY_REFUNDED"
SquareClient = mock
defer func() { SquareClient = origClient }()
req := RefundRequest{
Amount: 5000,
Reason: "customer request",
}
handler := RefundPayment
w := makePaymentRequest(handler, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp RefundResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected response status 'completed', got %q", resp.Status)
}
var status string
var squareRefundID *string
err = tx.QueryRow(ctx,
"SELECT status, square_refund_id FROM refunds WHERE payment_id = $1", paymentID).Scan(&status, &squareRefundID)
if err != nil {
t.Fatalf("failed to query refund: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
if squareRefundID != nil && *squareRefundID != "" {
t.Errorf("expected square_refund_id NULL (no new refund issued), got %q", *squareRefundID)
}
}
func TestTipPayment_HappyPath(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:tip-card"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "tip" {
t.Errorf("expected payment type tip, got %s", resp.PaymentType)
}
if resp.Amount != 500 {
t.Errorf("expected amount 500, got %d", resp.Amount)
}
}
func TestTipPayment_NoPriorPayment(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:tip-card"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestIdempotency_SameKeyReturnsExisting(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:idempotent-card"
idempotencyKey := "idempotent-same-key"
req1 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: idempotencyKey,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
if w1.Code != http.StatusOK {
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
var resp1 PaymentResponse
if err := parsePaymentResponseBody(w1, &resp1); err != nil {
t.Errorf("failed to parse first response: %v", err)
}
req2 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: idempotencyKey,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
if w2.Code != http.StatusOK {
t.Errorf("second request expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var resp2 PaymentResponse
if err := parsePaymentResponseBody(w2, &resp2); err != nil {
t.Errorf("failed to parse second response: %v", err)
}
if resp1.ID != resp2.ID {
t.Errorf("expected same payment ID, got %s and %s", resp1.ID, resp2.ID)
}
var count int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 payment (idempotent), got %d", count)
}
}
func TestIdempotency_DifferentKeyCreatesNew(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:different-key-card"
req1 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "key-1",
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
if w1.Code != http.StatusOK {
t.Errorf("first request expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
req2 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "key-2",
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
// The second request is blocked because only one "full" payment is
// allowed per booking (the payment-type duplicate guard prevents the
// two-tab double-payment race even when idempotency keys differ).
if w2.Code != http.StatusConflict {
t.Errorf("second request expected status 409, got %d. body: %s", w2.Code, w2.Body.String())
}
var count int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 payment (second was blocked), got %d", count)
}
}
// =============================================================================
// Payment-type duplicate guard — serialization lock prevents double payments
// =============================================================================
func TestCreateBookingPayment_DifferentPaymentTypesAllowed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
// First: a deposit payment should succeed.
cardToken := "cnon:diff-type-card"
depositReq := CreateBookingPaymentRequest{
Amount: 2000,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "diff-type-deposit-" + bookingID,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", depositReq, userToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("deposit payment expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Second: a balance payment uses a different payment_type — should also succeed.
balanceReq := CreateBookingPaymentRequest{
Amount: 3000,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "diff-type-balance-" + bookingID,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", balanceReq, userToken, ctx)
if w2.Code != http.StatusOK {
t.Errorf("balance payment expected 200 (different type allowed), got %d. body: %s", w2.Code, w2.Body.String())
}
// Verify at least one payment of each type exists. buildSplitRecords may
// create extra records (e.g. a 'balance' portion alongside 'deposit'), so
// we check DISTINCT types rather than a raw row count.
var distinctTypes []string
rows, err := tx.Query(ctx,
"SELECT DISTINCT payment_type FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY payment_type",
bookingID)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
defer rows.Close()
for rows.Next() {
var pt string
if err := rows.Scan(&pt); err == nil {
distinctTypes = append(distinctTypes, pt)
}
}
if len(distinctTypes) < 2 {
t.Errorf("expected at least 2 distinct payment types, got %d: %v", len(distinctTypes), distinctTypes)
}
}
func TestCreateBookingPayment_DuplicateTypeBlocked(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
cardToken := "cnon:dup-type-card"
// First 'full' payment succeeds.
req1 := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "dup-type-first-" + bookingID,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first payment expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Second 'full' payment with a different idempotency key should be blocked
// by the payment-type duplicate guard.
req2 := CreateBookingPaymentRequest{
Amount: 2000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "dup-type-second-" + bookingID,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
if w2.Code != http.StatusConflict {
t.Errorf("duplicate 'full' payment expected 409, got %d. body: %s", w2.Code, w2.Body.String())
}
// Verify only one real payment was created. buildSplitRecords converts the
// first 'full' payment into 'deposit' + 'balance', so we count deposit records
// rather than 'full' — the exact guard above confirmed the 409 rejection.
var depositCount int
err := tx.QueryRow(ctx,
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type = 'deposit' AND payment_method NOT IN ('discount', 'on_the_house')",
bookingID).Scan(&depositCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if depositCount != 1 {
t.Errorf("expected 1 deposit record (split from first 'full' payment), got %d", depositCount)
}
}
func TestCreateBookingPayment_MultiplePartialAllowed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
cardToken := "cnon:partial-card"
// First partial payment.
req1 := CreateBookingPaymentRequest{
Amount: 1000,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-first-" + bookingID,
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first partial expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Second partial payment (different key, same type) — allowed because
// the duplicate guard explicitly exempts 'partial'.
req2 := CreateBookingPaymentRequest{
Amount: 1500,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-second-" + bookingID,
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
if w2.Code != http.StatusOK {
t.Errorf("second partial expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Count all real payments (buildSplitRecords converts partials to deposit
// when within the 50% deposit cap). Both should have been created.
var total int
err := tx.QueryRow(ctx,
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')",
bookingID).Scan(&total)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if total != 2 {
t.Errorf("expected 2 payments (both created), got %d", total)
}
}
// TestBookingPayment_NoClientKey_RefundThenRepaySameAmount_CreatesNewCharge is
// the money-safety regression for the deterministic no-client-key fallback key
// (finding a-i): pay £50, refund it, then pay £50 again with NO client
// idempotency key. The second payment MUST be a NEW charge — the dedup lookup
// must not return the refunded (but still status='completed') payment as
// success, which would silently swallow the second payment while the booking
// shows paid with no money collected. The deterministic derivation rotates the
// key past the refunded payment's spent slot instead.
func TestBookingPayment_NoClientKey_RefundThenRepaySameAmount_CreatesNewCharge(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// A past booking keeps the payment unsplit (single record) and 'partial'
// is the repeatable payment type, so the same-type duplicate guard does
// not interfere with the re-pay. A SECOND £50 service is linked so the
// booking total is £100: the first £50 payment then does not auto-complete
// the booking, and a £50 re-pay after the refund stays within the
// remaining balance (refunds re-open booking capacity).
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
secondServiceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
_, err = tx.Exec(ctx, `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, bookingID, secondServiceID)
require.NoError(t, err)
userToken := jwt.GenerateUserToken(userID)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
handler := CreateBookingPayment
cardToken := "cnon:refund-repay-card"
// NO IdempotencyKey — exercises the deterministic booking+type+amount+card fallback.
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "partial",
NewCardToken: &cardToken,
}
// 1. Pay £50 — completes.
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w1.Code, "first payment: %s", w1.Body.String())
var resp1 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w1, &resp1))
// 2. Refund the full £50 via the admin refund handler (the dev mock
// completes the Square refund synchronously).
refundReq := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "refund-repay-" + bookingID}
wRefund := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+resp1.ID+"/refund", refundReq, adminToken, ctx)
require.Equal(t, http.StatusOK, wRefund.Code, "refund: %s", wRefund.Body.String())
// 3. Pay £50 again — same no-key derivation. Must be a NEW charge, not a
// dedup to the refunded first payment.
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "repay: %s", w2.Body.String())
var resp2 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w2, &resp2))
require.NotEqual(t, resp1.ID, resp2.ID, "refund-then-repay must create a NEW payment, not return the refunded payment as success")
// Exactly two completed real payments with distinct idempotency keys and
// distinct Square charges.
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')`, bookingID).Scan(&payCount))
require.Equal(t, 2, payCount, "refund-then-repay must record two distinct payments")
var keys, sqIDs []string
rows, err := tx.Query(ctx, `SELECT idempotency_key, square_payment_id FROM payments WHERE booking_id = $1 AND status = 'completed' ORDER BY created_at ASC`, bookingID)
require.NoError(t, err)
for rows.Next() {
var k, s string
require.NoError(t, rows.Scan(&k, &s))
keys = append(keys, k)
sqIDs = append(sqIDs, s)
}
rows.Close()
require.Equal(t, 2, len(keys))
require.NotEqual(t, keys[0], keys[1], "the two payments must use distinct idempotency keys")
require.NotEqual(t, sqIDs[0], sqIDs[1], "the second payment must be a new Square charge, not a dedup of the refunded one")
}
// TestBookingPayment_NoClientKey_TwoEqualPartials_DoNotCollapse is the
// money-safety regression for the deterministic no-client-key fallback key
// (finding a-ii): two genuine equal-amount partial payments on the same card
// must both be recorded. The deterministic key derivation includes a sequence
// that advances past the first completed equal partial, so the second derives
// a DISTINCT key instead of silently collapsing onto the first.
func TestBookingPayment_NoClientKey_TwoEqualPartials_DoNotCollapse(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
handler := CreateBookingPayment
cardToken := "cnon:equal-partial-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "partial",
NewCardToken: &cardToken,
}
for i := 0; i < 2; i++ {
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "partial %d: %s", i, w.Body.String())
}
var keys []string
rows, err := tx.Query(ctx, `SELECT idempotency_key FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') ORDER BY created_at ASC`, bookingID)
require.NoError(t, err)
for rows.Next() {
var k string
require.NoError(t, rows.Scan(&k))
keys = append(keys, k)
}
rows.Close()
require.Equal(t, 2, len(keys), "two genuine equal-amount partials must both be recorded")
require.NotEqual(t, keys[0], keys[1], "equal-amount partials must not collapse onto one idempotency key")
}
// TestBookingPayment_NoClientKey_SameDepositTwice_StillDedups pins the
// double-charge protection that MUST survive the sequence fix: paying the same
// 50% deposit twice on an UN-refunded booking with no client key must dedup to
// the existing completed payment — never a second Square charge. The
// deterministic derivation advances its sequence only past refunded/partial
// slots; an un-refunded 'deposit' slot keeps its key so the dedup lookup
// returns the original payment.
func TestBookingPayment_NoClientKey_SameDepositTwice_StillDedups(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
handler := CreateBookingPayment
cardToken := "cnon:same-deposit-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
}
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w1.Code, "first deposit: %s", w1.Body.String())
var resp1 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w1, &resp1))
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w2.Code, "second deposit: %s", w2.Body.String())
var resp2 PaymentResponse
require.NoError(t, parsePaymentResponseBody(w2, &resp2))
require.Equal(t, resp1.ID, resp2.ID, "the same 50%% deposit charged twice on an un-refunded booking must DEDUP, not double-charge")
var count int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')`, bookingID).Scan(&count))
require.Equal(t, 1, count, "only ONE deposit payment may be recorded")
}
// ============================================================
// User Booking Payment Tests — deposit, full, partial, balance
// ============================================================
func setupDepositBooking(t *testing.T, ctx context.Context, q db.Querier) (string, string) {
return setupDepositBookingAtTime(t, ctx, q, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
}
// setupDepositBookingPast creates a confirmed booking with start_time in the past
// (1 hour ago). This prevents the payment-split logic from triggering, which is
// useful for tests that verify payment sequencing rather than deposit splitting.
func setupDepositBookingPast(t *testing.T, ctx context.Context, q db.Querier) (string, string) {
return setupDepositBookingAtTime(t, ctx, q, clock.Now().Add(-1*time.Hour))
}
func setupDepositBookingAtTime(t *testing.T, ctx context.Context, q db.Querier, startTime time.Time) (string, string) {
userID, err := fixtures.CreateTestUser(q)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(q)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(q, userID, serviceID, startTime)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
_, err = q.Exec(ctx,
"UPDATE bookings SET status = 'confirmed', deposit_required = TRUE WHERE id = $1",
bookingID)
if err != nil {
t.Fatalf("failed to update booking: %v", err)
}
return userID, bookingID
}
func TestBookingPayment_Deposit_HappyPath(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:deposit-card"
req := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
SaveCard: false,
IdempotencyKey: "deposit-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "deposit" {
t.Errorf("expected payment type deposit, got %s", resp.PaymentType)
}
if resp.Amount != 2500 {
t.Errorf("expected amount 2500, got %d", resp.Amount)
}
if resp.Status != "completed" {
t.Errorf("expected status completed, got %s", resp.Status)
}
var count int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'deposit'", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 deposit payment, got %d", count)
}
}
func TestBookingPayment_FullPayment(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:full-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "full-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "full" {
t.Errorf("expected payment type full, got %s", resp.PaymentType)
}
if resp.Amount != 5000 {
t.Errorf("expected amount 5000, got %d", resp.Amount)
}
}
func TestBookingPayment_PartialPayment(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:partial-card"
req := CreateBookingPaymentRequest{
Amount: 1500,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "partial" {
t.Errorf("expected payment type partial, got %s", resp.PaymentType)
}
if resp.Amount != 1500 {
t.Errorf("expected amount 1500, got %d", resp.Amount)
}
}
func TestBookingPayment_BalancePayment(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:balance-card"
req := CreateBookingPaymentRequest{
Amount: 3500,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "balance-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if resp.PaymentType != "balance" {
t.Errorf("expected payment type balance, got %s", resp.PaymentType)
}
}
// ---------------------------------------------------------------------------
// Payment-split tests — verify that a single Square charge is recorded as
// multiple payment rows when paid before the booking start time, and that
// both records share the same square_payment_id.
// ---------------------------------------------------------------------------
func TestBookingPayment_FullPayment_SplitsIntoDepositAndBalance(t *testing.T) {
// A full payment of £50 on a £50 booking (future-dated) should be split:
// record 1: payment_type='deposit', amount=25.00
// record 2: payment_type='balance', amount=25.00
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:split-full-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "split-full-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Should be exactly 2 payment records. Order by payment_type (the enum
// defines 'deposit' before 'balance', so the deposit row is first) plus
// created_at as a tiebreaker — ORDER BY amount was nondeterministic here
// because both split amounts are equal (£25.00), which flaked ~1-in-10
// runs asserting records[0] is the deposit.
rows, err := tx.Query(ctx,
`SELECT payment_type, amount, square_payment_id
FROM payments WHERE booking_id = $1 ORDER BY payment_type, created_at ASC`, bookingID)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
defer rows.Close()
var records []struct {
ptype string
amount float64
squarePaymentID *string
}
for rows.Next() {
var r struct {
ptype string
amount float64
squarePaymentID *string
}
if err := rows.Scan(&r.ptype, &r.amount, &r.squarePaymentID); err != nil {
t.Fatalf("failed to scan row: %v", err)
}
records = append(records, r)
}
if len(records) != 2 {
t.Fatalf("expected 2 split records, got %d", len(records))
}
// First record should be the deposit portion (larger or equal — deposit is 25, balance is 25).
if records[0].ptype != "deposit" {
t.Errorf("expected first record to be 'deposit', got %q", records[0].ptype)
}
// Second record should be balance.
if records[1].ptype != "balance" {
t.Errorf("expected second record to be 'balance', got %q", records[1].ptype)
}
// Both records must share the same square_payment_id.
if records[0].squarePaymentID == nil || records[1].squarePaymentID == nil {
t.Error("both records should have a square_payment_id")
} else if *records[0].squarePaymentID != *records[1].squarePaymentID {
t.Errorf("expected same square_payment_id, got %q and %q",
*records[0].squarePaymentID, *records[1].squarePaymentID)
}
}
func TestBookingPayment_FullPayment_PastBooking_DoesNotSplit(t *testing.T) {
// A full payment on a PAST booking should NOT split (single record).
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBookingPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:nosplit-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "nosplit-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var count int
err := tx.QueryRow(ctx,
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Fatalf("failed to query payments: %v", err)
}
if count != 1 {
t.Errorf("expected 1 payment (no-split), got %d", count)
}
}
func TestBookingPayment_TransactionAtomicity_SplitRollsBackOnError(t *testing.T) {
// Verify that when the split-record insert fails, the entire group rolls
// back atomically. We simulate a failure by causing the second INSERT to
// violate a NOT NULL constraint (passing an invalid record).
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID := setupDepositBooking(t, ctx, tx)
// Use a nil idempotency key on the split record — this works fine for both.
// Instead we rely on the fact that the handler wraps both inserts in a
// single transaction: if either fails, neither survives.
//
// Because we can't easily inject a DB error through the handler, we verify
// the architecture at the service level instead:
innerTx, err := db.Conn.Begin(ctx)
if err != nil {
t.Fatalf("failed to begin tx: %v", err)
}
defer innerTx.Rollback(ctx)
svc := NewPaymentService()
now := clock.Now()
// First record — valid.
pid1, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{
BookingID: bookingID,
PaymentType: "deposit",
PaymentMethod: "cash",
Status: "completed",
Amount: 25.00,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create first payment record: %v", err)
}
if pid1 == "" {
t.Fatal("expected non-empty payment id")
}
// Second record — also valid.
pid2, err := svc.CreatePaymentRecordTx(ctx, innerTx, PaymentRecord{
BookingID: bookingID,
PaymentType: "balance",
PaymentMethod: "cash",
Status: "completed",
Amount: 25.00,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create second payment record: %v", err)
}
if pid2 == "" {
t.Fatal("expected non-empty payment id")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit tx: %v", err)
}
// Both records should exist.
var count int
tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1 OR id = $2", pid1, pid2).Scan(&count)
if count != 2 {
t.Errorf("expected 2 committed records, got %d", count)
}
// Now test rollback: start a new inner tx, insert, then rollback.
tx2, err := db.Conn.Begin(ctx)
if err != nil {
t.Fatalf("failed to begin tx2: %v", err)
}
pid3, err := svc.CreatePaymentRecordTx(ctx, tx2, PaymentRecord{
BookingID: bookingID,
PaymentType: "deposit",
PaymentMethod: "cash",
Status: "completed",
Amount: 10.00,
CreatedAt: now,
UpdatedAt: now,
}, nil)
if err != nil {
t.Fatalf("failed to create rolled-back record: %v", err)
}
tx2.Rollback(ctx)
// Rolled-back record should NOT exist.
var rollbackCount int
tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", pid3).Scan(&rollbackCount)
if rollbackCount != 0 {
t.Errorf("expected 0 records after rollback, got %d", rollbackCount)
}
}
// ---------------------------------------------------------------------------
// buildSplitRecords unit tests — pure function, no DB needed.
// ---------------------------------------------------------------------------
func makeTestRecord(bookingID, ptype string, amount float64) PaymentRecord {
now := clock.Now()
key := "test-key"
return PaymentRecord{
BookingID: bookingID,
PaymentType: ptype,
PaymentMethod: "online_square",
Status: "completed",
Amount: amount,
SquarePaymentID: strPtr("sq_test"),
IdempotencyKey: &key,
Fees: 1.50,
CreatedAt: now,
UpdatedAt: now,
}
}
func strPtr(s string) *string { return &s }
func TestBuildSplitRecords_FutureBooking_FullPayment_Splits(t *testing.T) {
// £50 payment on a £50 future booking → splits into deposit £25 + balance £25
record := makeTestRecord("b1", "full", 50)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "full", info, 50)
require.NoError(t, err)
if len(records) != 2 {
t.Fatalf("expected 2 records, got %d", len(records))
}
if records[0].PaymentType != "deposit" {
t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType)
}
if records[0].Amount != 25 {
t.Errorf("expected first record amount 25, got %.2f", records[0].Amount)
}
if records[1].PaymentType != "balance" {
t.Errorf("expected second record 'balance', got %q", records[1].PaymentType)
}
if records[1].Amount != 25 {
t.Errorf("expected second record amount 25, got %.2f", records[1].Amount)
}
// Both share the same SquarePaymentID.
if *records[0].SquarePaymentID != *records[1].SquarePaymentID {
t.Error("split records must share square_payment_id")
}
// Split record has separate idempotency key.
if *records[1].IdempotencyKey != *records[0].IdempotencyKey+"-split-1" {
t.Errorf("split key should be derived, got %q", *records[1].IdempotencyKey)
}
// Split record has zero fees (all on primary).
if records[1].Fees != 0 {
t.Errorf("expected split fees=0, got %.2f", records[1].Fees)
}
}
func TestBuildSplitRecords_PastBooking_NoSplit(t *testing.T) {
// Same amount on a PAST booking → single record
record := makeTestRecord("b2", "full", 50)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(-2 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "full", info, 50)
require.NoError(t, err)
if len(records) != 1 {
t.Fatalf("expected 1 record (no split), got %d", len(records))
}
if records[0].PaymentType != "full" {
t.Errorf("expected 'full', got %q", records[0].PaymentType)
}
}
func TestBuildSplitRecords_DepositWithinCap_NoSplit(t *testing.T) {
// £20 deposit on a £50 total (40% < 50% cap) → single deposit record
record := makeTestRecord("b3", "deposit", 20)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "deposit", info, 20)
require.NoError(t, err)
if len(records) != 1 {
t.Fatalf("expected 1 record (within cap), got %d", len(records))
}
if records[0].PaymentType != "deposit" {
t.Errorf("expected 'deposit', got %q", records[0].PaymentType)
}
}
func TestBuildSplitRecords_PaymentLessThanDepositMax_NoSplit(t *testing.T) {
// £25 on a £100 total (25% < 50% cap) → single deposit record
record := makeTestRecord("b4", "deposit", 25)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 100,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "deposit", info, 25)
require.NoError(t, err)
if len(records) != 1 {
t.Fatalf("expected 1 record (under 50%%), got %d", len(records))
}
}
func TestBuildSplitRecords_OverflowBeyondTotal_BecomesTip(t *testing.T) {
// £60 on a £50 future booking: deposit caps at £25 (50%), balance covers
// the remaining £25 owed, and the £10 overflow beyond the booking total
// becomes a tip record.
record := makeTestRecord("b5", "full", 60)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "full", info, 60)
require.NoError(t, err)
if len(records) != 3 {
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
}
if records[0].PaymentType != "deposit" {
t.Errorf("expected first record 'deposit', got %q", records[0].PaymentType)
}
if records[0].Amount != 25 {
t.Errorf("expected deposit amount 25, got %.2f", records[0].Amount)
}
if records[1].PaymentType != "balance" {
t.Errorf("expected second record 'balance', got %q", records[1].PaymentType)
}
if records[1].Amount != 25 {
t.Errorf("expected balance amount 25, got %.2f", records[1].Amount)
}
if records[2].PaymentType != "tip" {
t.Errorf("expected third record 'tip', got %q", records[2].PaymentType)
}
if records[2].Amount != 10 {
t.Errorf("expected tip amount 10 (overflow beyond the booking total), got %.2f", records[2].Amount)
}
// The tip is a split: zero fees and a derived idempotency key.
if records[2].Fees != 0 {
t.Errorf("expected tip fees=0, got %.2f", records[2].Fees)
}
if *records[2].IdempotencyKey != *records[0].IdempotencyKey+"-split-3" {
t.Errorf("expected tip key derived from primary, got %q", *records[2].IdempotencyKey)
}
// The splits share the Square payment id of the primary record.
if *records[2].SquarePaymentID != *records[0].SquarePaymentID {
t.Error("tip split must share square_payment_id with the primary record")
}
}
// TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge
// proves the money invariant documented on buildSplitRecords: the split
// records (deposit + balance + tip) ALWAYS partition the charged amount
// exactly, so their sum can never exceed what Square actually charged — even
// when a discount is present. A discount reduces what the customer owes but is
// booked as a SEPARATE ledger row (payment_method='discount') that
// GetBookingPaymentInfo excludes from TotalPaid, so buildSplitRecords runs
// against the full booking total and never inflates the split sum past the
// charged pence.
func TestBuildSplitRecords_DiscountBooking_TipOverflow_SumNeverExceedsCharge(t *testing.T) {
// Discounted booking: £100 total with a £10 discount (the discount row
// exists in the ledger but is excluded from TotalPaid). Tip overflow
// charges over and above the booking total.
record := makeTestRecord("b-disc-tip", "full", 120)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 100,
TotalPaid: 0, // the £10 discount payment row is excluded here
}
records, err := buildSplitRecords(record, "full", info, 120)
require.NoError(t, err)
var sum float64
for _, r := range records {
sum += r.Amount
}
// Partition invariant: the sum equals the charged amount exactly — it can
// never exceed it, no matter how the tip overflows.
if sum > 120 {
t.Errorf("split records sum to %.2f, exceeding the charged amount £120", sum)
}
if math.Abs(sum-120) > 0.005 {
t.Errorf("expected split records to partition the charged £120 exactly, got sum %.2f (records: %+v)", sum, records)
}
// Same invariant with real money already paid toward the booking: the
// deposit room is consumed, but the sum still partitions the new charge.
record2 := makeTestRecord("b-disc-tip-2", "full", 70)
info2 := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 100,
TotalPaid: 60,
}
records2, err2 := buildSplitRecords(record2, "full", info2, 70)
require.NoError(t, err2)
var sum2 float64
for _, r := range records2 {
sum2 += r.Amount
}
if sum2 > 70 {
t.Errorf("split records sum to %.2f, exceeding the charged amount £70", sum2)
}
if math.Abs(sum2-70) > 0.005 {
t.Errorf("expected split records to partition the charged £70 exactly, got sum %.2f (records: %+v)", sum2, records2)
}
}
// TestGetBookingPaymentInfo_ExcludesDiscountPayments proves the database half
// of the invariant: GetBookingPaymentInfo.TotalPaid excludes discount payment
// rows, so a discounted booking never inflates TotalPaid (which would shrink
// the tip carve-out / grow the balance allocation into an over-recorded total).
func TestGetBookingPaymentInfo_ExcludesDiscountPayments(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
require.NoError(t, err)
// £60 of real money plus a £10 discount payment row (payment_method='discount').
_, err = fixtures.CreateTestPayment(tx, bookingID, 60.00, "online_square", "full", "completed")
require.NoError(t, err)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', 10.00, 'completed', $2)
`, bookingID, userID)
require.NoError(t, err)
svc := NewPaymentService()
info, err := svc.GetBookingPaymentInfo(ctx, bookingID)
require.NoError(t, err)
if info.TotalPaid != 60.00 {
t.Errorf("expected TotalPaid to exclude the £10 discount row, got %.2f", info.TotalPaid)
}
// A £70 tip-overflow charge on top must partition exactly into £70 of split
// records — the discount can never push the recorded sum past the charge.
record := makeTestRecord(bookingID, "full", 70)
records, err := buildSplitRecords(record, "full", info, 70)
require.NoError(t, err)
var sum float64
for _, r := range records {
sum += r.Amount
}
if sum > 70 {
t.Errorf("split records sum to %.2f, exceeding the charged amount £70", sum)
}
if math.Abs(sum-70) > 0.005 {
t.Errorf("expected split records to partition the charged £70 exactly, got sum %.2f", sum)
}
}
// TestBuildSplitRecords_DepositCarve_PenceExact pins the 50% deposit carve to
// exact pence outcomes (money-safety audit): the carve rounds ONCE at the
// pence boundary and the parts always partition the charged amount exactly, so
// the recorded pence can never exceed what Square actually charged. The
// tip-percentage math (tip% × subtotal) lives in the frontend — the backend
// receives the charged total — so this test pins the backend's deposit carve,
// the only percentage-derived math in the charge-splitting path.
func TestBuildSplitRecords_DepositCarve_PenceExact(t *testing.T) {
cases := []struct {
name string
total float64 // booking total, pounds
paid float64 // already paid, pounds
chargedPence int64 // the charge, int64 pence as Square reports it
wantDeposit int64 // expected deposit carve, pence
wantBalance int64 // expected balance portion, pence
wantTip int64 // expected tip portion, pence
}{
{"£50 charge on £50 booking → 25.00 + 25.00", 50, 0, 5000, 2500, 2500, 0},
{"£60 charge on £50 booking → deposit + balance + tip overflow", 50, 0, 6000, 2500, 2500, 1000},
{"£25 charge on £100 booking (under the 50% cap) → all deposit", 100, 0, 2500, 2500, 0, 0},
{"£60 charge on £100 booking → 50.00 deposit + 10.00 balance", 100, 0, 6000, 5000, 1000, 0},
{"£100 charge on £100 booking → 50.00 + 50.00", 100, 0, 10000, 5000, 5000, 0},
{"£50 charge on £100 booking with £30 already paid → deposit fills to cap", 100, 30, 5000, 2000, 3000, 0},
{"£12.34 charge on £25.00 booking → all deposit", 25, 0, 1234, 1234, 0, 0},
{"£25 charge on £25.50 booking → 12.75 deposit + 12.25 balance (half-penny carve)", 25.50, 0, 2500, 1275, 1225, 0},
{"£45.67 charge on £50 booking → 25.00 + 20.67", 50, 0, 4567, 2500, 2067, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
chargedPounds := float64(tc.chargedPence) / 100.0
record := makeTestRecord("pence-booking", "full", chargedPounds)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: tc.total,
TotalPaid: tc.paid,
}
records, err := buildSplitRecords(record, "full", info, chargedPounds)
require.NoError(t, err)
var depositPence, balancePence, tipPence, partitionPence int64
for _, r := range records {
pence := int64(math.Round(r.Amount * 100))
partitionPence += pence
switch r.PaymentType {
case "deposit":
depositPence = pence
case "tip":
tipPence = pence
default:
// The booking-portion remainder after the deposit carve.
// Its TYPE is dynamic ('balance' when the payment reaches
// the booking total, 'partial' when it does not); the
// pence are what this table pins.
balancePence = pence
}
}
if depositPence != tc.wantDeposit {
t.Errorf("deposit carve = %d pence, want %d", depositPence, tc.wantDeposit)
}
if balancePence != tc.wantBalance {
t.Errorf("balance portion = %d pence, want %d", balancePence, tc.wantBalance)
}
if tipPence != tc.wantTip {
t.Errorf("tip portion = %d pence, want %d", tipPence, tc.wantTip)
}
if partitionPence != tc.chargedPence {
t.Errorf("split records partition to %d pence, want the charged %d pence", partitionPence, tc.chargedPence)
}
})
}
}
// ---------------------------------------------------------------------------
// Handler-level atomicity — verify the full handler succeeds with split.
// ---------------------------------------------------------------------------
func TestBookingPayment_HandlerAtomicity_SplitSucceeds(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:atomic-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "atomic-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp PaymentResponse
if err := parsePaymentResponseBody(w, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.ID == "" {
t.Fatal("expected non-empty payment ID")
}
if resp.Amount != 5000 {
t.Errorf("expected amount 5000, got %d", resp.Amount)
}
if resp.PaymentType != "full" {
t.Errorf("expected payment type 'full' in response, got %q", resp.PaymentType)
}
// Verify both split records exist and the total paid is correct.
var recordCount int
tx.QueryRow(ctx,
"SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&recordCount)
if recordCount != 2 {
t.Errorf("expected 2 completed payment records from split, got %d", recordCount)
}
var totalPaid float64
tx.QueryRow(ctx,
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed'", bookingID).Scan(&totalPaid)
if totalPaid != 50.00 {
t.Errorf("expected total paid £50.00, got £%.2f", totalPaid)
}
// Deposit threshold should have been met — verify booking promoted from pending_release.
var status string
tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if status == "pending_release" {
t.Error("expected booking to be promoted from pending_release after payment meets 20% threshold")
}
}
func TestBookingPayment_ZeroAmountRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:zero-card"
req := CreateBookingPaymentRequest{
Amount: 0,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "zero-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_NegativeAmountRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:neg-card"
req := CreateBookingPaymentRequest{
Amount: -100,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "neg-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_InvalidPaymentTypeRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:invalid-type-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "invalid_type",
NewCardToken: &cardToken,
IdempotencyKey: "invalid-type-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_NoAuthRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID := setupDepositBooking(t, ctx, tx)
cardToken := "cnon:no-auth-card"
req := CreateBookingPaymentRequest{
Amount: 5000,
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "no-auth-test-1",
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, "", ctx)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestBookingPayment_DepositFollowedByBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID := setupDepositBooking(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:deposit-balance-card"
req1 := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "deposit",
NewCardToken: &cardToken,
IdempotencyKey: "deposit-balance-1",
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
if w1.Code != http.StatusOK {
t.Errorf("deposit: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
req2 := CreateBookingPaymentRequest{
Amount: 2500,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "deposit-balance-2",
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
if w2.Code != http.StatusOK {
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var count int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 2 {
t.Errorf("expected 2 payments, got %d", count)
}
}
func TestBookingPayment_PartialFollowedByBalance(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Past booking to avoid payment-split; we're testing sequence not deposit allocation.
userID, bookingID := setupDepositBookingPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
cardToken := "cnon:partial-balance-card"
req1 := CreateBookingPaymentRequest{
Amount: 1000,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-balance-1",
}
handler := CreateBookingPayment
w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req1, userToken, ctx)
if w1.Code != http.StatusOK {
t.Errorf("partial: expected status 200, got %d. body: %s", w1.Code, w1.Body.String())
}
req2 := CreateBookingPaymentRequest{
Amount: 4000,
PaymentType: "balance",
NewCardToken: &cardToken,
IdempotencyKey: "partial-balance-2",
}
w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req2, userToken, ctx)
if w2.Code != http.StatusOK {
t.Errorf("balance: expected status 200, got %d. body: %s", w2.Code, w2.Body.String())
}
var count int
err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query payments: %v", err)
}
if count != 2 {
t.Errorf("expected 2 payments, got %d", count)
}
}
func TestTipPayment_WrongOwnerRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestDataPast(t, ctx, tx)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
otherUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
otherToken := jwt.GenerateUserToken(otherUserID)
cardToken := "cnon:wrong-owner-tip"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, otherToken, ctx)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTipPayment_MultipleTipsAllowed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
for i := 0; i < 3; i++ {
cardToken := fmt.Sprintf("cnon:multi-tip-%d", i)
req := CreateTipPaymentRequest{
Amount: int64(200 + i*100),
NewCardToken: &cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("tip %d: expected status 200, got %d. body: %s", i, w.Code, w.Body.String())
}
}
var count int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'", bookingID).Scan(&count)
if err != nil {
t.Errorf("failed to query tip payments: %v", err)
}
if count != 3 {
t.Errorf("expected 3 tip payments, got %d", count)
}
}
func TestTipPayment_WithSavedCard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// Create a completed payment so the tip is allowed.
payID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
squarePayID := "sqp_test_saved_card"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID)
require.NoError(t, err)
// Create a saved card for this user.
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof:mock_saved', 'VISA', '1111', 12, 2030, 'sqfp_mock_saved')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
req := CreateTipPaymentRequest{
Amount: 1000,
CardID: &savedCardID,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
var resp PaymentResponse
require.NoError(t, parsePaymentResponseBody(w, &resp))
assert.Equal(t, "tip", resp.PaymentType)
assert.Equal(t, int64(1000), resp.Amount)
assert.Equal(t, "completed", resp.Status)
assert.NotEmpty(t, resp.CardBrand)
assert.NotEmpty(t, resp.CardLast4)
}
func TestTipPayment_RetryPending_ReattemptsCharge(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// Create a completed payment so the tip is allowed.
payID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
squarePayID := "sqp_test_retry"
_, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", squarePayID, payID)
require.NoError(t, err)
// Create a saved card for this user.
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
// Simulate a failed prior attempt: a PENDING tip record with the same
// idempotency key the frontend will send on retry. The handler must NOT
// short-circuit on this — it must re-attempt the Square charge.
key := "tip-retry-key-123"
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
VALUES ($1, 'tip', 'online_square', 'pending', 10.00, $2, NOW(), NOW(), $3)
`, bookingID, key, userID)
require.NoError(t, err)
req := CreateTipPaymentRequest{
Amount: 1000,
CardID: &savedCardID,
IdempotencyKey: key,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
var resp PaymentResponse
require.NoError(t, parsePaymentResponseBody(w, &resp))
assert.Equal(t, "completed", resp.Status, "retry of a pending record must re-attempt and complete, not return the stale pending record")
// Exactly one payment record for this key, now completed.
var count int
var status string
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM payments WHERE idempotency_key = $1`, key).Scan(&count, &status)
require.NoError(t, err)
assert.Equal(t, 1, count, "must reuse the pending record, not insert a duplicate")
assert.Equal(t, "completed", status)
}
// TestTipPayment_RetryPending_NonExactAmountSucceeds verifies the pending-retry
// amount guard compares pence exactly. £1.14 is stored as the float64
// 1.1399999999999999, so a naive int64(pounds*100) truncation yields 113 and
// falsely rejects a legitimate same-amount retry of 114 pence.
func TestTipPayment_RetryPending_NonExactAmountSucceeds(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
// Create a completed payment so the tip is allowed.
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
// Create a saved card for this user.
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
// Failed prior attempt: a PENDING tip of £1.14 (114 pence) with the same key.
key := "tip-retry-key-114"
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
VALUES ($1, 'tip', 'online_square', 'pending', 1.14, $2, NOW(), NOW(), $3)
`, bookingID, key, userID)
require.NoError(t, err)
req := CreateTipPaymentRequest{
Amount: 114,
CardID: &savedCardID,
IdempotencyKey: key,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
var resp PaymentResponse
require.NoError(t, parsePaymentResponseBody(w, &resp))
assert.Equal(t, "completed", resp.Status, "a same-amount retry of a non-exact pence value must re-attempt and complete")
assert.Equal(t, int64(114), resp.Amount)
// Exactly one payment record for this key, now completed.
var count int
var status string
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM payments WHERE idempotency_key = $1`, key).Scan(&count, &status)
require.NoError(t, err)
assert.Equal(t, 1, count, "must reuse the pending record, not insert a duplicate")
assert.Equal(t, "completed", status)
}
// TestTipPayment_RetryPending_AmountMismatchRejected verifies the amount guard
// still rejects a retry that changes the amount on the same idempotency key.
func TestTipPayment_RetryPending_AmountMismatchRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
var savedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint)
VALUES ($1, 'ccof:mock_retry', 'VISA', '1111', 12, 2030, 'sqfp_mock_retry')
RETURNING id
`, userID).Scan(&savedCardID)
require.NoError(t, err)
// Failed prior attempt: PENDING tip of £10.00 (1000 pence).
key := "tip-retry-key-mismatch"
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at, created_by)
VALUES ($1, 'tip', 'online_square', 'pending', 10.00, $2, NOW(), NOW(), $3)
`, bookingID, key, userID)
require.NoError(t, err)
// Retry with a DIFFERENT amount but the same key → must 400, not charge.
req := CreateTipPaymentRequest{
Amount: 2000,
CardID: &savedCardID,
IdempotencyKey: key,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusBadRequest, w.Code, "a same-key retry with a different amount must be rejected")
// The pending record must be untouched.
var status string
err = tx.QueryRow(ctx, `SELECT status FROM payments WHERE idempotency_key = $1`, key).Scan(&status)
require.NoError(t, err)
assert.Equal(t, "pending", status)
}
func TestTipPayment_TransactionFailure_SkipsSquare(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
cancelCtx, cancel := context.WithCancel(ctx)
cancel()
cardToken := "cnon:tip-card"
req := CreateTipPaymentRequest{
Amount: 500,
NewCardToken: &cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, cancelCtx)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
}
var completedTipCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND status = 'completed'", bookingID).Scan(&completedTipCount)
if err != nil {
t.Errorf("failed to query completed tip payments: %v", err)
}
if completedTipCount != 0 {
t.Errorf("expected 0 completed tip payments, got %d", completedTipCount)
}
}
// TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow verifies the H2
// fix: a no-client-key tip whose Square call failed ambiguously leaves a
// pending row; a retry (still no client key) must derive the SAME deterministic
// key from the booking+amount+card+sequence and reuse that pending row — never
// mint a fresh random key, a fresh pending row, and a second Square charge.
func TestTipPayment_NoClientKey_LostResponseRetryReusesPendingRow(t *testing.T) {
// NOT t.Parallel: it swaps the package-global SquareClient (a ShouldFail
// mock) mid-test, and a concurrent parallel charge test would observe the
// swapped client and fail with a spurious 503.
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
origClient := SquareClient
mc := square.NewDevClient().(*square.MockClient)
mc.ShouldFail = true
SquareClient = mc
defer func() { SquareClient = origClient }()
cardToken := "cnon:no-key-tip-retry"
req := CreateTipPaymentRequest{
Amount: 1000,
NewCardToken: &cardToken,
}
handler := CreateTipPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusServiceUnavailable, w.Code, "first attempt must fail ambiguously: %s", w.Body.String())
// The pending row must exist with a deterministic key.
var pendingKey string
var pendingCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT idempotency_key, COUNT(*) OVER() FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&pendingKey, &pendingCount))
require.Equal(t, 1, pendingCount)
require.NotEmpty(t, pendingKey)
require.Contains(t, pendingKey, bookingID, "the no-client-key fallback must derive a deterministic key from the booking, not a random one")
// Retry with Square healthy: no client key, same amount/card.
mc.ShouldFail = false
w = makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "retry must succeed: %s", w.Body.String())
// Exactly ONE tip payment row, completed, with the SAME key.
var count int
var status string
var storedKey string
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), MAX(idempotency_key) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&count, &status, &storedKey))
require.Equal(t, 1, count, "a no-client-key retry must reuse the pending row, not insert a duplicate")
require.Equal(t, "completed", status)
require.Equal(t, pendingKey, storedKey, "the retry must derive the same deterministic key")
}
// TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse verifies the
// H2 fix's other half: two genuinely distinct identical no-client-key tips on
// the same booking get n=1 and n=2, so they never collapse onto one
// idempotency key (which would silently swallow the second tip).
func TestTipPayment_NoClientKey_DistinctIdenticalTipsDoNotCollapse(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
_, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed")
require.NoError(t, err)
handler := CreateTipPayment
cardToken := "cnon:no-key-tip-dup"
req := CreateTipPaymentRequest{
Amount: 1000,
NewCardToken: &cardToken,
}
for i := 0; i < 2; i++ {
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "tip %d: %s", i, w.Body.String())
}
var keys []string
rows, err := tx.Query(ctx, `SELECT idempotency_key FROM payments WHERE booking_id = $1 AND payment_type = 'tip' ORDER BY created_at`, bookingID)
require.NoError(t, err)
for rows.Next() {
var k string
require.NoError(t, rows.Scan(&k))
keys = append(keys, k)
}
rows.Close()
require.Equal(t, 2, len(keys), "two distinct identical tips must both be recorded")
require.NotEqual(t, keys[0], keys[1], "distinct identical tips must not collapse onto one idempotency key")
}
func TestGetUserPaymentMethods_NoCards(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
handler := GetUserPaymentMethods
w := makePaymentRequest(handler, "GET", "/api/user/payment-methods", nil, userToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var cards []SavedCard
if err := parsePaymentResponseBody(w, &cards); err != nil {
t.Errorf("failed to parse response: %v", err)
}
if len(cards) != 0 {
t.Errorf("expected 0 cards, got %d", len(cards))
}
}
func TestDeletePaymentMethod_WrongOwnerRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_wrong_owner", "VISA", "0000")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
otherUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
otherToken := jwt.GenerateUserToken(otherUserID)
handler := DeletePaymentMethod
w := makePaymentRequest(handler, "DELETE", "/api/user/payment-methods/"+cardID, nil, otherToken, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var count int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND deleted_at IS NULL", cardID).Scan(&count)
if err != nil {
t.Errorf("failed to query card: %v", err)
}
if count != 1 {
t.Error("expected card to still exist (not deleted by wrong owner)")
}
}
func TestValidatePartialAmount(t *testing.T) {
tests := []struct {
name string
amountPence int64
remainingPence int64
expectErr bool
}{
{"valid partial", 500, 1000, false},
{"exact remaining", 1000, 1000, false},
{"exceeds remaining", 1500, 1000, true},
{"zero amount", 0, 1000, true},
{"negative amount", -100, 1000, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePartialAmount(tt.amountPence, tt.remainingPence)
if tt.expectErr && err == nil {
t.Error("expected error, got nil")
}
if !tt.expectErr && err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
func TestGetBookingRemainingBalancePence(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
service := NewPaymentService()
initialRemaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if initialRemaining <= 0 {
t.Fatalf("expected positive remaining balance, got %d", initialRemaining)
}
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "partial",
PaymentMethod: "cash",
Status: "completed",
Amount: 20.00,
}, nil)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
afterPartial, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterPartial != initialRemaining-2000 {
t.Errorf("expected %d pence remaining after £20 payment, got %d", initialRemaining-2000, afterPartial)
}
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "balance",
PaymentMethod: "cash",
Status: "completed",
Amount: float64(afterPartial) / 100.0,
}, nil)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
afterFull, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterFull != 0 {
t.Errorf("expected 0 pence remaining after full payment, got %d", afterFull)
}
}
// TestGetBookingRemainingBalancePence_RefundsReopenCapacity verifies the M-cap
// is refund-aware: a completed refund returns money, so it re-opens booking
// capacity — remaining = total - paid + refunded — while the cap never exceeds
// the booking total.
func TestGetBookingRemainingBalancePence_RefundsReopenCapacity(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
serviceID, err := fixtures.CreateTestService(tx)
require.NoError(t, err)
bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID)
require.NoError(t, err)
var bookingTotal int64
require.NoError(t, tx.QueryRow(ctx, `SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal))
service := NewPaymentService()
// Pay the full booking amount.
_, err = service.CreatePaymentRecord(ctx, PaymentRecord{
BookingID: bookingID,
PaymentType: "full",
PaymentMethod: "online_square",
Status: "completed",
Amount: float64(bookingTotal) / 100.0,
}, nil)
require.NoError(t, err)
remaining, err := service.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, int64(0), remaining, "a fully-paid booking must have 0 remaining")
// Refund half the booking value — capacity must re-open by that amount.
var payRowID string
require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND payment_type = 'full' ORDER BY created_at DESC LIMIT 1`, bookingID).Scan(&payRowID))
refundAmount := bookingTotal / 2
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
VALUES ($1, $2, $3, 'completed', 'test refund', 'manual')
`, payRowID, bookingID, float64(refundAmount)/100.0)
require.NoError(t, err)
remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, refundAmount, remaining, "a completed refund must re-open the remaining balance by its amount")
// The cap must never exceed the booking total even if refunds exceed payments.
_, err = tx.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin)
VALUES ($1, $2, $3, 'completed', 'over-refund test', 'manual')
`, payRowID, bookingID, bookingTotal)
require.NoError(t, err)
remaining, err = service.GetBookingRemainingBalancePence(ctx, bookingID)
require.NoError(t, err)
require.Equal(t, bookingTotal, remaining, "the remaining balance must never exceed the booking total")
}
func TestCreatePaymentMethod_HappyPath(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
handler := CreatePaymentMethod
reqBody := CreatePaymentMethodRequest{
CardToken: "cnon:visa",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
return
}
var card SavedCard
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if card.Brand != "VISA" {
t.Errorf("expected brand VISA, got %s", card.Brand)
}
if card.Last4 != "1111" {
t.Errorf("expected last4 1111, got %s", card.Last4)
}
if !card.IsDefault {
t.Error("expected first card to be default")
}
}
func TestCreatePaymentMethod_RawPANRejected(t *testing.T) {
// PCI-DSS: raw PANs are never accepted at the API edge — the handler must
// return 400 for a card_number body, since the field no longer exists and
// card_token is required. Validates that a raw PAN never reaches the
// Square client.
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Raw PAN sent as the old field name — should be ignored and rejected.
handler := CreatePaymentMethod
reqBody := map[string]string{
"card_number": "4111111111111111",
"expiry": "12/30",
"cvc": "123",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 (card_token required), got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreatePaymentMethod_MissingFieldsRejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
tests := []struct {
name string
body CreatePaymentMethodRequest
}{
{"no card token", CreatePaymentMethodRequest{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", tt.body, token, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
})
}
}
func TestCreatePaymentMethod_NoAuthRejected(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
handler := CreatePaymentMethod
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", CreatePaymentMethodRequest{
CardToken: "cnon:visa",
}, "", ctx)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreatePaymentMethod_SecondCardNotDefault(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
token := jwt.GenerateUserToken(userID)
// Create first card
handler := CreatePaymentMethod
reqBody := CreatePaymentMethodRequest{
CardToken: "cnon:visa",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("failed to create first card: %d. body: %s", w.Code, w.Body.String())
}
reqBody2 := CreatePaymentMethodRequest{
CardToken: "cnon:mastercard",
}
w = makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody2, token, ctx)
if w.Code != http.StatusOK {
t.Fatalf("failed to create second card: %d. body: %s", w.Code, w.Body.String())
}
var card SavedCard
if err := json.Unmarshal(w.Body.Bytes(), &card); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if card.Brand != "MASTERCARD" {
t.Errorf("expected brand MASTERCARD, got %s", card.Brand)
}
if card.IsDefault {
t.Error("expected second card to NOT be default")
}
}
// =============================================================================
// GetCheckoutStatus — GET /api/checkout/{checkout_id}/status?booking_id=...
// =============================================================================
// GetCheckoutStatus cannot be fully tested with the dev Square mock because
// the mock generates checkout IDs (like "chk_mock_...") that don't pass the
// 12-char hex validation. These tests cover the validation guard paths.
func TestGetCheckoutStatus_MissingCheckoutID(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/checkout//status?booking_id=abc", nil)
rctx := chi.NewRouteContext()
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
req = adminRequestCtx(req)
w := httptest.NewRecorder()
GetCheckoutStatus(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetCheckoutStatus_InvalidCheckoutID(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
// Must fail the Square-compatible checkout-ID check — the old 12-hex gate
// rejected real Square IDs (UUIDs like "08YceKh7B3ZqO"), so an injection
// attempt (path traversal) is the correct invalid case now.
badID := "../etc/passwd"
req := httptest.NewRequest("GET", "/api/checkout/"+badID+"/status?booking_id=abc", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", badID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
req = adminRequestCtx(req)
GetCheckoutStatus(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetCheckoutStatus_RealSquareID_PassesValidation(t *testing.T) {
// A real Square checkout ID (13-char UUID, not 12-hex) must pass the
// checkout-ID gate — the C-4 fix. It then 404s at the Square client level
// (mock has no such checkout), proving the gate no longer rejects it.
_, _ = testutils.SetupTestTx(t)
realID := "08YceKh7B3ZqO"
req := httptest.NewRequest("GET", "/api/checkout/"+realID+"/status?booking_id=abc", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", realID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
req = adminRequestCtx(req)
GetCheckoutStatus(w, req)
// Not 404-from-validation: the gate accepted it. The mock returns 500 for
// an unknown checkout (it panics on a missing ID), so assert NOT a 404
// from the gate — any non-404 is proof the gate passed.
if w.Code == http.StatusNotFound {
t.Errorf("real Square checkout ID %q was rejected by the validation gate — expected it to pass validation", realID)
}
}
func TestGetCheckoutStatus_ValidCheckoutNotFound(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
svcID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, userID, svcID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
req := httptest.NewRequest("GET", "/api/checkout/aaaaaaaaaaaa/status?booking_id="+bookingID, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
req = adminRequestCtx(req)
GetCheckoutStatus(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// AdminGetUserPaymentMethods — GET /api/admin/users/{id}/payment-methods
// =============================================================================
func TestAdminGetUserPaymentMethods_Success(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "cfa_admin_test", "VISA", "4321")
if err != nil {
t.Fatalf("failed to create payment method: %v", err)
}
_ = cardID
req := httptest.NewRequest("GET", "/api/admin/users/"+userID+"/payment-methods", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
AdminGetUserPaymentMethods(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var cards []SavedCard
if err := json.Unmarshal(w.Body.Bytes(), &cards); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(cards) != 1 {
t.Errorf("expected 1 card, got %d", len(cards))
}
}
func TestAdminGetUserPaymentMethods_InvalidUserID(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/users/$$$/payment-methods", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "$$$")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
req = adminRequestCtx(req)
AdminGetUserPaymentMethods(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestAdminGetUserPaymentMethods_NoCards(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
req := httptest.NewRequest("GET", "/api/admin/users/"+userID+"/payment-methods", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
AdminGetUserPaymentMethods(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var cards []SavedCard
if err := json.Unmarshal(w.Body.Bytes(), &cards); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(cards) != 0 {
t.Errorf("expected 0 cards, got %d", len(cards))
}
}
// =============================================================================
// GetBookingPaymentSummary — GET /api/bookings/{id}/payment-summary
// =============================================================================
func TestGetBookingPaymentSummary_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
_, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetBookingPaymentSummary(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var summary PaymentSummaryResponse
if err := json.Unmarshal(w.Body.Bytes(), &summary); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(summary.Payments) != 1 {
t.Errorf("expected 1 payment, got %d", len(summary.Payments))
}
if summary.Payments[0].Amount != 5000 {
t.Errorf("expected amount 5000, got %d", summary.Payments[0].Amount)
}
}
func TestGetBookingPaymentSummary_NotFound(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/bookings/aaaaaaaaaaaa/payment-summary", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "aaaaaaaaaaaa")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "some-user")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetBookingPaymentSummary(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetBookingPaymentSummary_Unauthorized(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
otherUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create other user: %v", err)
}
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, otherUserID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
req = req.WithContext(reqCtx)
_ = userID
w := httptest.NewRecorder()
GetBookingPaymentSummary(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetBookingPaymentSummary_AdminAccess(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, "admin-id")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
reqCtx = db.ContextWithTx(reqCtx, tx.(pgx.Tx))
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetBookingPaymentSummary(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// Service layer: GetPaymentByID
// =============================================================================
func TestGetPaymentByID_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.GetPaymentByID(ctx, "000000000001")
if err == nil {
t.Error("expected error for non-existent payment ID")
}
}
func TestGetPaymentByID_Found(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
svc := NewPaymentService()
record, err := svc.GetPaymentByID(ctx, paymentID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if record == nil || record.ID != paymentID {
t.Errorf("expected payment ID %s, got %v", paymentID, record)
}
}
// =============================================================================
// Service layer: GetBookingStatus
// =============================================================================
func TestGetBookingStatus_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.GetBookingStatus(ctx, "000000000001")
if err == nil {
t.Error("expected error for non-existent booking ID")
}
}
func TestGetBookingStatus_Found(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
svc := NewPaymentService()
status, err := svc.GetBookingStatus(ctx, bookingID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if status == "" {
t.Error("expected non-empty status")
}
}
// =============================================================================
// Service layer: GetBookingPaymentInfo
// =============================================================================
func TestGetBookingPaymentInfo_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.GetBookingPaymentInfo(ctx, "000000000001")
if err == nil {
t.Error("expected error for non-existent booking ID")
}
}
func TestGetBookingPaymentInfo_Found(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
svc := NewPaymentService()
info, err := svc.GetBookingPaymentInfo(ctx, bookingID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if info == nil {
t.Fatal("expected non-nil info")
}
if info.TotalAmount <= 0 {
t.Errorf("expected positive total amount, got %.2f", info.TotalAmount)
}
}
// =============================================================================
// Service layer: GetBookingRemainingBalancePence
// =============================================================================
func TestGetBookingRemainingBalancePence_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.GetBookingRemainingBalancePence(ctx, "000000000001")
if err == nil {
t.Error("expected error for non-existent booking ID")
}
}
func TestGetBookingRemainingBalancePence_FullBalance(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
svc := NewPaymentService()
pence, err := svc.GetBookingRemainingBalancePence(ctx, bookingID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if pence <= 0 {
t.Errorf("expected positive remaining balance for unpaid booking, got %d", pence)
}
}
// =============================================================================
// Service layer: GetAlreadyRefundedAmount
// =============================================================================
func TestGetAlreadyRefundedAmount_NoRefunds(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
svc := NewPaymentService()
amount, err := svc.GetAlreadyRefundedAmount(ctx, paymentID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if amount != 0 {
t.Errorf("expected 0 refunded amount, got %d", amount)
}
}
func TestGetAlreadyRefundedAmount_WithRefund(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
_, err = fixtures.CreateTestRefund(tx, paymentID, bookingID, 20.00)
if err != nil {
t.Fatalf("failed to create refund: %v", err)
}
svc := NewPaymentService()
amount, err := svc.GetAlreadyRefundedAmount(ctx, paymentID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if amount != 2000 {
t.Errorf("expected 2000 (2000p = £20.00), got %d", amount)
}
}
// =============================================================================
// Service layer: HasCompletedPayment
// =============================================================================
func TestHasCompletedPayment_NoPayments(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
svc := NewPaymentService()
hasPayments, err := svc.HasCompletedPayment(ctx, bookingID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if hasPayments {
t.Error("expected false for booking with no completed payments")
}
}
func TestHasCompletedPayment_HasPayment(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
_, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "cash", "full", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
svc := NewPaymentService()
hasPayments, err := svc.HasCompletedPayment(ctx, bookingID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !hasPayments {
t.Error("expected true for booking with completed payment")
}
}
// =============================================================================
// Service layer: SaveCardForUser
// =============================================================================
func TestSaveCardForUser_Success(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
svc := NewPaymentService()
cardID, err := svc.SaveCardForUser(ctx, userID, "cus_test_success", "cfa_test_success", "VISA", "4242", 12, 2030, "fp_success")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if cardID == "" {
t.Error("expected non-empty card ID")
}
}
func TestSaveCardForUser_InvalidUserID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.SaveCardForUser(ctx, "000000000001", "cus_test_invalid", "cfa_test", "VISA", "4242", 12, 2030, "fp_test")
if err == nil {
t.Error("expected error for non-existent user ID (FK violation)")
}
}
// =============================================================================
// Service layer: CreateRefundRecord
// =============================================================================
func TestCreateRefundRecord_InvalidPaymentID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
now := clock.Now()
_, err := svc.CreateRefundRecord(ctx, RefundRecord{
PaymentID: "000000000001",
BookingID: "000000000002",
Amount: 10.00,
Status: "completed",
Reason: "test",
CreatedAt: now,
})
if err == nil {
t.Error("expected error for non-existent payment ID (FK violation)")
}
}
// =============================================================================
// GetCheckoutStatus — GET /api/admin/payments/{checkout_id}/status
// =============================================================================
func TestGetCheckoutStatus_MissingBookingID(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
req = adminRequestCtx(req)
GetCheckoutStatus(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetCheckoutStatus_InvalidBookingID(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status?booking_id=invalid", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
req = adminRequestCtx(req)
w := httptest.NewRecorder()
GetCheckoutStatus(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetCheckoutStatus_BookingNotFound(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/payments/aaaaaaaaaaaa/status?booking_id=bbbbbbbbbbbb", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "aaaaaaaaaaaa")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
req = adminRequestCtx(req)
GetCheckoutStatus(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// CreateTerminalPayment — Validation gap tests
// =============================================================================
func TestTerminalPayment_NoAuth(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
}, "", ctx)
// No auth token → no role → the defense-in-depth isAdminRequest check
// rejects with 403 before the adminID check (S-1).
if w.Code != http.StatusForbidden {
t.Errorf("expected status 403, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTerminalPayment_InvalidJSON(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
adminToken := jwt.GenerateAdminToken()
body := bytes.NewReader([]byte(`{invalid}`))
req := httptest.NewRequest("POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+adminToken)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "aaaaaaaaaaaa")
reqCtx := context.WithValue(ctx, chi.RouteCtxKey, rctx)
if info := extractUserFromTestJWT(adminToken); info != nil {
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, info.userID)
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, info.role)
}
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
CreateTerminalPayment(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTerminalPayment_ValidateAmountFails(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 0,
PaymentType: "full",
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", req, adminToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestTerminalPayment_ValidatePaymentTypeFails(t *testing.T) {
ctx, _ := testutils.SetupTestTx(t)
adminToken := jwt.GenerateAdminToken()
req := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "invalid_type",
}
handler := CreateTerminalPayment
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/aaaaaaaaaaaa/payment", req, adminToken, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestSweepStalePendingPayments_MarksOldFailed(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
// A fresh pending payment (should NOT be failed).
freshID, err := fixtures.CreateTestPayment(tx, bookingID, 1000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create fresh pending payment: %v", err)
}
// A stale pending payment (25h old — past Square's ~24h key retention).
staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending")
if err != nil {
t.Fatalf("failed to create stale pending payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", staleID); err != nil {
t.Fatalf("failed to age the stale payment: %v", err)
}
_, err = SweepStalePendingPayments(ctx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
// Stale pending → failed; fresh pending untouched.
var staleStatus, freshStatus string
if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", staleID).Scan(&staleStatus); err != nil {
t.Fatalf("failed to query stale payment: %v", err)
}
if err := tx.QueryRow(ctx, "SELECT status FROM payments WHERE id = $1", freshID).Scan(&freshStatus); err != nil {
t.Fatalf("failed to query fresh payment: %v", err)
}
if staleStatus != "failed" {
t.Errorf("expected stale pending payment to be marked failed, got %q", staleStatus)
}
if freshStatus != "pending" {
t.Errorf("expected fresh pending payment to stay pending, got %q", freshStatus)
}
}
// TestSavedCardPayment_LostResponseRetry_Dedups verifies the R1 fix: two
// "Charge Saved Card" requests with identical inputs (booking + type + amount +
// card) derive the SAME deterministic idempotency key, so a lost-response
// retry reuses the completed payment instead of charging twice. Before the fix,
// every request used a fresh random key → the second click double-charged.
func TestSavedCardPayment_LostResponseRetry_Dedups(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:saved-card-test", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
handler := CreateTerminalPayment
reqBody := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
}
// First charge.
w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Same-input retry (lost response) — must dedup, not double-charge.
w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Exactly ONE payment record for this booking.
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' AND amount = 50.00`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 payment record (dedup), got %d — double-charge!", payCount)
}
}
// TestSavedCardPayment_SweptFailed_Rejected verifies the R2 fix: after the
// sweep marks a pending payment failed, a same-key retry is cleanly rejected
// with 409 instead of 500-ing on the idempotency_key UNIQUE constraint.
func TestSavedCardPayment_SweptFailed_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:swept-card-test", "VISA", "1111")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a failed payment with the deterministic key the handler will derive.
scKey := bookingID + "-sc-full-5000-" + cardID
if _, err := tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, user_saved_card_id, created_by, created_at, updated_at)
VALUES ($1, 'full', 'online_square', 'failed', 50.00, $2, $3, $4, NOW(), NOW())
`, bookingID, scKey, cardID, adminID); err != nil {
t.Fatalf("failed to seed failed payment: %v", err)
}
handler := CreateTerminalPayment
reqBody := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 (swept-failed rejection), got %d. body: %s", w.Code, w.Body.String())
}
// No new payment row was inserted.
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, scKey).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 (failed) payment row, got %d", payCount)
}
}
// TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup verifies the Bug 2
// fix: the frontend sends a per-attempt idempotency UUID, so two DISTINCT
// identical saved-card charges on the same booking (same amount, same card,
// same payment type) must NOT collapse on the old deterministic
// booking+type+amount+card key. Each becomes its own payment.
func TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:distinct-key-card", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
handler := CreateTerminalPayment
// Two legitimately distinct £20 'full' charges on the same booking — the
// frontend sends a different per-attempt UUID for each. Each stays within
// the £50 booking's remaining obligation (a second £50 charge would be a
// B3 overcharge on the now fully-paid booking and correctly rejected).
for i, key := range []string{"saved-card-uuid-0001", "saved-card-uuid-0002"} {
reqBody := CreateTerminalPaymentRequest{
Amount: 2000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: key,
}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("saved-card charge %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
// Exactly TWO payment records — the second charge must not be swallowed by
// the dedup branch.
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 2 {
t.Errorf("expected exactly 2 payment records (no dedup), got %d", payCount)
}
}
// TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups verifies that a lost-
// response retry carrying the SAME client UUID still dedups to a single
// payment — the client key does not disable retry safety.
func TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:same-key-card", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
handler := CreateTerminalPayment
reqBody := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
// Distinct from TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup's
// keys: both tests share the package-level singleton mock (SquareClient
// is set once in TestMain), and the mock now mirrors Square's body-aware
// dedup — reusing a retained key with a different source_id returns
// IDEMPOTENCY_KEY_REUSED (same as real Square).
IdempotencyKey: "saved-card-uuid-retry-0001",
}
// First charge.
w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Same-input retry (lost response) — same client UUID → dedup, not a
// second charge.
w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Exactly ONE payment record.
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 payment record (same-key dedup), got %d — double-charge!", payCount)
}
}
// TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup verifies the cash/
// giftcard branch: two identical cash receipts on the same booking are
// legitimate distinct payments and must each insert their own row (there is
// deliberately no idempotency dedup in this branch). Each £20 receipt stays
// within the £50 booking's remaining obligation (B3 clamps only what exceeds
// the remaining balance, and a fully-paid booking rejects further charges).
func TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
handler := CreateTerminalPayment
reqBody := CreateTerminalPaymentRequest{
Amount: 2000,
PaymentType: "full",
PaymentMethod: strPtr("cash"),
}
for i := 0; i < 2; i++ {
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("cash receipt %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 2 {
t.Errorf("expected exactly 2 cash payment rows (no dedup), got %d", payCount)
}
}
// TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep
// also marks stale pending till_sales rows (card payments) as failed, so a
// lost-response till sale can't stay pending past Square's key retention.
func TestSweepStalePendingPayments_CoversTillSales(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
// A stale pending till sale (card payment, 25h old).
var tillSaleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW() - INTERVAL '25 hours', NOW())
RETURNING id
`, adminID).Scan(&tillSaleID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// A fresh pending till sale that must NOT be swept.
var freshSaleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card create', 1, 30.00, 30.00, 'online_square', 'pending', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&freshSaleID)
if err != nil {
t.Fatalf("failed to seed fresh till sale: %v", err)
}
_, err = SweepStalePendingPayments(ctx)
if err != nil {
t.Fatalf("sweep failed: %v", err)
}
var staleStatus, freshStatus string
if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", tillSaleID).Scan(&staleStatus); err != nil {
t.Fatalf("failed to query stale till sale: %v", err)
}
if err := tx.QueryRow(ctx, "SELECT status FROM till_sales WHERE id = $1", freshSaleID).Scan(&freshStatus); err != nil {
t.Fatalf("failed to query fresh till sale: %v", err)
}
if staleStatus != "failed" {
t.Errorf("expected stale pending till sale to be marked failed, got %q", staleStatus)
}
if freshStatus != "pending" {
t.Errorf("expected fresh pending till sale to stay pending, got %q", freshStatus)
}
}
func TestBuildSplitRecords_TipOverflow_SeparateTipRecord(t *testing.T) {
// A £60 payment on a £50 future booking with nothing paid yet:
// deposit = min(60, 25) = £25, balance = min(35, 25) = £25,
// tip = 60 - 25 - 25 = £10 — the overflow becomes a separate tip record.
record := makeTestRecord("b-overflow", "full", 60)
info := &BookingPaymentInfo{
StartTime: clock.Now().Add(48 * time.Hour),
TotalAmount: 50,
TotalPaid: 0,
}
records, err := buildSplitRecords(record, "full", info, 60)
require.NoError(t, err)
if len(records) != 3 {
t.Fatalf("expected 3 records (deposit + balance + tip), got %d", len(records))
}
if records[0].PaymentType != "deposit" || records[0].Amount != 25 {
t.Errorf("expected deposit 25, got %q %.2f", records[0].PaymentType, records[0].Amount)
}
if records[1].PaymentType != "balance" || records[1].Amount != 25 {
t.Errorf("expected balance 25, got %q %.2f", records[1].PaymentType, records[1].Amount)
}
if records[2].PaymentType != "tip" || records[2].Amount != 10 {
t.Errorf("expected tip record 10, got %q %.2f", records[2].PaymentType, records[2].Amount)
}
// Tip is an overflow split — zero fees, derived idempotency key -split-2.
if records[2].Fees != 0 {
t.Errorf("expected tip record fees=0, got %.2f", records[2].Fees)
}
wantKey := *record.IdempotencyKey + "-split-3"
if *records[2].IdempotencyKey != wantKey {
t.Errorf("expected tip key %q, got %q", wantKey, *records[2].IdempotencyKey)
}
// All three share the same SquarePaymentID (one charge, three ledger rows).
if *records[2].SquarePaymentID != *record.SquarePaymentID {
t.Error("tip split must share square_payment_id")
}
}
// TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes is the
// C1 money-bug regression: a user paying the FULL discounted amount on a
// booking with an active time-based campaign must have the campaign discount
// applied (booking_discounts row + discount payment record + campaign
// redemption) and the booking must auto-complete. Previously the deposit+
// balance split was inserted BEFORE applyEligibleCampaignsAtPayment ran, so
// the split's two just-inserted completed records tripped the
// existingPayment>=2 guard in ComputeEligibleDiscounts and the discount was
// never applied — the booking stayed unpaid on paper and never completed.
func TestBookingPayment_FullDiscountedAmount_AppliesDiscountAndCompletes(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Fixture booking total is £50 (one test service). A 10% time-based
// campaign makes the discounted total £45.
userID, bookingID, _ := setupTestData(t, ctx, tx)
userToken := jwt.GenerateUserToken(userID)
now := clock.Now()
var campaignID string
err := tx.QueryRow(ctx, `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', $2, $3, 0)
RETURNING id
`, "Summer Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)
require.NoError(t, err)
cardToken := "cnon:discounted-full"
req := CreateBookingPaymentRequest{
Amount: 4500, // £45 = £50 - 10% discount
PaymentType: "full",
NewCardToken: &cardToken,
IdempotencyKey: "discounted-full-" + bookingID,
}
handler := CreateBookingPayment
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
// The campaign discount must have been applied for this booking.
var discountCount int
require.NoError(t, tx.QueryRow(ctx,
`SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND source_id = $2`,
bookingID, campaignID).Scan(&discountCount))
assert.Equal(t, 1, discountCount, "the campaign discount must be applied when the full discounted amount is paid")
// The discount payment record and the campaign redemption counter follow.
var discountPaymentCount int
require.NoError(t, tx.QueryRow(ctx,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPaymentCount))
assert.Equal(t, 1, discountPaymentCount, "the discount payment record must exist")
var redeemed int
require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed))
assert.Equal(t, 1, redeemed, "the campaign must be redeemed exactly once")
// The booking must auto-complete: £25 deposit + £20 balance (real money)
// plus the £5 discount covers the £50 total.
var status string
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status))
assert.Equal(t, "completed", status, "a booking paid to its discounted total must auto-complete")
}