//go:build test && dev package payments // ============================================================================= // LOOP B — Round-2 money findings. Each test pins a fixed behaviour and would // fail on the pre-fix code. // ============================================================================= import ( "context" "encoding/json" "net/http" "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" ) // ============================================================================= // Finding 1 — B1 re-poll race: a webhook-promoted 'completed' sweepdup refund // whose PARENT payment row is still pending must still be resolved by the B1 // re-poll pass, and the sweep's in-flight guard must keep treating such a // completed refund as in-flight (never re-replaying the expired key). // ============================================================================= // seedCompletedB1RefundAndPendingParent seeds a payments-table B1 sweep // auto-refund row with status 'completed' (as the webhook's COMPLETED // promotion leaves it) on a still-pending parent payment — the stranded // state the re-poll pass must resolve. func seedCompletedB1RefundAndPendingParent(t *testing.T, ctx context.Context, tx db.Querier, userID, bookingID string, amount float64, squareRefundID, refundKey string) (paymentID, refundID string) { t.Helper() pid, err := fixtures.CreateTestPayment(tx, bookingID, amount, "online_square", "full", "pending") if err != nil { t.Fatalf("failed to create pending parent payment: %v", err) } var rid string err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, origin, reason, idempotency_key, created_by, created_at) VALUES ($1, $2, $3, $4, 'completed', 'manual', $5, $6, $7, NOW()) RETURNING id `, pid, bookingID, amount, squareRefundID, sweepDuplicateRefundReason, refundKey, userID).Scan(&rid) if err != nil { t.Fatalf("failed to insert webhook-completed B1 refund row: %v", err) } return pid, rid } // TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight locks the // in-flight guard widening (sweep.go hasInFlightSweepDuplicateRefund): a B1 // sweepdup refund a webhook promoted to 'completed' — while the parent payment // row is still pending — must STILL count as in-flight. Before the fix the // guard matched only 'pending', so the next sweep re-replayed the expired key // and minted ANOTHER charge before the re-poll pass resolved the parent. The // failed-refund guard must stay false for a completed (not failed) refund so // the row is never blind-failed on the replay path. func TestHasInFlightSweepDuplicateRefund_CompletedRefund_StillInFlight(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.CreateTestBooking(tx, userID, serviceID) require.NoError(t, err) paymentID, _ := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, "ref_b1_wbhk_inflight", "sweepdup-pay_dup_inflight") assert.True(t, hasInFlightSweepDuplicateRefund(ctx, "payments", paymentID), "a webhook-completed sweepdup refund on a pending parent must still count as in-flight") assert.False(t, hasFailedSweepDuplicateRefund(ctx, "payments", paymentID), "a completed (not failed) sweepdup refund must NOT trip the failed-refund guard") } // TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment locks // the re-poll query widening (refunds.go sweepPendingB1Refunds): a sweepdup // refund the webhook promoted to 'completed' — whose PARENT payment row is // still pending — is re-polled and, when Square confirms the refund COMPLETED, // the parent is finally marked failed. Before the fix the query matched only // 'pending' refunds, so the completed refund was never resolved and the parent // stayed pending forever (feeding the sweep replay loop). func TestSweepPendingB1Refunds_WebhookCompletedRefund_ResolvesParentPayment(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) const squareRefundID = "ref_b1_wbhk_completed" const refundKey = "sweepdup-pay_dup_wbhk" paymentID, refundID := seedCompletedB1RefundAndPendingParent(t, ctx, tx, userID, bookingID, 50.00, squareRefundID, refundKey) pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx, "no transaction in context") require.NoError(t, pgxTx.Commit(ctx), "failed to commit setup tx") t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, userID) _, _ = 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) }) origClient := SquareClient SquareClient = &b1RePollStatusClient{SquareClient: square.NewDevClient(), refundID: squareRefundID, status: "COMPLETED"} defer func() { SquareClient = origClient }() freshCtx := context.Background() if _, err := SweepPendingSquareRefunds(freshCtx); err != nil { t.Fatalf("SweepPendingSquareRefunds failed: %v", err) } var refundStatus string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&refundStatus)) assert.Equal(t, "completed", refundStatus, "the webhook-completed refund stays completed") var parentStatus string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM payments WHERE id = $1`, paymentID).Scan(&parentStatus)) assert.Equal(t, "failed", parentStatus, "the pending parent must be resolved to failed once the refund settled") } // ============================================================================= // Finding 3 — a Square APPROVED refund is NON-terminal: the call sites must // keep the refunds row pending (with the square_refund_id recorded) so a later // FAILED/CANCELED can demote it, instead of resolving it to 'completed' and // stranding it. // ============================================================================= // approvedRefundClient answers RefundPayment with Square status APPROVED — // the ambiguous authorization-only state that must stay pending locally. type approvedRefundClient struct { square.SquareClient } func (c *approvedRefundClient) RefundPayment(ctx context.Context, req square.RefundPaymentReq) (*square.RefundResult, error) { return &square.RefundResult{ID: "ref_approved_test", Status: "APPROVED", Amount: req.Amount, PaymentID: req.PaymentID}, nil } // TestRefundPayment_ApprovedStatus_LeavesRowPending pins the MED-HIGH finding // at the RefundPayment handler call site: a Square APPROVED refund is NOT // terminal — resolving it to 'completed' would strand the row (the FAILED // demotion only demotes 'pending'). The row must stay 'pending' with the // square_refund_id recorded, and the response must report 'pending'. func TestRefundPayment_ApprovedStatus_LeavesRowPending(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBooking(tx, userID, serviceID) require.NoError(t, err) paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 100.00, "online_square", "full", "completed") require.NoError(t, err) _, err = tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_approved' WHERE id = $1", paymentID) require.NoError(t, err) origClient := SquareClient SquareClient = &approvedRefundClient{SquareClient: square.NewDevClient()} defer func() { SquareClient = origClient }() adminToken := jwt.GenerateTestToken(adminID, "admin") req := RefundRequest{Amount: 5000, Reason: "customer request", IdempotencyKey: "approved-refund-" + bookingID} w := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "an APPROVED refund is non-terminal but the handler must still respond 200, body: %s", w.Body.String()) var body RefundResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, "pending", body.Status, "the response must report pending for an APPROVED refund") var status, sqRefundID string require.NoError(t, tx.QueryRow(ctx, `SELECT status, COALESCE(square_refund_id, '') FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID)) assert.Equal(t, "pending", status, "an APPROVED refund must leave the row pending, never completed") assert.Equal(t, "ref_approved_test", sqRefundID, "the square_refund_id must be recorded on the pending row so the re-poll can settle it") } // ============================================================================= // Finding 6 — guest-booking cash/gift-card terminal charges must write an // admin_audit_log row (target_user_id NULL) AFTER the money commits. // ============================================================================= // TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget pins the MEDIUM // finding: a CASH terminal charge on a GUEST booking (user_id NULL) previously // wrote NO audit row (the `if customerID.Valid` guard skipped it). The audit // must run with a NULL target_user_id — matching the till flow — after the // money transaction commits. func TestCreateTerminalPayment_GuestCash_AuditsWithNullTarget(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(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) // Guest booking: no account behind it. _, err = tx.Exec(ctx, `UPDATE bookings SET user_id = NULL, status = 'in_progress' WHERE id = $1`, bookingID) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") pm := "cash" req := CreateTerminalPaymentRequest{ Amount: 5000, PaymentType: "full", PaymentMethod: &pm, IdempotencyKey: "guest-cash-audit-" + bookingID, } w := makePaymentRequest(CreateTerminalPayment, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx) require.Equal(t, http.StatusOK, w.Code, "a guest cash terminal charge must complete, body: %s", w.Body.String()) var auditCount int require.NoError(t, tx.QueryRow(ctx, ` SELECT COUNT(*) FROM admin_audit_log WHERE action_type = 'admin_cash_charge' AND target_user_id IS NULL AND details->>'booking_id' = $1 `, bookingID).Scan(&auditCount)) assert.Equal(t, 1, auditCount, "a guest cash charge must audit with a NULL target_user_id") } // ============================================================================= // Finding 8 — the A6 skip path (deposit fully covered by discount) must bind // the request's idempotency key to a row so a same-key retry dedups instead of // re-running and potentially charging the full deposit. // ============================================================================= // TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups pins the // idempotency fix: after a discount-covered deposit skips the Square charge, // the discount row carries the request's idempotency key. A lost-response // same-key retry then short-circuits on the completed row — no second skip, no // Square call, no second discount redemption — where before it re-ran the // handler and could charge the full deposit once the campaign had exhausted. func TestBookingPayment_DiscountCoveredDeposit_SameKeyRetry_Dedups(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) userToken := jwt.GenerateUserToken(userID) seedActiveCampaign(t, ctx, tx, 100) origClient := SquareClient SquareClient = &failOnChargeClient{SquareClient: square.NewDevClient(), t: t} defer func() { SquareClient = origClient }() cardToken := "cnon:deposit-covered-dedup" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "deposit-covered-dedup-" + bookingID, } handler := CreateBookingPayment w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, "the discount-covered deposit must complete, body: %s", w.Body.String()) // The skip path bound the request key to the applied discount row. var keyedDiscountCount int require.NoError(t, tx.QueryRow(ctx, ` SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount' AND idempotency_key = $2 `, bookingID, req.IdempotencyKey).Scan(&keyedDiscountCount)) assert.Equal(t, 1, keyedDiscountCount, "the request's idempotency key must be bound to the discount row") // Same-key retry: short-circuits on the completed row — no Square charge, // no additional discount redemption. w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w2.Code, "a same-key retry must dedup to the completed result, body: %s", w2.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 retry must not re-apply (double-redeem) the campaign discount") }