//go:build test && dev package payments import ( "context" "database/sql" "net/http" "sync" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // ROUND 8 — money-safety testing gaps // ============================================================================= // // This file pins four behaviors that keep money movements safe: // // 1. RefundPayment's manual guard rejects discount/on-the-house ledger rows // (a discount row is not real money, so refunding it would pay money out // of nothing) BEFORE any Square refund call or refund row is created. // // 2. The sweep's recordUntrackedTerminalPayment splits a COMPLETED terminal // charge that exceeds the remaining booking balance into deposit/balance/ // tip records, applies per-record VAT via ApplyVATToBookingPayment, and // completes the now-fully-paid booking. // // 3. CreateTerminalPayment always sends AllowTipping: false in the Square // CreateCheckoutReq — the third leg of the tip double-count fix (the // frontend embeds the tip in the charge amount, so the terminal must not // prompt for a second one). // // 4. acquireAdvisoryXactLockBlocking (the deliberately-unbounded refund lock) // blocks a second waiter until the holder's transaction commits — the // "a refund must never be dropped" rationale for the unbounded wait. // ============================================================================= // T5 — RefundPayment manual guard rejects discount / on-the-house payments // ============================================================================= // TestRound8_RefundPayment_DiscountOrOnTheHouse_Rejected pins the T5 manual // guard: RefundPayment rejects a completed discount/on-the-house payment row // with 400 and a "Cannot refund a discount or complimentary payment" message, // BEFORE issuing any Square refund call and BEFORE creating any refund row. A // discount/on-the-house row is a ledger entry, not real money — the customer // never paid it, so refunding it would pay money out of nothing. The row is // seeded WITHOUT a square_payment_id so the rejection can only come from the // discount guard (the later "Payment has no Square reference" guard would // fire with a different message if the discount guard were ever removed). func TestRound8_RefundPayment_DiscountOrOnTheHouse_Rejected(t *testing.T) { for _, method := range []string{"discount", "on_the_house"} { t.Run(method, func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) _, bookingID, _ := setupTestData(t, ctx, tx) payID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, method, "full", "completed") require.NoError(t, err) // Swap in a client that records every Square refund call so the // test can prove the guard fires before any money would move. origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting defer func() { SquareClient = origClient }() adminToken := jwt.GenerateTestToken(adminID, "admin") req := RefundRequest{Amount: 1000, Reason: "round8 guard test"} w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+payID+"/refund", req, adminToken, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "Cannot refund a discount or complimentary payment", "the message must identify the discount/complimentary rejection") require.Empty(t, counting.refundCalls(), "no Square refund call may be issued for a discount/on-the-house payment") var refundCount int err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, payID).Scan(&refundCount) require.NoError(t, err) assert.Equal(t, 0, refundCount, "no refund row may be created for a discount/on-the-house payment") }) } } // ============================================================================= // T6 — recordUntrackedTerminalPayment tip-split / VAT / completion branches // ============================================================================= // TestRound8_SweepUntrackedTerminal_OverBalance_TipSplit_VAT_CompletesBooking // pins the T6 money-safety contract of recordUntrackedTerminalPayment: a stale // "tmp-" terminal checkout that COMPLETED at Square with an amount ABOVE the // remaining booking balance (£55 on a £50 booking) must be recorded as THREE // ledger rows (deposit £25 + balance £25 + tip £5), each booking row must get // its VAT applied through ApplyVATToBookingPayment (the tip record must never // carry VAT), and the now-fully-paid booking must be transitioned to // 'completed' via completeFullyPaidBooking. This mirrors the existing // TestSweepStaleTerminalCheckouts_TmpProvisional_Completed_RecordsPayment but // exercises the over-balance split that its tipAmount=0 charge never reaches. func TestRound8_SweepUntrackedTerminal_OverBalance_TipSplit_VAT_CompletesBooking(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) // The booking must be in a payable state for the untracked charge to be // recorded (bookingStatusAllowsCompletedPayment) and completable by // completeFullyPaidBooking. if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking in_progress: %v", err) } // VAT-registered so the sweep's per-record ApplyVATToBookingPayment writes // vat_amount/net_amount on the split booking rows. The update is part of the // setup tx that is committed below, so the sweep sees it at pool level. if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`); err != nil { t.Fatalf("failed to enable VAT registration: %v", err) } const tmpID = "tmp-round8-tip-split" seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID) // B3: the £5 overflow is only carved into a tip record when the customer // EXPLICITLY requested a tip (tip_enabled) — mark it so the split is // exercised here. if _, err := tx.Exec(ctx, `UPDATE terminal_checkouts SET tip_enabled = TRUE WHERE checkout_id = $1`, tmpID); err != nil { t.Fatalf("failed to mark the checkout tip-enabled: %v", err) } origClient := SquareClient const sqPayID = "sqp_round8_tip_split" SquareClient = &provisionalCheckoutClient{ SquareClient: square.NewDevClient(), checkoutID: tmpID, // £55 charged on a £50 booking: deposit £25 + balance £25 + tip £5. result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: sqPayID, Amount: 5500, Fees: 88, CardBrand: "VISA", CardLast4: "4242"}, } defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx, "no transaction in context") require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx") pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID) _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) // Restore the shared business_settings row to the VAT-unregistered // baseline so parallel tests keep their own VAT expectations. _, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, default_vat_rate = 20.00`) }) // Drop any other stale terminal rows left by sequential tests so the count // is deterministic. if _, err := db.Conn.Exec(pool, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, tmpID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(pool, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil { t.Fatalf("failed to clean leftover stale till sales: %v", err) } n, err := SweepStaleTerminalCheckouts(pool) require.NoError(t, err, "sweep failed") assert.Equal(t, 1, n, "the COMPLETED over-balance provisional checkout must be resolved by the sweep") var status string require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM terminal_checkouts WHERE checkout_id = $1", tmpID).Scan(&status)) assert.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED") // The untracked charge must be split: one £55 Square charge → deposit £25 + // balance £25 + tip £5 (three ledger rows sharing the square_payment_id). rows, err := db.Conn.Query(pool, ` SELECT payment_type, amount, is_vat_applicable, vat_amount, net_amount FROM payments WHERE booking_id = $1 AND square_payment_id = $2 ORDER BY payment_type `, bookingID, sqPayID) require.NoError(t, err, "failed to query recorded split payments") defer rows.Close() type splitRow struct { paymentType string amount float64 vatApplied bool vatAmount sql.NullFloat64 netAmount sql.NullFloat64 } splits := map[string]splitRow{} for rows.Next() { var r splitRow require.NoError(t, rows.Scan(&r.paymentType, &r.amount, &r.vatApplied, &r.vatAmount, &r.netAmount)) splits[r.paymentType] = r } require.NoError(t, rows.Err()) require.Len(t, splits, 3, "the over-balance terminal charge must split into deposit + balance + tip records") assert.InDelta(t, 25.0, splits["deposit"].amount, 0.001, "deposit = 50%% of the £50 booking total") assert.InDelta(t, 25.0, splits["balance"].amount, 0.001, "balance = the remaining booking total") assert.InDelta(t, 5.0, splits["tip"].amount, 0.001, "tip = the charged amount above the booking value") // Per-record VAT (ApplyVATToBookingPayment): the deposit and balance rows // carry 20% VAT of the £25 gross (£4.17 VAT, £20.83 net); the tip record // must never have VAT applied. for _, pt := range []string{"deposit", "balance"} { r := splits[pt] assert.True(t, r.vatApplied, "%s record must have VAT applied", pt) require.True(t, r.vatAmount.Valid, "%s record must have vat_amount set", pt) assert.InDelta(t, 4.17, r.vatAmount.Float64, 0.001, "%s record VAT (20%% of £25 gross)", pt) require.True(t, r.netAmount.Valid, "%s record must have net_amount set", pt) assert.InDelta(t, 20.83, r.netAmount.Float64, 0.001, "%s record net of 20%% VAT", pt) } assert.False(t, splits["tip"].vatApplied, "tip record must never have VAT applied") assert.False(t, splits["tip"].vatAmount.Valid, "tip record must have NULL vat_amount") assert.False(t, splits["tip"].netAmount.Valid, "tip record must have NULL net_amount") // The £55 charge covers the full £50 booking (deposit + balance), so // completeFullyPaidBooking must have transitioned the booking to // 'completed' — the same completion the poll handler performs. var bookingStatus string require.NoError(t, db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus)) assert.Equal(t, "completed", bookingStatus, "a fully-paid booking must be completed by the sweep") } // ============================================================================= // T8 — CreateTerminalPayment sends AllowTipping: false to Square // ============================================================================= // recordingCheckoutClient records every CreateCheckoutReq so a test can assert // exactly what the handler sends to Square while delegating the actual call to // the underlying client (the same recording-client pattern as // recordingPaymentClient / countingRefundClient in the sibling files). type recordingCheckoutClient struct { square.SquareClient mu sync.Mutex reqs []square.CreateCheckoutReq } func (c *recordingCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) { c.mu.Lock() c.reqs = append(c.reqs, req) c.mu.Unlock() return c.SquareClient.CreateCheckout(ctx, req) } func (c *recordingCheckoutClient) checkoutReqs() []square.CreateCheckoutReq { c.mu.Lock() defer c.mu.Unlock() return append([]square.CreateCheckoutReq(nil), c.reqs...) } // TestRound8_CreateTerminalPayment_AllowTippingFalse pins the T8 leg of the // tip double-count fix: the frontend embeds the tip in the charge amount // (totalWithTip), so CreateTerminalPayment must pass AllowTipping: false in // the Square CreateCheckoutReq even when the client requests TipEnabled — // otherwise the terminal would prompt for a second tip and the tip would be // double-counted in production. The request is captured with a recording // client and asserted verbatim. func TestRound8_CreateTerminalPayment_AllowTippingFalse(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) adminToken := jwt.GenerateAdminToken() origClient := SquareClient rec := &recordingCheckoutClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() handler := CreateTerminalPayment req := CreateTerminalPaymentRequest{ Amount: 5500, PaymentType: "full", TipEnabled: true, } w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) reqs := rec.checkoutReqs() require.Len(t, reqs, 1, "exactly one CreateCheckoutReq must be sent to Square") assert.False(t, reqs[0].AllowTipping, "AllowTipping must be false even with TipEnabled — the tip is already embedded in the amount") assert.Equal(t, int64(5500), reqs[0].Amount, "the charge amount (tip embedded) must reach Square verbatim") assert.Equal(t, bookingID, reqs[0].ReferenceID, "the checkout must be scoped to the booking") } // ============================================================================= // T9 — acquireAdvisoryXactLockBlocking blocks waiters until the holder commits // ============================================================================= // TestRound8_AdvisoryXactLock_BlocksWaiterUntilCommit pins the T9 contract of // the deliberately-unbounded transaction-scoped refund lock: a second waiter // on the same "crussell:refund:" key must BLOCK (not time out, not proceed) // while the holder's transaction is open, and must acquire the lock — returning // nil — only after the holder commits. This is the "a refund must never be // dropped" rationale: if the manual RefundPayment holds the key across its // up-to-30s Square round-trip, a timed-out cancellation would abort and the // caller would commit a cancellation with ZERO refund rows created (no sweep // retry is possible because the rows never existed). Uses channels + timeouts // so the assertion never depends on a sleep; both transactions are rolled back // when the lock is not acquired. func TestRound8_AdvisoryXactLock_BlocksWaiterUntilCommit(t *testing.T) { ctx := context.Background() key := "crussell:refund:round8-locktest" // Goroutine A: the holder. Its transaction stays OPEN until we commit it, // so the lock it holds is never released early. holderTx, err := db.Conn.Begin(ctx) require.NoError(t, err, "failed to begin holder tx") defer func() { _ = holderTx.Rollback(ctx) }() require.NoError(t, acquireAdvisoryXactLockBlocking(ctx, holderTx, key), "the uncontended blocking xact lock must be acquired immediately") // Goroutine B: the waiter. It signals that it has STARTED (its tx is open // and it is about to issue the blocking acquire) and then reports the // acquire result on a buffered channel. started := make(chan struct{}) acquired := make(chan error, 1) go func() { waiterTx, err := db.Conn.Begin(ctx) if err != nil { acquired <- err return } defer func() { _ = waiterTx.Rollback(ctx) }() close(started) acquired <- acquireAdvisoryXactLockBlocking(ctx, waiterTx, key) }() <-started // While the holder's tx is open, the waiter must NOT have returned. select { case err := <-acquired: t.Fatalf("waiter returned %v while the holder tx was still open — the blocking xact lock did not block", err) case <-time.After(300 * time.Millisecond): // Expected: the waiter is blocked on the holder's lock. } // Release the lock by committing the holder's transaction; the waiter must // then acquire it and return nil. require.NoError(t, holderTx.Commit(ctx), "failed to commit holder tx") select { case err := <-acquired: require.NoError(t, err, "the waiter must acquire the lock once the holder commits") case <-time.After(10 * time.Second): t.Fatal("waiter never acquired the lock after the holder committed — the blocking xact lock did not release") } }