//go:build test && dev package payments import ( "context" "encoding/json" "net/http" "testing" "time" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // B3 — admin overcharge double-loss: server-side clamp of terminal charges to // the booking's remaining obligation + tip only when explicitly requested. // ============================================================================= // seedPriorPayment records a completed real payment on a booking so the // remaining obligation is total - paid. func seedPriorPayment(t *testing.T, ctx context.Context, q db.Querier, bookingID string, amountPounds float64) { t.Helper() _, err := q.Exec(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at) VALUES ($1, 'full', 'cash', 'completed', $2, NOW(), NOW()) `, bookingID, amountPounds) require.NoError(t, err) } // TestTerminalCash_ClampsToRemainingObligation locks B3(a) for the cash branch: // a PaymentModal sending £45 (subtotal - discounts - campaignPreview) on a // booking with £30 already paid must be recorded at the £20 remaining // obligation, never the verbatim £45 (which would overcharge the customer). func TestTerminalCash_ClampsToRemainingObligation(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) seedPriorPayment(t, ctx, tx, bookingID, 30.00) adminToken := jwt.GenerateAdminToken() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, // £45 = subtotal - campaign preview, IGNORING the £30 already paid PaymentType: "full", PaymentMethod: strPtr("cash"), } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var paid float64 require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') AND payment_type != 'tip'`, bookingID).Scan(&paid)) assert.InDelta(t, 50.00, paid, 0.001, "£30 prior + £20 clamped = £50 obligation, never £75") var resp CheckoutResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), "cash response must carry the payment id") var clamped float64 require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE id = $1`, resp.CheckoutID).Scan(&clamped)) assert.InDelta(t, 20.00, clamped, 0.001, "the cash payment must be clamped to the £20 remaining obligation") } // TestTerminalCash_FullyPaid_RecordsVerbatim locks the fully-paid edge of // B3(a): when the booking has no remaining obligation, a cash payment is // recorded VERBATIM (a deliberate admin overpayment is real money received and // must stay on the ledger — the app's "overpayment handled at the counter" // semantics). The clamp only protects the common B3 case where prior payments // left a positive remaining obligation. func TestTerminalCash_FullyPaid_RecordsVerbatim(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) seedPriorPayment(t, ctx, tx, bookingID, 50.00) adminToken := jwt.GenerateAdminToken() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, PaymentType: "full", PaymentMethod: strPtr("cash"), } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "a fully-paid booking must still record the admin's deliberate overpayment, body: %s", w.Body.String()) var resp CheckoutResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), "cash response must carry the payment id") var lastCash float64 require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE id = $1`, resp.CheckoutID).Scan(&lastCash)) assert.InDelta(t, 45.00, lastCash, 0.001, "the fully-paid cash receipt must be recorded verbatim") } // TestTerminalSavedCard_ClampsToRemainingObligation locks B3(a) for the // saved-card branch of CreateTerminalPayment: the pending record and the // Square charge use the clamped remaining obligation, not the verbatim // PaymentModal amount that ignored prior payments. func TestTerminalSavedCard_ClampsToRemainingObligation(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) seedPriorPayment(t, ctx, tx, bookingID, 30.00) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") require.NoError(t, err) adminToken := jwt.GenerateAdminToken() origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sc-b3-clamp-" + bookingID, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var paid float64 require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house') AND payment_type != 'tip'`, bookingID).Scan(&paid)) assert.InDelta(t, 50.00, paid, 0.001, "£30 prior + £20 clamped = £50 obligation, never £75") var charged float64 require.NoError(t, tx.QueryRow(ctx, `SELECT amount FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' AND status = 'completed'`, bookingID).Scan(&charged)) assert.InDelta(t, 20.00, charged, 0.001, "the saved-card charge must be clamped to the £20 remaining obligation") } // TestTerminalCheckout_NoTip_ClampsToRemaining locks B3(b): a card-reader // checkout for more than the remaining obligation is clamped down to the // remaining value UNLESS the customer explicitly requested a tip. The // recorded terminal_checkouts row amount must reflect the clamp. func TestTerminalCheckout_NoTip_ClampsToRemaining(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) seedPriorPayment(t, ctx, tx, bookingID, 30.00) adminToken := jwt.GenerateAdminToken() origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, // over the £20 remaining, no tip requested PaymentType: "full", TipEnabled: false, IdempotencyKey: "chk-b3-clamp-" + bookingID, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var checkoutAmount float64 var tipEnabled bool require.NoError(t, tx.QueryRow(ctx, `SELECT amount, tip_enabled FROM terminal_checkouts WHERE booking_id = $1 AND status = 'PENDING'`, bookingID).Scan(&checkoutAmount, &tipEnabled)) assert.InDelta(t, 20.00, checkoutAmount, 0.001, "a no-tip checkout must present only the £20 remaining obligation") assert.False(t, tipEnabled, "tip_enabled must be persisted as false") } // TestTerminalCheckout_TipEnabled_NotClamped locks the tip side of B3(b): when // the customer explicitly requested a tip, the checkout amount (booking // portion + tip) is NOT clamped — the overflow is gratuity. func TestTerminalCheckout_TipEnabled_NotClamped(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) seedPriorPayment(t, ctx, tx, bookingID, 30.00) adminToken := jwt.GenerateAdminToken() origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, // £20 booking portion + £25 explicit tip PaymentType: "full", TipEnabled: true, IdempotencyKey: "chk-b3-tip-" + bookingID, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var checkoutAmount float64 var tipEnabled bool require.NoError(t, tx.QueryRow(ctx, `SELECT amount, tip_enabled FROM terminal_checkouts WHERE booking_id = $1 AND status = 'PENDING'`, bookingID).Scan(&checkoutAmount, &tipEnabled)) assert.InDelta(t, 45.00, checkoutAmount, 0.001, "an explicit tip must not be clamped away") assert.True(t, tipEnabled, "tip_enabled must be persisted as true") } // ============================================================================= // B14 — terminal saved-card charges must apply VAT (booking/cash paths do). // ============================================================================= // TestTerminalSavedCard_AppliesVAT locks B14: a saved-card charge through the // terminal handler must apply apply_vat_to_payment after the completed flip, // exactly like the booking/cash paths — otherwise a VAT-registered business // silently loses the VAT fields on every saved-card terminal charge. func TestTerminalSavedCard_AppliesVAT(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`) require.NoError(t, err) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") require.NoError(t, err) adminToken := jwt.GenerateAdminToken() origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sc-b14-vat-" + bookingID, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) paymentID, ok := resp["payment_id"].(string) require.True(t, ok, "response must carry payment_id") var isVATApplicable bool var vatAmount, netAmount *float64 require.NoError(t, tx.QueryRow(ctx, `SELECT is_vat_applicable, vat_amount, net_amount FROM payments WHERE id = $1`, paymentID).Scan(&isVATApplicable, &vatAmount, &netAmount)) require.True(t, isVATApplicable, "a saved-card terminal charge must be VAT-applicable") require.NotNil(t, vatAmount) assert.InDelta(t, 7.50, *vatAmount, 0.001, "£45 at 20%% VAT = £7.50") require.NotNil(t, netAmount) assert.InDelta(t, 37.50, *netAmount, 0.001, "net = £37.50") } // ============================================================================= // B13 — max_redemptions race: the apply-time re-check must surface a // campaign exhausted by a concurrent redemption instead of silently charging // full price. // ============================================================================= // TestApplyEligibleCampaignsAtPayment_CampaignExhausted_ReturnsError locks the // B13 error path directly: a campaign that was eligible at preview time but // exhausted (times_redeemed reached max_redemptions) by a concurrent // redemption before the apply-time re-check must surface a // campaignExhaustedAtApplyError with the promised discount value. func TestApplyEligibleCampaignsAtPayment_CampaignExhausted_ReturnsError(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) var bookingTotal float64 require.NoError(t, tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal)) now := clock.Now() var campaignID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 2, 0) RETURNING id `, "B13 Summer Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)) // Preview-time computation: the campaign is eligible (£5 on a £50 booking). expected := ComputeEligibleDiscounts(ctx, tx, bookingID, userID, bookingTotal) require.Len(t, expected, 1, "the campaign must be eligible at preview time") require.Equal(t, campaignID, expected[0].SourceID) // A CONCURRENT redemption on another booking exhausts the campaign. _, err := tx.Exec(ctx, `UPDATE discount_campaigns SET times_redeemed = 2 WHERE id = $1`, campaignID) require.NoError(t, err) err = applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, expected) require.Error(t, err, "an exhausted-at-apply campaign must surface an error, not silently charge full price") var exErr *campaignExhaustedAtApplyError require.ErrorAs(t, err, &exErr) require.Equal(t, campaignID, exErr.campaignID) require.Equal(t, int64(500), exErr.lostPence, "the lost discount is £5 on the £50 booking") // No discount row may have been created. var discountCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)) require.Zero(t, discountCount) } // TestApplyEligibleCampaignsAtPayment_CampaignAvailable_NoError locks the B13 // control: when the campaign is still available at apply time, the discount is // applied and no error is returned. func TestApplyEligibleCampaignsAtPayment_CampaignAvailable_NoError(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) var bookingTotal float64 require.NoError(t, tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&bookingTotal)) now := clock.Now() var campaignID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 10, 0) RETURNING id `, "B13 Still Active", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)) expected := ComputeEligibleDiscounts(ctx, tx, bookingID, userID, bookingTotal) require.Len(t, expected, 1) err := applyEligibleCampaignsAtPayment(ctx, tx, bookingID, userID, expected) require.NoError(t, err, "an available campaign must apply cleanly") var discountCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)) require.Equal(t, 1, discountCount, "the available campaign discount must be applied") } // TestTerminalSavedCard_AppliesCampaignAtChargeTime locks the B13 fix for the // saved-card terminal path: an eligible campaign must be applied AT CHARGE TIME // (inside the completed-flip transaction), not deferred to the completion // side-effects — those only run when bookingIsFullyPaid, by which point // capDiscountToRemainingObligation sees zero headroom and the discount would be // lost. Charging the discounted amount (£45 on a £50 booking) mints the £5 // discount row so real money + discount == total and the booking completes. func TestTerminalSavedCard_AppliesCampaignAtChargeTime(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") require.NoError(t, err) adminToken := jwt.GenerateAdminToken() now := clock.Now() var campaignID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 10, 0) RETURNING id `, "Terminal Saved-Card Sale", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)) origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, // the discounted amount the frontend preview showed PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sc-b13-apply-" + bookingID, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var discountCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)) assert.Equal(t, 1, discountCount, "the eligible campaign must be applied at saved-card charge time") var discountAmount float64 require.NoError(t, tx.QueryRow(ctx, `SELECT discount_amount FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountAmount)) assert.InDelta(t, 5.00, discountAmount, 0.001, "the £50 booking at 10%% = £5 discount") var discountPay float64 require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' AND status = 'completed'`, bookingID).Scan(&discountPay)) assert.InDelta(t, 5.00, discountPay, 0.001, "the discount payment record must exist") var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)) assert.Equal(t, "completed", status, "real money + discount row must complete the booking") var redeemed int require.NoError(t, tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed)) assert.Equal(t, 1, redeemed, "the campaign redemption counter must be incremented exactly once") } // exhaustCampaignOnChargeClient simulates the B13 max_redemptions race: it // exhausts the campaign (times_redeemed = max_redemptions) at the moment the // Square charge is made — i.e. BETWEEN the pre-charge eligibility snapshot and // the apply-time re-check inside the saved-card terminal path. type exhaustCampaignOnChargeClient struct { square.SquareClient campaignID string } func (c *exhaustCampaignOnChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { _, _ = db.Conn.Exec(ctx, `UPDATE discount_campaigns SET times_redeemed = max_redemptions WHERE id = $1`, c.campaignID) return c.SquareClient.CreatePayment(ctx, req) } // TestTerminalSavedCard_CampaignExhaustedAtApply_ReturnsCampaignFullyRedeemed // locks the B13 saved-card terminal path: a campaign exhausted by a concurrent // redemption between the frontend's preview and the apply-time re-check must // surface the same campaign_fully_redeemed 400 the online booking path returns, // instead of silently skipping the discount and leaving the booking underpaid. // The charge still completes at Square and the payment is recorded; the // frontend learns the campaign ended so it can prompt for the difference. func TestTerminalSavedCard_CampaignExhaustedAtApply_ReturnsCampaignFullyRedeemed(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:mock_card_123", "VISA", "4242") require.NoError(t, err) adminToken := jwt.GenerateAdminToken() now := clock.Now() var campaignID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed) VALUES ($1, 'time_based', 10, 'active', $2, $3, 2, 0) RETURNING id `, "B13 Terminal Race", now.Add(-24*time.Hour), now.Add(24*time.Hour)).Scan(&campaignID)) origClient := SquareClient SquareClient = &exhaustCampaignOnChargeClient{SquareClient: square.NewDevClient(), campaignID: campaignID} defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 4500, PaymentType: "full", PaymentMethod: strPtr("saved_card"), UserSavedCardID: &cardID, IdempotencyKey: "sc-b13-exhaust-" + bookingID, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "an exhausted-at-apply campaign must surface 400 campaign_fully_redeemed, body: %s", w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, "campaign_fully_redeemed", body["code"]) var discountCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)) assert.Zero(t, discountCount, "an exhausted campaign must not mint a discount row") // The charge still succeeded at Square and the payment was recorded as // completed (mirroring the online path: the payment is committed, then the // 400 is returned so the frontend can prompt for the difference). var payCount int var payStatus string require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount)) assert.Equal(t, 1, payCount) require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND payment_method = 'online_square' LIMIT 1`, bookingID).Scan(&payStatus)) assert.Equal(t, "completed", payStatus) }