//go:build test && dev package payments // Tests for the C2 till-vs-online advisory-lock serialization fix and the // SCA tokenize-result wire contract (new_card_token + saved_card_id on // CreateBookingPayment). // // These tests COMMIT their setup so the advisory locks work across independent // pool connections (see cleanupConcurrentTestRows in concurrency_test.go for // the leak-free deletion order). They swap the SquareClient global and run // lock-bound waits (~3s each), so they are sequential — never t.Parallel. import ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/stretchr/testify/require" ) // enteredCreatePaymentClient blocks inside CreatePayment until the caller // confirms entry (closing entered), then delays `delay` before forwarding. // Because the online handler holds the crussell:payment: advisory // lock from BEFORE the pending insert through to AFTER CreatePayment returns, // a closed entered channel proves the lock is provably held — letting the test // deterministically fire a concurrent till cash payment into that window. type enteredCreatePaymentClient struct { square.SquareClient entered chan struct{} delay time.Duration } func (c *enteredCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { select { case <-c.entered: default: close(c.entered) } time.Sleep(c.delay) return c.SquareClient.CreatePayment(ctx, req) } // commitSetupTx commits the per-test transaction so the test operates at pool // level on independent connections (advisory locks only serialize across // independent connections; a shared per-test tx would mask the race). func commitSetupTx(t *testing.T, ctx context.Context) { t.Helper() innerTx := db.TxFromContext(ctx) require.NotNil(t, innerTx, "no transaction in context") require.NoError(t, innerTx.Commit(ctx)) } // TestTerminalCash_BlockedByConcurrentOnlineCharge pins the C2 fix: while an // online CreateBookingPayment charge holds the crussell:payment: // advisory lock across its Square round-trip, a till CASH payment on the SAME // booking must fail fast with 409 — never pass its remaining-balance check and // record a second amount (which buildSplitRecords would carve into a // non-refundable tip). Before the fix the till branch serialized only on the // bookings-row FOR UPDATE (a DIFFERENT primitive the online path never takes), // so both charges would land and the overflow became a silent tip. func TestTerminalCash_BlockedByConcurrentOnlineCharge(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") cleanupConcurrentTestRows(t, context.Background(), userID, bookingID) commitSetupTx(t, ctx) // The online charge holds the lock for ~4s (longer than the ~3s bounded // try-lock), so the concurrent till cash request is guaranteed to hit a // contended lock and give up with 409. origClient := SquareClient slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second} SquareClient = slow defer func() { SquareClient = origClient }() pool := context.Background() cardToken := "cnon:c2-online-inflight" onlineReq := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "c2-online-inflight-" + bookingID, } var wg sync.WaitGroup var onlineRec *httptest.ResponseRecorder wg.Add(1) go func() { defer wg.Done() onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool) }() // Block until the online charge is inside the Square call — the advisory // lock is provably held from that point until the handler returns. <-slow.entered cashReq := CreateTerminalPaymentRequest{ Amount: 2000, PaymentType: "full", PaymentMethod: strPtr("cash"), } cashRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", cashReq, adminToken, pool) require.Equal(t, http.StatusConflict, cashRec.Code, "till cash payment must 409 while an online charge holds the payment lock, body: %s", cashRec.Body.String()) wg.Wait() require.Equal(t, http.StatusOK, onlineRec.Code, "the online charge must succeed once the till payment was blocked: %s", onlineRec.Body.String()) // No cash payment row, no tip row (the till payment was never recorded), // and the booking's real-money ledger is exactly the online charge. assertNoTillMoneyRecorded(t, pool, bookingID, 5000) } // TestTerminalGiftCard_BlockedByConcurrentOnlineCharge is the gift-card half // of the C2 fix: the till GIFT-CARD branch takes the same advisory lock, so a // concurrent online charge blocks it too (the pre-fix FOR UPDATE only // serialized against other row-lock holders). func TestTerminalGiftCard_BlockedByConcurrentOnlineCharge(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") cleanupConcurrentTestRows(t, context.Background(), userID, bookingID) commitSetupTx(t, ctx) origClient := SquareClient slow := &enteredCreatePaymentClient{SquareClient: square.NewDevClient(), entered: make(chan struct{}), delay: 4 * time.Second} SquareClient = slow defer func() { SquareClient = origClient }() pool := context.Background() cardToken := "cnon:c2-online-inflight-gc" onlineReq := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &cardToken, IdempotencyKey: "c2-online-inflight-gc-" + bookingID, } var wg sync.WaitGroup var onlineRec *httptest.ResponseRecorder wg.Add(1) go func() { defer wg.Done() onlineRec = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", onlineReq, userToken, pool) }() <-slow.entered gcReq := CreateTerminalPaymentRequest{ Amount: 2000, PaymentType: "full", PaymentMethod: strPtr("giftcard"), GiftCardID: strPtr("GC-0000-0000-0000"), } gcRec := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", gcReq, adminToken, pool) require.Equal(t, http.StatusConflict, gcRec.Code, "till gift-card payment must 409 while an online charge holds the payment lock, body: %s", gcRec.Body.String()) wg.Wait() require.Equal(t, http.StatusOK, onlineRec.Code, onlineRec.Body.String()) assertNoTillMoneyRecorded(t, pool, bookingID, 5000) } // TestOnlineCharge_BlockedWhileTillHoldsLock is the reverse direction of the // C2 fix: while the till cash/giftcard branch holds the SAME advisory lock // (here held directly to deterministically simulate a till charge in flight), // an online CreateBookingPayment charge must fail fast with 409 — the two // paths now contend on one primitive instead of passing their independent // remaining-balance checks. func TestOnlineCharge_BlockedWhileTillHoldsLock(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestDataPast(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) cleanupConcurrentTestRows(t, context.Background(), userID, bookingID) commitSetupTx(t, ctx) pool := context.Background() key := "crussell:payment:" + bookingID // Simulate a till cash/giftcard charge in flight: hold the same advisory // lock on a dedicated connection for longer than the ~3s try-lock bound. holder, err := db.Conn.Acquire(pool) require.NoError(t, err) defer holder.Release() _, err = holder.Exec(pool, `SELECT pg_advisory_lock(hashtext($1))`, key) require.NoError(t, err) defer func() { _, _ = holder.Exec(pool, `SELECT pg_advisory_unlock(hashtext($1))`, key) }() cardToken := "cnon:c2-online-blocked" req := CreateBookingPaymentRequest{ Amount: 2000, PaymentType: "partial", NewCardToken: &cardToken, IdempotencyKey: "c2-online-blocked-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, pool) require.Equal(t, http.StatusConflict, w.Code, "online charge must 409 while the till holds the payment lock, body: %s", w.Body.String()) var payCount int require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)) require.Zero(t, payCount, "no payment record may exist when the online charge was blocked") } // assertNoTillMoneyRecorded pins the "not silently double-record" half of C2: // after a till cash/giftcard payment was blocked, the booking must carry no // till-originated row, no tip row (the pre-fix double-charge carved the // overflow into a non-refundable tip), and exactly wantPence of real-money // completed payments. func assertNoTillMoneyRecorded(t *testing.T, ctx context.Context, bookingID string, wantPence int64) { t.Helper() var cashRows int require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&cashRows)) require.Zero(t, cashRows, "the blocked till cash payment must not be recorded") var gcRows int require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'giftcard'`, bookingID).Scan(&gcRows)) require.Zero(t, gcRows, "the blocked till gift-card payment must not be recorded") var tipRows int require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipRows)) require.Zero(t, tipRows, "no non-refundable tip row may be minted by the blocked till payment") var paidPence int64 require.NoError(t, db.Conn.QueryRow(ctx, ` SELECT COALESCE(ROUND(SUM(amount) * 100), 0) FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip' AND payment_method NOT IN ('discount', 'on_the_house') `, bookingID).Scan(&paidPence)) require.Equal(t, wantPence, paidPence, "the booking ledger must reflect exactly the online charge") } // TestCreateBookingPayment_SCATokenizeResult_UsedAsSource pins the SCA wire // contract: a saved-card charge carrying new_card_token (the SCA // tokenize-result from card.tokenize(verificationDetails, cardId)) plus // saved_card_id must call Square with source_id = the tokenize-result token — // NOT the stored ccof id — and with customer_id derived from the saved card, // and must NOT require a verification_token for the new path. func TestCreateBookingPayment_SCATokenizeResult_UsedAsSource(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-test", "VISA", "4242") require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) rec := installRecordingClient(t) token := "cnon:sca-tokenize-result" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &token, UserSavedCardID: &cardID, IdempotencyKey: "sca-tokenize-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) rec.mu.Lock() last := rec.lastReq rec.mu.Unlock() require.Equal(t, token, last.SourceID, "the SCA tokenize-result token must be the charge source_id") require.NotEqual(t, "ccof:sca-tokenize-test", last.SourceID, "the stored ccof id must NOT be the source for the tokenize-result flow") require.NotEmpty(t, last.CustomerID, "customer_id must derive from the saved card row") require.Empty(t, last.VerificationToken, "the tokenize-result flow must not require a legacy verification_token") // The recorded payment row must carry the token as its square source and // reference the saved card row. var squareSource, uscID sql.NullString require.NoError(t, tx.QueryRow(ctx, `SELECT square_source_id, user_saved_card_id FROM payments WHERE booking_id = $1`, bookingID).Scan(&squareSource, &uscID)) require.Equal(t, token, squareSource.String, "the payment row must record the tokenize-result token as its square source") require.Equal(t, cardID, uscID.String, "the payment row must reference the saved card") } // TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate pins that the SCA // tokenize-result flow is SCA-primary: with 2FA enforced, a charge carrying // new_card_token + saved_card_id (no verification_code, no verification_token) // succeeds — the tokenize-result token itself proves buyer verification, so no // homegrown fallback authorization is demanded. func TestCreateBookingPayment_SCATokenizeResult_Skips2FAGate(t *testing.T) { helperEnvEnforce2FAStaging(t) ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sca-tokenize-2fa", "VISA", "4242") require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) installRecordingClient(t) token := "cnon:sca-tokenize-2fa" req := CreateBookingPaymentRequest{ Amount: 5000, PaymentType: "full", NewCardToken: &token, UserSavedCardID: &cardID, IdempotencyKey: "sca-tokenize-2fa-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "an SCA tokenize-result charge must skip the 2FA gate, body: %s", w.Body.String()) } // TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog locks the M8 fix // for the SYNC path: when the RefundPayment handler's FIRST Square attempt // returns COMPLETED immediately (no pending row for the sweep to re-issue), the // synchronous terminal-success path must write the SAME admin_audit_log row the // sweep's re-issue writes — action 'admin_refund', admin actor, payment id, // pence amount and reason, via the shared insertManualRefundAudit helper. // The row is marked completed so the sweep (which only processes still-pending // rows) can never re-process this refund, guaranteeing exactly one audit row. // Mirrors TestManualRefund_IssuesAdminAuditLog (refunds_test.go) but drives // the handler end-to-end instead of the sweep. func TestManualRefund_SynchronousCompletion_IssuesAdminAuditLog(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) 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) paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed") require.NoError(t, err) // The mock refunds any non-"pay_mock_" id leniently and returns COMPLETED // synchronously, so the handler's FIRST attempt completes immediately. const chargeID = "sqp_m8_sync_audit" _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", chargeID, paymentID) require.NoError(t, err) // The audit insert runs in its OWN transaction via InsertAdminAuditCharge // (never the per-test tx), and the refund's advisory-lock serialization // needs pool-level rows — commit the setup so the sync path executes at // pool level, exactly like the sweep test. commitSetupTx(t, ctx) freshCtx := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_audit_log WHERE action_type = 'admin_refund' AND admin_id = $1`, adminID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE payment_id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, adminID) }) origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() adminToken := jwt.GenerateTestToken(adminID, "admin") req := RefundRequest{Amount: 5000, Reason: "customer request"} rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, freshCtx) require.Equal(t, http.StatusOK, rec.Code, "synchronous refund should complete 200, body: %s", rec.Body.String()) var resp RefundResponse require.NoError(t, parsePaymentResponseBody(rec, &resp)) require.Equal(t, "completed", resp.Status, "the synchronous COMPLETED refund must resolve to 'completed'") // The refund row must be completed so the sweep can never re-process it // (and duplicate the audit). var status string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, resp.ID).Scan(&status)) require.Equal(t, "completed", status) // Exactly one admin_refund audit row, carrying payment id, pence amount and // reason. var auditCount int require.NoError(t, db.Conn.QueryRow(freshCtx, ` SELECT COUNT(*) FROM admin_audit_log WHERE admin_id = $1 AND action_type = 'admin_refund' `, adminID).Scan(&auditCount)) require.Equal(t, 1, auditCount, "expected exactly 1 'admin_refund' audit row for the synchronous refund") var details string require.NoError(t, db.Conn.QueryRow(freshCtx, ` SELECT details::text FROM admin_audit_log WHERE admin_id = $1 AND action_type = 'admin_refund' `, adminID).Scan(&details)) for _, want := range []string{paymentID, "5000", "customer request"} { require.True(t, strings.Contains(details, want), "expected audit details to carry %q, got %s", want, details) } } // TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds // pins the M11 fix: in an ENFORCED (SCA-only) deployment a card save that // carries a GENUINE SCA tokenize-result as card_token succeeds — the // STORE-intent SCA performed at tokenization (SquareCardInput.tokenizeForStore) // IS the verification (PSR 2017 reg 100), so the save skips the 2FA gate at the // call site exactly like the charge surfaces' scaTokenizedSavedCard path, and // the card is persisted. Before the fix the handler demanded the (removed) 2FA // fallback and refused every save 402 verification_required, so add-card could // not complete in an enforced deployment. func TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds(t *testing.T) { helperEnvEnforce2FAStaging(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) t.Cleanup(func() { InvalidateSquareCustomerCache(userID) }) origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() req := CreatePaymentMethodRequest{CardToken: "cnon:sca-tokenize-store"} w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx) require.Equal(t, http.StatusOK, w.Code, "a genuine SCA tokenize-result card save must succeed in an enforced deployment, body: %s", w.Body.String()) var card SavedCard require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card)) require.NotEmpty(t, card.ID, "the saved card must be returned") var cardCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount)) require.Equal(t, 1, cardCount, "the SCA-compliant save must persist exactly one card") } // TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402 pins // auth-F1 on the add-card surface: a verification_token is CLIENT-ASSERTED and // never forwarded to Square on a SAVE surface (CreateCardOnFile takes no token), // so it must NOT skip the gate — enforced + a NON-token-like card_token (a raw // PAN — the only shape the gate still refuses) + a (forged) non-empty // verification_token is refused 402 verification_required and no card is // persisted. The M11 fix cannot be a client-asserted token bypass. func TestTwoFactorEnforced_CreatePaymentMethod_ForgeVerificationToken_402(t *testing.T) { helperEnvEnforce2FAStaging(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) forged := "forged-verification-token" req := CreatePaymentMethodRequest{ CardToken: "4111111111111111", VerificationToken: &forged, } w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a forged verification_token must not skip the save gate, body: %s", w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) var cardCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount)) require.Zero(t, cardCount, "a refused forged-token save must not persist a card") } // TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402 pins the M11 // refusal half in the SCA suite's own staging helper: a NON-token-like save // source (a raw PAN — the gate's only remaining refusal shape) is refused 402 // verification_required in an enforced deployment and no card is persisted. A // GENUINE token-like source (cnon:/ccof:) is SCA-proven — the STORE-intent SCA // ran at tokenization — and skips the gate (see // TestTwoFactorEnforced_CreatePaymentMethod_SCATokenizeResult_Save_Succeeds). // Companion to TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_402 // (errors_test.go). func TestTwoFactorEnforced_CreatePaymentMethod_Tokenless_Refused402(t *testing.T) { helperEnvEnforce2FAStaging(t) ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) req := CreatePaymentMethodRequest{CardToken: "4111111111111111"} w := makePaymentRequest(CreatePaymentMethod, "POST", "/api/user/payment-methods", req, token, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a token-less save must be refused 402 in an enforced deployment, body: %s", w.Body.String()) var body map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.Equal(t, "verification_required", body["code"]) var cardCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&cardCount)) require.Zero(t, cardCount, "a refused token-less save must not persist a card") }