Files
Crussell/backend/handlers/payments/loop_b_fixes_test.go
T
popertots 4d5d2cd381 fix: restart-loop-A findings — pending sweep refunds, tip carve on discounts, TOCTOU redemption, single-use 2FA code + mint endpoint, refresh-token family revocation, admin 2FA code UX
Restart of Loop A (fresh review -> fix -> verify) findings from commit 5e967fa:
- B1: sweep auto-refund treats Square PENDING refunds as NON-terminal (row stays pending, no gift-card clawback, refunds row inserted for payments AND till_sales, re-polls the deterministic sweepdup- key); Square-less pre-pass exempts square_refund_id IS NOT NULL rows
- M4: terminal tip carve accounts for pending campaign discounts (headroom = total - pending - paid) so explicit tips aren't absorbed as service revenue; no-tip case stays a single record
- max_redemptions TOCTOU closed with atomic conditional UPDATE ... RETURNING; exhausted-at-apply surfaces campaign_fully_redeemed
- 2FA: verification code is single-use on the saved-card gate (VerifyForUser consume=true, interactive flows unaffected); new POST /api/user/2fa/code mints a fresh code for enabled users (RequireAuth + RequireNonGuest + mint cooldown + per-user limiter)
- Refresh tokens: family_id + used_at columns; reuse of an already-rotated token revokes the ENTIRE family and inserts a refresh_token_reuse admin alert; rotation mints descendants in the same family
- Frontend: 2FA code input + Request-a-new-code on all saved-card surfaces; admin modal keys code input to customer 2FA + 403 self-heal; tip-display note for pending discounts; 76 frontend tests
- Verified: all 26 backend packages pass, frontend build+tests green, env-docs 41/41
2026-08-22 00:34:50 +01:00

459 lines
21 KiB
Go

//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_RejectsOvercharge locks the fully-paid edge of
// B3(a): when the booking has no remaining obligation, a no-tip charge is
// rejected with 400 — recording the requested amount verbatim would overcharge
// a customer who already paid in full (overpayment is handled manually at the
// counter, not minted into the ledger).
func TestTerminalCash_FullyPaid_RejectsOvercharge(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.StatusBadRequest, w.Code, "a charge on a fully-paid booking must be rejected, body: %s", w.Body.String())
var payCount int
require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&payCount))
assert.Equal(t, 1, payCount, "only the £50 prior payment may exist — the overcharge must not be recorded")
}
// 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)
}