//go:build test && dev package payments import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgconn" ) // ============================================================================= // Cancellation-race recheck — booking cancelled between pending commit and tx2 // ============================================================================= // cancellingCreatePaymentClient cancels the booking just before the Square // charge succeeds, simulating a concurrent cancellation landing between the // pending-record commit (step 1) and the post-charge transaction (step 2). type cancellingCreatePaymentClient struct { square.SquareClient bookingID string } func (c *cancellingCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { if _, err := db.Conn.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, c.bookingID); err != nil { return nil, err } return c.SquareClient.CreatePayment(ctx, req) } func TestCreateBookingPayment_CancellationRace_MarksFailed(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient SquareClient = &cancellingCreatePaymentClient{SquareClient: square.NewDevClient(), bookingID: bookingID} defer func() { SquareClient = origClient }() cardToken := "cnon:test-card-nonce" key := "cancel-race-" + bookingID req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: key, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusConflict { t.Fatalf("expected 409 when booking cancelled mid-charge, got %d: %s", w.Code, w.Body.String()) } // The pending payment must be marked failed, never completed with splits. var status string err := tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&status) if err != nil { t.Fatalf("failed to query payment status: %v", err) } if status != "failed" { t.Errorf("expected payment status 'failed' after cancellation race, got %q", status) } // No completed payment may exist on the cancelled booking (the deposit // would otherwise bypass the cancellation refund computation). var completedCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount) if err != nil { t.Fatalf("failed to count completed payments: %v", err) } if completedCount != 0 { t.Errorf("expected 0 completed payments on cancelled booking, got %d", completedCount) } // The booking itself stays cancelled. var bookingStatus string err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if bookingStatus != "client_cancelled" { t.Errorf("expected booking to remain client_cancelled, got %q", bookingStatus) } } // ============================================================================= // Tips on cancelled bookings rejected // ============================================================================= func TestCreateTipPayment_RejectsCancelledBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled") if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil { t.Fatalf("failed to create completed payment: %v", err) } cardToken := "cnon:test-card-nonce" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusConflict { t.Fatalf("expected 409 for tip on cancelled booking, got %d: %s", w.Code, w.Body.String()) } // No tip payment record may be created. var tipCount int err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount) if err != nil { t.Fatalf("failed to count tip payments: %v", err) } if tipCount != 0 { t.Errorf("expected 0 tip payments on cancelled booking, got %d", tipCount) } } func TestCreateTipPayment_RejectsNoShowBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "no_show") if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil { t.Fatalf("failed to create completed payment: %v", err) } cardToken := "cnon:test-card-nonce" req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken} handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusConflict { t.Fatalf("expected 409 for tip on no-show booking, got %d: %s", w.Code, w.Body.String()) } } // ============================================================================= // ReleasePaymentLock ownership checks // ============================================================================= func TestReleasePaymentLock_CrossUserRejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed") ownerToken := jwt.GenerateUserToken(userID) lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code) } otherUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create other user: %v", err) } otherToken := jwt.GenerateUserToken(otherUserID) w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", otherToken, ctx) if w.Code != http.StatusForbidden { t.Fatalf("expected 403 for cross-user release, got %d: %s", w.Code, w.Body.String()) } var lockCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount) if err != nil { t.Fatalf("failed to query time_blockers: %v", err) } if lockCount != 1 { t.Errorf("expected lock to remain after cross-user release, got %d blockers", lockCount) } } func TestReleasePaymentLock_AdminAllowed(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed") ownerToken := jwt.GenerateUserToken(userID) lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code) } w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", jwt.GenerateAdminToken(), ctx) if w.Code != http.StatusNoContent { t.Fatalf("expected 204 for admin release, got %d: %s", w.Code, w.Body.String()) } } func TestReleasePaymentLock_UnauthenticatedRejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed") ownerToken := jwt.GenerateUserToken(userID) lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx) if lockW.Code != http.StatusOK { t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code) } w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", "", ctx) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for unauthenticated release, got %d: %s", w.Code, w.Body.String()) } } // ============================================================================= // GetBookingPaymentSummary fail-closed auth // ============================================================================= func TestGetBookingPaymentSummary_UnauthenticatedRejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed") req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetBookingPaymentSummary(w, req) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for unauthenticated summary request, got %d: %s", w.Code, w.Body.String()) } } // ============================================================================= // Terminal payment type recorded + in-flight checkout guard // ============================================================================= // createTerminalCheckoutWithType is like createTerminalCheckout but records the // given payment type instead of hardcoding "full". func createTerminalCheckoutWithType(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64, paymentType string) string { t.Helper() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: amount, PaymentType: paymentType, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } var createResp CheckoutResponse if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil { t.Fatalf("failed to decode create response: %v", err) } if createResp.CheckoutID == "" { t.Fatal("expected checkout_id to be set") } return createResp.CheckoutID } func TestGetCheckoutStatus_RecordsChargedPaymentType(t *testing.T) { origClient := SquareClient SquareClient = &testCheckoutClient{ SquareClient: square.NewDevClient(), hexIDs: make(map[string]string), } defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() checkoutID := createTerminalCheckoutWithType(t, ctx, bookingID, adminToken, 5000, "balance") resp := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken) if resp.PaymentID == "" { t.Fatal("expected payment_id to be set") } var paymentType string err := tx.QueryRow(ctx, `SELECT payment_type FROM payments WHERE id = $1`, resp.PaymentID).Scan(&paymentType) if err != nil { t.Fatalf("failed to query payment type: %v", err) } if paymentType != "balance" { t.Errorf("expected recorded payment_type 'balance', got %q", paymentType) } } func TestCreateTerminalPayment_InFlightGuard_ReturnsExisting(t *testing.T) { origClient := SquareClient mc := square.NewDevClient().(*square.MockClient) mc.HoldCheckouts = true // keep the first checkout pending so the guard fires SquareClient = mc defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() first := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000) // A second attempt while the first is still in flight must reuse it, not // create a second live checkout. handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"} w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp CheckoutResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.CheckoutID != first { t.Errorf("expected the in-flight checkout %q to be returned, got %q", first, resp.CheckoutID) } var rowCount int err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1`, bookingID).Scan(&rowCount) if err != nil { t.Fatalf("failed to count terminal checkouts: %v", err) } if rowCount != 1 { t.Errorf("expected exactly 1 terminal_checkouts row, got %d", rowCount) } } func TestCreateTerminalPayment_InFlightGuard_AllowsAfterCompletion(t *testing.T) { origClient := SquareClient SquareClient = &testCheckoutClient{ SquareClient: square.NewDevClient(), hexIDs: make(map[string]string), } defer func() { SquareClient = origClient }() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 3000) pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken) // Once the first checkout is recorded COMPLETED, a new charge is allowed // (B3: a second charge is clamped to the remaining obligation, so a £20 // charge after a £30 one on the £50 booking is fine). checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 2000) if checkoutB == checkoutA { t.Error("expected a new checkout after the previous one completed") } pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken) var rowCount int err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1`, bookingID).Scan(&rowCount) if err != nil { t.Fatalf("failed to count terminal checkouts: %v", err) } if rowCount != 2 { t.Errorf("expected 2 terminal_checkouts rows (one completed, one new), got %d", rowCount) } } // ============================================================================= // Discount preview — global milestone + shared-eligibility semantics // ============================================================================= func TestDiscountPreview_GlobalMilestoneIncluded(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create past booking: %v", err) } if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID); err != nil { t.Fatalf("failed to complete past booking: %v", err) } // The first payment on the booking is in-person, which is the global // milestone's eligibility condition. if _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW()) `, bookingID); err != nil { t.Fatalf("failed to create in-person payment: %v", err) } if _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ('Global Milestone', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 1, 100, 0) `); err != nil { t.Fatalf("failed to create global milestone campaign: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp DiscountPreviewResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } found := false for _, d := range resp.Discounts { if d.Source == "campaign" && d.Percent == 10 { found = true break } } if !found { t.Errorf("expected the global milestone discount in the preview, got %+v", resp.Discounts) } } func TestDiscountPreview_Anniversary_AlreadyAppliedSkipped(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) // First visit years ago so anniversary campaigns qualify. if _, err := tx.Exec(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed') `, userID, time.Date(2020, 1, 15, 10, 0, 0, 0, time.UTC)); err != nil { t.Fatalf("failed to create first-visit booking: %v", err) } // Campaign A: already applied to this booking. var campA string err := tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit) VALUES ('Anniv A', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years') RETURNING id `).Scan(&campA) if err != nil { t.Fatalf("failed to create campaign A: %v", err) } if _, err := tx.Exec(ctx, ` INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', 10, 100, 10) `, bookingID, userID, campA); err != nil { t.Fatalf("failed to apply campaign A: %v", err) } // Campaign B: eligible. if _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit) VALUES ('Anniv B', 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years') `); err != nil { t.Fatalf("failed to create campaign B: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp DiscountPreviewResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } for _, d := range resp.Discounts { if d.Name == "Anniv A" { t.Error("expected the already-applied anniversary campaign to be skipped") } } foundB := false for _, d := range resp.Discounts { if d.Name == "Anniv B" { foundB = true break } } if !foundB { t.Errorf("expected the eligible anniversary campaign in the preview, got %+v", resp.Discounts) } } // TestDiscountPreview_ManyCampaignsExercisesSharedEligibility creates several // campaigns (time-based + milestone + global) and confirms the shared // ComputeEligibleDiscounts helper aggregates them correctly in the preview. func TestDiscountPreview_ManyCampaignsExercisesSharedEligibility(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx) serviceID, err := fixtures.CreateTestService(tx) if err != nil { t.Fatalf("failed to create service: %v", err) } pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("failed to create past booking: %v", err) } if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID); err != nil { t.Fatalf("failed to complete past booking: %v", err) } if _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW()) `, bookingID); err != nil { t.Fatalf("failed to create in-person payment: %v", err) } now := time.Now() if _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Time Sale', 'time_based', 5, 'active', $1, $2) `, now.Add(-24*time.Hour), now.Add(24*time.Hour)); err != nil { t.Fatalf("failed to create time-based campaign: %v", err) } if _, err := tx.Exec(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed) VALUES ('Global 1st', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 1, 100, 0) `); err != nil { t.Fatalf("failed to create global campaign: %v", err) } w := serveDiscountPreviewHandler(bookingID, userID, token, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var resp DiscountPreviewResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if len(resp.Discounts) < 2 { t.Errorf("expected time-based + global milestone discounts in preview, got %+v", resp.Discounts) } } // ============================================================================= // Verification token passthrough to Square // ============================================================================= type recordingPaymentClient struct { square.SquareClient mu sync.Mutex lastReq square.CreatePaymentReq } func (c *recordingPaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { c.mu.Lock() c.lastReq = req c.mu.Unlock() return c.SquareClient.CreatePayment(ctx, req) } func TestCreateBookingPayment_VerificationTokenPassthrough(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() vrf := "vrf_booking_token_123" cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "vrf-booking-" + bookingID, VerificationToken: &vrf, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } rec.mu.Lock() got := rec.lastReq.VerificationToken rec.mu.Unlock() if got != vrf { t.Errorf("expected VerificationToken %q passed to Square, got %q", vrf, got) } if rec.lastReq.BuyerEmail == "" { t.Error("expected BuyerEmail to be populated for booking payment") } } func TestCreateTipPayment_VerificationTokenPassthrough(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // Past start so the tip passes the start-time guard. userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil { t.Fatalf("failed to create completed payment: %v", err) } origClient := SquareClient rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() vrf := "vrf_tip_token_456" cardToken := "cnon:test-card-nonce" req := CreateTipPaymentRequest{ Amount: 500, NewCardToken: &cardToken, VerificationToken: &vrf, } handler := CreateTipPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } rec.mu.Lock() got := rec.lastReq.VerificationToken rec.mu.Unlock() if got != vrf { t.Errorf("expected VerificationToken %q passed to Square, got %q", vrf, got) } } func TestCreateBookingPayment_VerificationTokenTooLongRejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") long := make([]byte, 600) for i := range long { long[i] = 'a' } big := string(long) cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "vrf-too-long-" + bookingID, VerificationToken: &big, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for oversized verification token, got %d: %s", w.Code, w.Body.String()) } } // ============================================================================= // Deposit-promotion SUM excludes tip rows // ============================================================================= func TestBookingPayment_PromotionThreshold_ExcludesTipRows(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release") // A tip on the booking must not count toward the 20% promotion threshold. if _, err := tx.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at) VALUES ($1, 'tip', 'in_person_card', 1000, 'completed', NOW(), NOW()) `, bookingID); err != nil { t.Fatalf("failed to create tip payment: %v", err) } // 15% of the total via a real payment — below the 20% threshold even with // the tip present. var total float64 if err := tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&total); err != nil { t.Fatalf("failed to read booking total: %v", err) } amount := int64(total * 100 * 0.15) cardToken := "cnon:test-card-nonce" req := CreateBookingPaymentRequest{ Amount: amount, PaymentType: "partial", NewCardToken: &cardToken, IdempotencyKey: "promo-tip-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var status string err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking status: %v", err) } if status != "pending_release" { t.Errorf("expected booking to remain pending_release (tip excluded from threshold), got %q", status) } } // ============================================================================= // ApplyEligibleDiscount — counter/used-flag survive a payments-INSERT failure // ============================================================================= // failingExecQuerier wraps a db.Querier and fails any Exec whose SQL contains // the target fragment, simulating a constraint/connection error on that one // statement while delegating everything else to the wrapped querier. type failingExecQuerier struct { db.Querier failSQLContains string } func (f failingExecQuerier) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { if strings.Contains(sql, f.failSQLContains) { return pgconn.CommandTag{}, errors.New("simulated failure on " + f.failSQLContains) } return f.Querier.Exec(ctx, sql, args...) } // seedTestCampaign inserts an active time_based campaign and returns its id. func seedTestCampaign(t *testing.T, ctx context.Context, q db.Querier) string { t.Helper() var campaignID string err := q.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date) VALUES ('Test Campaign', 'time_based', 10.00, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '30 days') RETURNING id `).Scan(&campaignID) if err != nil { t.Fatalf("failed to seed test campaign: %v", err) } return campaignID } func TestApplyEligibleDiscount_CampaignPaymentInsertFailure_StillIncrementsCounter(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) campaignID := seedTestCampaign(t, ctx, tx) // The payments INSERT fails AFTER the booking_discounts row was inserted, // so the redemption happened — the counter MUST still increment. failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"} if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{ Source: "campaign", Name: "Test Campaign", Percent: 10.00, Amount: 10.00, SourceID: campaignID, CampaignType: "time_based", }); err != nil { t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err) } var redeemed int if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil { t.Fatalf("failed to read campaign counter: %v", err) } if redeemed != 1 { t.Errorf("expected times_redeemed incremented to 1 despite the payment-record insert failure, got %d", redeemed) } var bdCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND source_id = $2`, bookingID, campaignID).Scan(&bdCount); err != nil { t.Fatalf("failed to count booking_discounts: %v", err) } if bdCount != 1 { t.Errorf("expected 1 booking_discounts row, got %d", bdCount) } var discountPayments int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPayments); err != nil { t.Fatalf("failed to count discount payments: %v", err) } if discountPayments != 0 { t.Errorf("expected NO discount payment row (the insert was simulated to fail), got %d", discountPayments) } } func TestApplyEligibleDiscount_ReferralPaymentInsertFailure_StillMarksUsed(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) referredID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create referred user: %v", err) } var referralID string if err := tx.QueryRow(ctx, ` INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id `, userID, referredID).Scan(&referralID); err != nil { t.Fatalf("failed to seed user referral: %v", err) } var rdID string if err := tx.QueryRow(ctx, ` INSERT INTO referral_discounts (user_id, referral_id, discount_percent) VALUES ($1, $2, 10.00) RETURNING id `, userID, referralID).Scan(&rdID); err != nil { t.Fatalf("failed to seed referral discount: %v", err) } // The payments INSERT fails AFTER the referral's booking_discounts row was // inserted, so the discount WAS redeemed — the used flag MUST still be set. failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"} if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{ Source: "referral", Name: "Referral Discount (10%)", Percent: 10.00, Amount: 10.00, SourceID: rdID, IsReferral: true, }); err != nil { t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err) } var used bool if err := tx.QueryRow(ctx, `SELECT used FROM referral_discounts WHERE id = $1`, rdID).Scan(&used); err != nil { t.Fatalf("failed to read referral discount used flag: %v", err) } if !used { t.Error("expected referral discount marked used despite the payment-record insert failure") } var bdCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&bdCount); err != nil { t.Fatalf("failed to count booking_discounts: %v", err) } if bdCount != 1 { t.Errorf("expected 1 referral booking_discounts row, got %d", bdCount) } } func TestApplyEligibleDiscount_BookingDiscountsInsertFailure_CounterStillReserved(t *testing.T) { // B13 reservation-first semantics: the campaign counter increment (the // atomic conditional reservation) happens BEFORE the booking_discounts // insert, so a booking_discounts-INSERT failure no longer leaves the // counter untouched — the redemption slot was consumed and max_redemptions // bounds the lost reservation (the next eligible booking finds the campaign // with one fewer redemption). No discount payment row is minted. ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) campaignID := seedTestCampaign(t, ctx, tx) failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO booking_discounts"} if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{ Source: "campaign", Name: "Test Campaign", Percent: 10.00, Amount: 10.00, SourceID: campaignID, CampaignType: "time_based", }); err != nil { t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err) } var redeemed int if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil { t.Fatalf("failed to read campaign counter: %v", err) } if redeemed != 1 { t.Errorf("expected times_redeemed incremented to 1 by the reservation despite the booking_discounts insert failure, got %d", redeemed) } var discountPayments int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPayments); err != nil { t.Fatalf("failed to count discount payments: %v", err) } if discountPayments != 0 { t.Errorf("expected NO discount payment row (the booking_discounts insert was simulated to fail), got %d", discountPayments) } } // ============================================================================= // CreateTerminalPayment — orphaned live checkout cancelled on INSERT failure // ============================================================================= // fixedCheckoutClient returns a predetermined checkout ID so a test can force // the terminal_checkouts INSERT to collide (PK) while delegating everything // else to the real mock. type fixedCheckoutClient struct { square.SquareClient checkoutID string mu sync.Mutex cancelled []string } func (c *fixedCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) { return &square.CheckoutResult{ ID: c.checkoutID, Status: "PENDING", AmountMoney: req.Amount, Currency: "GBP", ReferenceID: req.ReferenceID, }, nil } func (c *fixedCheckoutClient) CancelCheckout(ctx context.Context, checkoutID string) error { c.mu.Lock() c.cancelled = append(c.cancelled, checkoutID) c.mu.Unlock() return c.SquareClient.CancelCheckout(ctx, checkoutID) } func (c *fixedCheckoutClient) cancelCalls() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.cancelled...) } func TestCreateTerminalPayment_RecordInsertFailure_CancelsOrphanedCheckout(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() // R3: the handler now inserts the tracked row FIRST with a provisional // (tmp-) checkout_id, then CreateCheckout, then UPDATEs the row to the real // Square id. A COMPLETED terminal_checkouts row with the same checkout_id // the client will return makes that tracking UPDATE collide on the PK while // the active-checkout guard (PENDING/IN_PROGRESS only) does not fire — the // checkout is live at Square but untracked, so the handler must cancel it. const dupCheckoutID = "chk_dup_insert_01" if _, err := tx.Exec(ctx, ` INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount) VALUES ($1, $2, 'full', 'COMPLETED', 50.00) `, dupCheckoutID, bookingID); err != nil { t.Fatalf("failed to seed duplicate checkout row: %v", err) } origClient := SquareClient client := &fixedCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: dupCheckoutID} SquareClient = client defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"} w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500 for the failed terminal_checkouts INSERT, got %d: %s", w.Code, w.Body.String()) } // The orphaned live checkout must have been cancelled at Square. if calls := client.cancelCalls(); len(calls) != 1 || calls[0] != dupCheckoutID { t.Errorf("expected exactly one CancelCheckout for the orphaned checkout %q, got %v", dupCheckoutID, calls) } } func TestCreateTerminalPayment_CreateCheckoutFailure_MarksProvisionalRowFailed(t *testing.T) { // R3: the handler inserts the tracked row FIRST with a provisional // ("tmp-") checkout_id, THEN calls Square CreateCheckout. When Square fails // AFTER that insert, the handler must mark the provisional row failed so a // retry can proceed — otherwise the "tmp-" row (provably pre-Square) wedges // the booking's in-flight guard forever. ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() origClient := SquareClient mc := square.NewDevClient().(*square.MockClient) mc.FailCreateCheckout = true SquareClient = mc defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"} w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500 when Square CreateCheckout fails, got %d: %s", w.Code, w.Body.String()) } // The provisional row must have been marked failed, and its checkout_id // must still be the provisional "tmp-" value (Square never returned a real // id, so nothing may overwrite it). var status, checkoutID string err := tx.QueryRow(ctx, ` SELECT status, checkout_id FROM terminal_checkouts WHERE booking_id = $1 `, bookingID).Scan(&status, &checkoutID) if err != nil { t.Fatalf("failed to query terminal checkout row: %v", err) } if status != "failed" { t.Errorf("expected provisional terminal_checkouts row status 'failed', got %q", status) } if !strings.HasPrefix(checkoutID, "tmp-") { t.Errorf("expected checkout_id to remain provisional (tmp- prefix), got %q", checkoutID) } // A retry after the failure must be able to create a fresh checkout: the // in-flight guard must not see the failed row as active. var activeCount int if err := tx.QueryRow(ctx, ` SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1 AND status IN ('PENDING', 'IN_PROGRESS') `, bookingID).Scan(&activeCount); err != nil { t.Fatalf("failed to count active terminal checkouts: %v", err) } if activeCount != 0 { t.Errorf("expected 0 active terminal checkouts after the failure, got %d", activeCount) } }