Files
Crussell/backend/handlers/payments/payments_fixes_test.go
T
popertots 738f6b6a51 Expand payment test coverage: lock contention, nonce-direct, provisional rows, GDPR scrub, validators
Close the coverage-gap round: terminal CreateCheckout-failure marks the provisional row failed, GetCheckoutStatus reference_id mismatch 400, deadline wire shape, concurrent loyalty redemption 409, delete_guest_user + stale-guest saved-card scrubbing, ValidateAmount and isTokenLike direct units, bounded try-lock timeout, buildSplitRecords tip-overflow, and concurrent same-key dedup for gift card / booking / tip / checkout.
2026-08-22 00:34:49 +01:00

985 lines
37 KiB
Go

//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, 5000)
pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
// Once the first checkout is recorded COMPLETED, a new charge is allowed.
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 3000)
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)
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "in_progress")
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"}
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
Source: "campaign",
Name: "Test Campaign",
Percent: 10.00,
Amount: 10.00,
SourceID: campaignID,
CampaignType: "time_based",
})
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"}
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
Source: "referral",
Name: "Referral Discount (10%)",
Percent: 10.00,
Amount: 10.00,
SourceID: rdID,
IsReferral: true,
})
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_NoCounterIncrement(t *testing.T) {
// The booking_discounts-INSERT failure is the ONE early return that is
// correct: nothing was recorded, so the redemption never happened and the
// campaign counter must NOT increment.
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"}
ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
Source: "campaign",
Name: "Test Campaign",
Percent: 10.00,
Amount: 10.00,
SourceID: campaignID,
CampaignType: "time_based",
})
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 != 0 {
t.Errorf("expected times_redeemed unchanged (0) when the booking_discounts insert fails, got %d", redeemed)
}
}
// =============================================================================
// 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)
}
}