Files
Crussell/backend/handlers/payments/payments_test.go
T
popertots 54f6bf3c1a Fix P0/P1 review findings: truncation, raw-PAN API edge, refund lock, till pending-retry, idempotency keys
P0 — float truncation: applied math.Round to all remaining int64(x*100)
sites (till penceAmount, refund over-refund guard, GetAlreadyRefundedAmount,
payment summary conversions). A £1.14 till sale previously charged 113p.

P0 — raw PAN stopped at the API edge:
- Deleted CardNumber/CardExpMonth/CardExpYear/CardCVC from TillSaleRequest
  and CardNumber/Expiry/CVC from CreatePaymentMethodRequest. Both now accept
  card_token (Square nonce) and return 400 when absent. PAN+CVV no longer
  transit the application server (PCI-DSS SAQ-A scope).
- Deleted CreateCardOnFileRaw from the SquareClient interface and all
  implementations (MockClient, ProdClient, devProdClient).
- Added idempotency_key column to refunds table (UNIQUE).

P0 — RefundPayment hardened: advisory lock on payment ID (prevents two
concurrent refunds passing the over-refund guard), pending-refund-record-
then-Square pattern (scheduler reprocesses on failure), same-key dedup.

P1 — till sale pending-retry now re-attempts the Square charge instead of
returning the stale 'pending' status (gift card was already funded in the
committed tx — silent money loss otherwise). Sale row reused, not duplicated.

P1 — idempotency key caching in frontend: BuyGiftCard and
UserPaymentModal/BookingFlow now cache the key per amount+card, regenerated
on change and cleared on success — matches the tip-flow pattern so a
lost-response retry dedups instead of double-charging.

P1 — CreateTerminalPayment cash/giftcard INSERTs now persist idempotency_key.
Key is unique per payment (booking+type+amount would wrongly dedup two
legitimate identical payments, e.g. two £50 cash receipts).

P1 — gift-card codes no longer logged (spendable credential; value+recipient
only).

Tests: till pending-retry re-attempt, refund same-key dedup, mock CreatePayment
idempotency dedup, CreatePaymentMethod nonce happy path + raw-PAN rejection,
till online_square card_token required/valid.
2026-08-22 00:34:49 +01:00

3154 lines
94 KiB
Go

//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/db"
"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)
}
func TestValidateCardInfo(t *testing.T) {
empty := ""
cardID := "card_123"
token := "cnon:test"
tests := []struct {
name string
cardID *string
newToken *string
wantError bool
}{
{"both set rejected", &cardID, &token, true},
{"card_id only ok", &cardID, nil, false},
{"token only ok", nil, &token, false},
{"neither set rejected", nil, nil, true},
{"empty card_id rejected", &empty, nil, true},
{"empty token rejected", nil, &empty, true},
{"both empty rejected", &empty, &empty, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCardInfo(tt.cardID, 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, "cfa_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 — the idempotency key
// (paymentID + amount) dedups it.
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",
}
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 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)
}
}
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 TestTipPayment_HappyPath(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(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, _ := setupTestData(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)
}
}
// ============================================================
// 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.
rows, err := tx.Query(ctx,
`SELECT payment_type, amount, square_payment_id
FROM payments WHERE booking_id = $1 ORDER BY amount DESC`, 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 := buildSplitRecords(record, "full", info, 50)
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 := buildSplitRecords(record, "full", info, 50)
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 := buildSplitRecords(record, "deposit", info, 20)
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 := buildSplitRecords(record, "deposit", info, 25)
if len(records) != 1 {
t.Fatalf("expected 1 record (under 50%%), got %d", len(records))
}
}
// ---------------------------------------------------------------------------
// 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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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, _ := setupTestData(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)
}
}
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
amountCents int64
remainingCents 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.amountCents, tt.remainingCents)
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 TestGetBookingRemainingBalanceCents(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.GetBookingRemainingBalanceCents(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.GetBookingRemainingBalanceCents(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterPartial != initialRemaining-2000 {
t.Errorf("expected %d cents 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.GetBookingRemainingBalanceCents(ctx, bookingID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if afterFull != 0 {
t.Errorf("expected 0 cents remaining after full payment, got %d", afterFull)
}
}
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)
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)
req := httptest.NewRequest("GET", "/api/checkout/1234567890abc/status?booking_id=abc", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "1234567890abc")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
w := httptest.NewRecorder()
GetCheckoutStatus(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
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()
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()
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: GetBookingRemainingBalanceCents
// =============================================================================
func TestGetBookingRemainingBalanceCents_NotFound(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_ = tx
svc := NewPaymentService()
_, err := svc.GetBookingRemainingBalanceCents(ctx, "000000000001")
if err == nil {
t.Error("expected error for non-existent booking ID")
}
}
func TestGetBookingRemainingBalanceCents_FullBalance(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
svc := NewPaymentService()
cents, err := svc.GetBookingRemainingBalanceCents(ctx, bookingID)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if cents <= 0 {
t.Errorf("expected positive remaining balance for unpaid booking, got %d", cents)
}
}
// =============================================================================
// 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, "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", "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()
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)
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()
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)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, 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())
}
}