Files
Crussell/backend/handlers/payments/payments_test.go
T
popertotsandSisyphus 7b24f8e484 refactor(payments): integrate VAT into gift card buy flow and wrap in transactions
Refactor BuyGiftCard to insert pending payment before Square call with VAT applied. Add transaction wrapping to gift card handlers. Remove redundant Content-Type header sets. Migrate all time.Now() to clock.Now().

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:40 +01:00

2312 lines
66 KiB
Go

//go:build test && dev
// +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/testutils"
"crussell/mw"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
)
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 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_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,
CardToken: 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,
CardToken: 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)
}
}
func TestSquareWebhook_DevMode_NoSignature(t *testing.T) {
t.Parallel()
t.Skip("webhook handler tested in webhooks package")
}
// ============================================================
// 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)
}
}
// ---------------------------------------------------------------------------
// nonDepositPaymentType unit tests — pure function, no DB needed.
// ---------------------------------------------------------------------------
func TestNonDepositPaymentType_FirstPaymentFull(t *testing.T) {
// First-ever payment, paying full amount → "full"
result := nonDepositPaymentType("full", 100, 100, 100)
if result != "full" {
t.Errorf("expected 'full', got %q", result)
}
}
func TestNonDepositPaymentType_BalanceWhenPriorExists(t *testing.T) {
// Total paid after this = 100, portion = 50, prior = 50 → "balance"
result := nonDepositPaymentType("balance", 100, 50, 100)
if result != "balance" {
t.Errorf("expected 'balance', got %q", result)
}
}
func TestNonDepositPaymentType_PartialWhenUnderTotal(t *testing.T) {
// Paying 30 on a 100 total → "partial"
result := nonDepositPaymentType("partial", 30, 30, 100)
if result != "partial" {
t.Errorf("expected 'partial', got %q", result)
}
// Same result when request type is "full" but amount doesn't cover total
result = nonDepositPaymentType("full", 80, 80, 100)
if result != "partial" {
t.Errorf("expected 'partial' when full doesn't cover total, got %q", result)
}
}
func TestNonDepositPaymentType_FullWhenFirstPaymentFullyCovers(t *testing.T) {
// First payment ever, exactly covers total → "full"
result := nonDepositPaymentType("full", 100, 100, 100)
if result != "full" {
t.Errorf("expected 'full', got %q", result)
}
}
func TestNonDepositPaymentType_DepositReqTypeBecomesPartial(t *testing.T) {
// Request type is "deposit" but amount doesn't fully cover → "partial"
result := nonDepositPaymentType("deposit", 30, 30, 100)
if result != "partial" {
t.Errorf("expected 'partial' for deposit type under total, got %q", result)
}
}
// ---------------------------------------------------------------------------
// 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)
req := CreateTipPaymentRequest{
Amount: 500,
CardToken: "cnon:wrong-owner-tip",
}
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++ {
req := CreateTipPaymentRequest{
Amount: int64(200 + i*100),
CardToken: fmt.Sprintf("cnon:multi-tip-%d", i),
}
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_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()
req := CreateTipPaymentRequest{
Amount: 500,
CardToken: "cnon:tip-card",
}
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{
CardNumber: "4111111111111111",
Expiry: "12/30",
CVC: "123",
}
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_ExpiredCardRejected(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{
CardNumber: "4111111111111111",
Expiry: "01/20",
CVC: "123",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreatePaymentMethod_InvalidExpiryRejected(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
expiry string
}{
{"bad format", "12-30"},
{"bad month", "13/30"},
{"bad year", "12/abc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := CreatePaymentMethod
reqBody := CreatePaymentMethodRequest{
CardNumber: "4111111111111111",
Expiry: tt.expiry,
CVC: "123",
}
w := makePaymentRequest(handler, "POST", "/api/user/payment-methods", reqBody, token, ctx)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, 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 number", CreatePaymentMethodRequest{Expiry: "12/30", CVC: "123"}},
{"no expiry", CreatePaymentMethodRequest{CardNumber: "4111111111111111", CVC: "123"}},
{"no cvc", CreatePaymentMethodRequest{CardNumber: "4111111111111111", Expiry: "12/30"}},
}
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{
CardNumber: "4111111111111111",
Expiry: "12/30",
CVC: "123",
}, "", 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{
CardNumber: "4111111111111111",
Expiry: "12/30",
CVC: "123",
}
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{
CardNumber: "5500000000000004",
Expiry: "06/30",
CVC: "456",
}
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")
}
}