- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete - giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed - sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT - till: suffixed-key slot scan lock held across Square round-trip Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
1007 lines
38 KiB
Go
1007 lines
38 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Cancellation-race recheck — booking cancelled between pending commit and tx2
|
|
// =============================================================================
|
|
|
|
// cancellingCreatePaymentClient cancels the booking just before the Square
|
|
// charge succeeds, simulating a concurrent cancellation landing between the
|
|
// pending-record commit (step 1) and the post-charge transaction (step 2).
|
|
type cancellingCreatePaymentClient struct {
|
|
square.SquareClient
|
|
bookingID string
|
|
}
|
|
|
|
func (c *cancellingCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
|
if _, err := db.Conn.Exec(ctx, `UPDATE bookings SET status = 'client_cancelled' WHERE id = $1`, c.bookingID); err != nil {
|
|
return nil, err
|
|
}
|
|
return c.SquareClient.CreatePayment(ctx, req)
|
|
}
|
|
|
|
func TestCreateBookingPayment_CancellationRace_MarksFailed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &cancellingCreatePaymentClient{SquareClient: square.NewDevClient(), bookingID: bookingID}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
key := "cancel-race-" + bookingID
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 when booking cancelled mid-charge, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The pending payment must be marked failed, never completed with splits.
|
|
var status string
|
|
err := tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payment status: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected payment status 'failed' after cancellation race, got %q", status)
|
|
}
|
|
|
|
// No completed payment may exist on the cancelled booking (the deposit
|
|
// would otherwise bypass the cancellation refund computation).
|
|
var completedCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&completedCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count completed payments: %v", err)
|
|
}
|
|
if completedCount != 0 {
|
|
t.Errorf("expected 0 completed payments on cancelled booking, got %d", completedCount)
|
|
}
|
|
|
|
// The booking itself stays cancelled.
|
|
var bookingStatus string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query booking status: %v", err)
|
|
}
|
|
if bookingStatus != "client_cancelled" {
|
|
t.Errorf("expected booking to remain client_cancelled, got %q", bookingStatus)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tips on cancelled bookings rejected
|
|
// =============================================================================
|
|
|
|
func TestCreateTipPayment_RejectsCancelledBooking(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "client_cancelled")
|
|
|
|
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
|
t.Fatalf("failed to create completed payment: %v", err)
|
|
}
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 for tip on cancelled booking, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// No tip payment record may be created.
|
|
var tipCount int
|
|
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip'`, bookingID).Scan(&tipCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count tip payments: %v", err)
|
|
}
|
|
if tipCount != 0 {
|
|
t.Errorf("expected 0 tip payments on cancelled booking, got %d", tipCount)
|
|
}
|
|
}
|
|
|
|
func TestCreateTipPayment_RejectsNoShowBooking(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "no_show")
|
|
|
|
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
|
t.Fatalf("failed to create completed payment: %v", err)
|
|
}
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateTipPaymentRequest{Amount: 500, NewCardToken: &cardToken}
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 for tip on no-show booking, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ReleasePaymentLock ownership checks
|
|
// =============================================================================
|
|
|
|
func TestReleasePaymentLock_CrossUserRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
ownerToken := jwt.GenerateUserToken(userID)
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code)
|
|
}
|
|
|
|
otherUserID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create other user: %v", err)
|
|
}
|
|
otherToken := jwt.GenerateUserToken(otherUserID)
|
|
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", otherToken, ctx)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for cross-user release, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var lockCount int
|
|
err = tx.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM time_blockers WHERE description = 'PAYMENT_IN_FLIGHT:' || $1", bookingID).Scan(&lockCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query time_blockers: %v", err)
|
|
}
|
|
if lockCount != 1 {
|
|
t.Errorf("expected lock to remain after cross-user release, got %d blockers", lockCount)
|
|
}
|
|
}
|
|
|
|
func TestReleasePaymentLock_AdminAllowed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
ownerToken := jwt.GenerateUserToken(userID)
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code)
|
|
}
|
|
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", jwt.GenerateAdminToken(), ctx)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for admin release, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReleasePaymentLock_UnauthenticatedRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
ownerToken := jwt.GenerateUserToken(userID)
|
|
lockW := paymentLockRequest("POST", "/api/bookings/"+bookingID+"/payment-lock", ownerToken, ctx)
|
|
if lockW.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 acquiring lock, got %d", lockW.Code)
|
|
}
|
|
|
|
w := releasePaymentLockRequest("/api/bookings/"+bookingID+"/payment-lock", "", ctx)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated release, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// GetBookingPaymentSummary fail-closed auth
|
|
// =============================================================================
|
|
|
|
func TestGetBookingPaymentSummary_UnauthenticatedRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/payment-summary", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
GetBookingPaymentSummary(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for unauthenticated summary request, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Terminal payment type recorded + in-flight checkout guard
|
|
// =============================================================================
|
|
|
|
// createTerminalCheckoutWithType is like createTerminalCheckout but records the
|
|
// given payment type instead of hardcoding "full".
|
|
func createTerminalCheckoutWithType(t *testing.T, ctx context.Context, bookingID, adminToken string, amount int64, paymentType string) string {
|
|
t.Helper()
|
|
handler := CreateTerminalPayment
|
|
req := CreateTerminalPaymentRequest{
|
|
Amount: amount,
|
|
PaymentType: paymentType,
|
|
}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
var createResp CheckoutResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
|
|
t.Fatalf("failed to decode create response: %v", err)
|
|
}
|
|
if createResp.CheckoutID == "" {
|
|
t.Fatal("expected checkout_id to be set")
|
|
}
|
|
return createResp.CheckoutID
|
|
}
|
|
|
|
func TestGetCheckoutStatus_RecordsChargedPaymentType(t *testing.T) {
|
|
origClient := SquareClient
|
|
SquareClient = &testCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
hexIDs: make(map[string]string),
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
checkoutID := createTerminalCheckoutWithType(t, ctx, bookingID, adminToken, 5000, "balance")
|
|
|
|
resp := pollCheckoutStatus(t, ctx, checkoutID, bookingID, adminToken)
|
|
if resp.PaymentID == "" {
|
|
t.Fatal("expected payment_id to be set")
|
|
}
|
|
|
|
var paymentType string
|
|
err := tx.QueryRow(ctx, `SELECT payment_type FROM payments WHERE id = $1`, resp.PaymentID).Scan(&paymentType)
|
|
if err != nil {
|
|
t.Fatalf("failed to query payment type: %v", err)
|
|
}
|
|
if paymentType != "balance" {
|
|
t.Errorf("expected recorded payment_type 'balance', got %q", paymentType)
|
|
}
|
|
}
|
|
|
|
func TestCreateTerminalPayment_InFlightGuard_ReturnsExisting(t *testing.T) {
|
|
origClient := SquareClient
|
|
mc := square.NewDevClient().(*square.MockClient)
|
|
mc.HoldCheckouts = true // keep the first checkout pending so the guard fires
|
|
SquareClient = mc
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
first := createTerminalCheckout(t, ctx, bookingID, adminToken, 5000)
|
|
|
|
// A second attempt while the first is still in flight must reuse it, not
|
|
// create a second live checkout.
|
|
handler := CreateTerminalPayment
|
|
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp CheckoutResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if resp.CheckoutID != first {
|
|
t.Errorf("expected the in-flight checkout %q to be returned, got %q", first, resp.CheckoutID)
|
|
}
|
|
|
|
var rowCount int
|
|
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1`, bookingID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count terminal checkouts: %v", err)
|
|
}
|
|
if rowCount != 1 {
|
|
t.Errorf("expected exactly 1 terminal_checkouts row, got %d", rowCount)
|
|
}
|
|
}
|
|
|
|
func TestCreateTerminalPayment_InFlightGuard_AllowsAfterCompletion(t *testing.T) {
|
|
origClient := SquareClient
|
|
SquareClient = &testCheckoutClient{
|
|
SquareClient: square.NewDevClient(),
|
|
hexIDs: make(map[string]string),
|
|
}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
checkoutA := createTerminalCheckout(t, ctx, bookingID, adminToken, 3000)
|
|
pollCheckoutStatus(t, ctx, checkoutA, bookingID, adminToken)
|
|
|
|
// Once the first checkout is recorded COMPLETED, a new charge is allowed
|
|
// (B3: a second charge is clamped to the remaining obligation, so a £20
|
|
// charge after a £30 one on the £50 booking is fine).
|
|
checkoutB := createTerminalCheckout(t, ctx, bookingID, adminToken, 2000)
|
|
if checkoutB == checkoutA {
|
|
t.Error("expected a new checkout after the previous one completed")
|
|
}
|
|
pollCheckoutStatus(t, ctx, checkoutB, bookingID, adminToken)
|
|
|
|
var rowCount int
|
|
err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1`, bookingID).Scan(&rowCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to count terminal checkouts: %v", err)
|
|
}
|
|
if rowCount != 2 {
|
|
t.Errorf("expected 2 terminal_checkouts rows (one completed, one new), got %d", rowCount)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Discount preview — global milestone + shared-eligibility semantics
|
|
// =============================================================================
|
|
|
|
func TestDiscountPreview_GlobalMilestoneIncluded(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create past booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID); err != nil {
|
|
t.Fatalf("failed to complete past booking: %v", err)
|
|
}
|
|
|
|
// The first payment on the booking is in-person, which is the global
|
|
// milestone's eligibility condition.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
|
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
|
`, bookingID); err != nil {
|
|
t.Fatalf("failed to create in-person payment: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
|
|
VALUES ('Global Milestone', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 1, 100, 0)
|
|
`); err != nil {
|
|
t.Fatalf("failed to create global milestone campaign: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp DiscountPreviewResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, d := range resp.Discounts {
|
|
if d.Source == "campaign" && d.Percent == 10 {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected the global milestone discount in the preview, got %+v", resp.Discounts)
|
|
}
|
|
}
|
|
|
|
func TestDiscountPreview_Anniversary_AlreadyAppliedSkipped(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
// First visit years ago so anniversary campaigns qualify.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status) VALUES ($1, $2, 'completed')
|
|
`, userID, time.Date(2020, 1, 15, 10, 0, 0, 0, time.UTC)); err != nil {
|
|
t.Fatalf("failed to create first-visit booking: %v", err)
|
|
}
|
|
|
|
// Campaign A: already applied to this booking.
|
|
var campA string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit)
|
|
VALUES ('Anniv A', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years')
|
|
RETURNING id
|
|
`).Scan(&campA)
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign A: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
|
|
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', 10, 100, 10)
|
|
`, bookingID, userID, campA); err != nil {
|
|
t.Fatalf("failed to apply campaign A: %v", err)
|
|
}
|
|
|
|
// Campaign B: eligible.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, milestone_unit)
|
|
VALUES ('Anniv B', 'milestone', 15, 'active', NOW(), NOW() + INTERVAL '1 year', 'anniversary', 1, 'years')
|
|
`); err != nil {
|
|
t.Fatalf("failed to create campaign B: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp DiscountPreviewResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
for _, d := range resp.Discounts {
|
|
if d.Name == "Anniv A" {
|
|
t.Error("expected the already-applied anniversary campaign to be skipped")
|
|
}
|
|
}
|
|
foundB := false
|
|
for _, d := range resp.Discounts {
|
|
if d.Name == "Anniv B" {
|
|
foundB = true
|
|
break
|
|
}
|
|
}
|
|
if !foundB {
|
|
t.Errorf("expected the eligible anniversary campaign in the preview, got %+v", resp.Discounts)
|
|
}
|
|
}
|
|
|
|
// TestDiscountPreview_ManyCampaignsExercisesSharedEligibility creates several
|
|
// campaigns (time-based + milestone + global) and confirms the shared
|
|
// ComputeEligibleDiscounts helper aggregates them correctly in the preview.
|
|
func TestDiscountPreview_ManyCampaignsExercisesSharedEligibility(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, token := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
serviceID, err := fixtures.CreateTestService(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create service: %v", err)
|
|
}
|
|
pastBookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
|
|
if err != nil {
|
|
t.Fatalf("failed to create past booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE bookings SET status = 'completed' WHERE id = $1`, pastBookingID); err != nil {
|
|
t.Fatalf("failed to complete past booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
|
VALUES ($1, 'full', 'in_person_card', 5000, 'completed', NOW(), NOW())
|
|
`, bookingID); err != nil {
|
|
t.Fatalf("failed to create in-person payment: %v", err)
|
|
}
|
|
|
|
now := clock.Now()
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ('Time Sale', 'time_based', 5, 'active', $1, $2)
|
|
`, now.Add(-24*time.Hour), now.Add(24*time.Hour)); err != nil {
|
|
t.Fatalf("failed to create time-based campaign: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, milestone_type, milestone_value, max_redemptions, times_redeemed)
|
|
VALUES ('Global 1st', 'milestone', 10, 'active', NOW(), NOW() + INTERVAL '1 year', 'global_booking_count', 1, 100, 0)
|
|
`); err != nil {
|
|
t.Fatalf("failed to create global campaign: %v", err)
|
|
}
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, userID, token, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp DiscountPreviewResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if len(resp.Discounts) < 2 {
|
|
t.Errorf("expected time-based + global milestone discounts in preview, got %+v", resp.Discounts)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Verification token passthrough to Square
|
|
// =============================================================================
|
|
|
|
type recordingPaymentClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
lastReq square.CreatePaymentReq
|
|
}
|
|
|
|
func (c *recordingPaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
|
c.mu.Lock()
|
|
c.lastReq = req
|
|
c.mu.Unlock()
|
|
return c.SquareClient.CreatePayment(ctx, req)
|
|
}
|
|
|
|
func TestCreateBookingPayment_VerificationTokenPassthrough(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
vrf := "vrf_booking_token_123"
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "vrf-booking-" + bookingID,
|
|
VerificationToken: &vrf,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.VerificationToken
|
|
rec.mu.Unlock()
|
|
if got != vrf {
|
|
t.Errorf("expected VerificationToken %q passed to Square, got %q", vrf, got)
|
|
}
|
|
if rec.lastReq.BuyerEmail == "" {
|
|
t.Error("expected BuyerEmail to be populated for booking payment")
|
|
}
|
|
}
|
|
|
|
func TestCreateTipPayment_VerificationTokenPassthrough(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
// Past start so the tip passes the start-time guard.
|
|
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
|
|
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
|
t.Fatalf("failed to create completed payment: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
vrf := "vrf_tip_token_456"
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateTipPaymentRequest{
|
|
Amount: 500,
|
|
NewCardToken: &cardToken,
|
|
VerificationToken: &vrf,
|
|
}
|
|
|
|
handler := CreateTipPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/tip", req, userToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.VerificationToken
|
|
rec.mu.Unlock()
|
|
if got != vrf {
|
|
t.Errorf("expected VerificationToken %q passed to Square, got %q", vrf, got)
|
|
}
|
|
}
|
|
|
|
func TestCreateBookingPayment_VerificationTokenTooLongRejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
long := make([]byte, 600)
|
|
for i := range long {
|
|
long[i] = 'a'
|
|
}
|
|
big := string(long)
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "vrf-too-long-" + bookingID,
|
|
VerificationToken: &big,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for oversized verification token, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Deposit-promotion SUM excludes tip rows
|
|
// =============================================================================
|
|
|
|
func TestBookingPayment_PromotionThreshold_ExcludesTipRows(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "pending_release")
|
|
|
|
// A tip on the booking must not count toward the 20% promotion threshold.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
|
VALUES ($1, 'tip', 'in_person_card', 1000, 'completed', NOW(), NOW())
|
|
`, bookingID); err != nil {
|
|
t.Fatalf("failed to create tip payment: %v", err)
|
|
}
|
|
|
|
// 15% of the total via a real payment — below the 20% threshold even with
|
|
// the tip present.
|
|
var total float64
|
|
if err := tx.QueryRow(ctx, `SELECT total_amount FROM bookings WHERE id = $1`, bookingID).Scan(&total); err != nil {
|
|
t.Fatalf("failed to read booking total: %v", err)
|
|
}
|
|
amount := int64(total * 100 * 0.15)
|
|
|
|
cardToken := "cnon:test-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: amount,
|
|
PaymentType: "partial",
|
|
NewCardToken: &cardToken,
|
|
IdempotencyKey: "promo-tip-" + bookingID,
|
|
}
|
|
|
|
handler := CreateBookingPayment
|
|
w := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var status string
|
|
err := tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query booking status: %v", err)
|
|
}
|
|
if status != "pending_release" {
|
|
t.Errorf("expected booking to remain pending_release (tip excluded from threshold), got %q", status)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ApplyEligibleDiscount — counter/used-flag survive a payments-INSERT failure
|
|
// =============================================================================
|
|
|
|
// failingExecQuerier wraps a db.Querier and fails any Exec whose SQL contains
|
|
// the target fragment, simulating a constraint/connection error on that one
|
|
// statement while delegating everything else to the wrapped querier.
|
|
type failingExecQuerier struct {
|
|
db.Querier
|
|
failSQLContains string
|
|
}
|
|
|
|
func (f failingExecQuerier) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
|
if strings.Contains(sql, f.failSQLContains) {
|
|
return pgconn.CommandTag{}, errors.New("simulated failure on " + f.failSQLContains)
|
|
}
|
|
return f.Querier.Exec(ctx, sql, args...)
|
|
}
|
|
|
|
// seedTestCampaign inserts an active time_based campaign and returns its id.
|
|
func seedTestCampaign(t *testing.T, ctx context.Context, q db.Querier) string {
|
|
t.Helper()
|
|
var campaignID string
|
|
err := q.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ('Test Campaign', 'time_based', 10.00, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '30 days')
|
|
RETURNING id
|
|
`).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed test campaign: %v", err)
|
|
}
|
|
return campaignID
|
|
}
|
|
|
|
func TestApplyEligibleDiscount_CampaignPaymentInsertFailure_StillIncrementsCounter(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
campaignID := seedTestCampaign(t, ctx, tx)
|
|
|
|
// The payments INSERT fails AFTER the booking_discounts row was inserted,
|
|
// so the redemption happened — the counter MUST still increment.
|
|
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"}
|
|
if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
|
|
Source: "campaign",
|
|
Name: "Test Campaign",
|
|
Percent: 10.00,
|
|
Amount: 10.00,
|
|
SourceID: campaignID,
|
|
CampaignType: "time_based",
|
|
}); err != nil {
|
|
t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err)
|
|
}
|
|
|
|
var redeemed int
|
|
if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil {
|
|
t.Fatalf("failed to read campaign counter: %v", err)
|
|
}
|
|
if redeemed != 1 {
|
|
t.Errorf("expected times_redeemed incremented to 1 despite the payment-record insert failure, got %d", redeemed)
|
|
}
|
|
|
|
var bdCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND source_id = $2`, bookingID, campaignID).Scan(&bdCount); err != nil {
|
|
t.Fatalf("failed to count booking_discounts: %v", err)
|
|
}
|
|
if bdCount != 1 {
|
|
t.Errorf("expected 1 booking_discounts row, got %d", bdCount)
|
|
}
|
|
|
|
var discountPayments int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPayments); err != nil {
|
|
t.Fatalf("failed to count discount payments: %v", err)
|
|
}
|
|
if discountPayments != 0 {
|
|
t.Errorf("expected NO discount payment row (the insert was simulated to fail), got %d", discountPayments)
|
|
}
|
|
}
|
|
|
|
func TestApplyEligibleDiscount_ReferralPaymentInsertFailure_StillMarksUsed(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
referredID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create referred user: %v", err)
|
|
}
|
|
var referralID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO user_referrals (referrer_id, referred_id) VALUES ($1, $2) RETURNING id
|
|
`, userID, referredID).Scan(&referralID); err != nil {
|
|
t.Fatalf("failed to seed user referral: %v", err)
|
|
}
|
|
var rdID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO referral_discounts (user_id, referral_id, discount_percent) VALUES ($1, $2, 10.00) RETURNING id
|
|
`, userID, referralID).Scan(&rdID); err != nil {
|
|
t.Fatalf("failed to seed referral discount: %v", err)
|
|
}
|
|
|
|
// The payments INSERT fails AFTER the referral's booking_discounts row was
|
|
// inserted, so the discount WAS redeemed — the used flag MUST still be set.
|
|
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO payments"}
|
|
if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
|
|
Source: "referral",
|
|
Name: "Referral Discount (10%)",
|
|
Percent: 10.00,
|
|
Amount: 10.00,
|
|
SourceID: rdID,
|
|
IsReferral: true,
|
|
}); err != nil {
|
|
t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err)
|
|
}
|
|
|
|
var used bool
|
|
if err := tx.QueryRow(ctx, `SELECT used FROM referral_discounts WHERE id = $1`, rdID).Scan(&used); err != nil {
|
|
t.Fatalf("failed to read referral discount used flag: %v", err)
|
|
}
|
|
if !used {
|
|
t.Error("expected referral discount marked used despite the payment-record insert failure")
|
|
}
|
|
|
|
var bdCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&bdCount); err != nil {
|
|
t.Fatalf("failed to count booking_discounts: %v", err)
|
|
}
|
|
if bdCount != 1 {
|
|
t.Errorf("expected 1 referral booking_discounts row, got %d", bdCount)
|
|
}
|
|
}
|
|
|
|
func TestApplyEligibleDiscount_BookingDiscountsInsertFailure_CounterStillReserved(t *testing.T) {
|
|
// B13 reservation-first semantics: the campaign counter increment (the
|
|
// atomic conditional reservation) happens BEFORE the booking_discounts
|
|
// insert, so a booking_discounts-INSERT failure no longer leaves the
|
|
// counter untouched — the redemption slot was consumed and max_redemptions
|
|
// bounds the lost reservation (the next eligible booking finds the campaign
|
|
// with one fewer redemption). No discount payment row is minted.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
campaignID := seedTestCampaign(t, ctx, tx)
|
|
|
|
failing := failingExecQuerier{Querier: tx, failSQLContains: "INSERT INTO booking_discounts"}
|
|
if err := ApplyEligibleDiscount(ctx, failing, bookingID, userID, 100.00, EligibleDiscount{
|
|
Source: "campaign",
|
|
Name: "Test Campaign",
|
|
Percent: 10.00,
|
|
Amount: 10.00,
|
|
SourceID: campaignID,
|
|
CampaignType: "time_based",
|
|
}); err != nil {
|
|
t.Fatalf("ApplyEligibleDiscount must not return an error here: %v", err)
|
|
}
|
|
|
|
var redeemed int
|
|
if err := tx.QueryRow(ctx, `SELECT times_redeemed FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&redeemed); err != nil {
|
|
t.Fatalf("failed to read campaign counter: %v", err)
|
|
}
|
|
if redeemed != 1 {
|
|
t.Errorf("expected times_redeemed incremented to 1 by the reservation despite the booking_discounts insert failure, got %d", redeemed)
|
|
}
|
|
|
|
var discountPayments int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'discount'`, bookingID).Scan(&discountPayments); err != nil {
|
|
t.Fatalf("failed to count discount payments: %v", err)
|
|
}
|
|
if discountPayments != 0 {
|
|
t.Errorf("expected NO discount payment row (the booking_discounts insert was simulated to fail), got %d", discountPayments)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CreateTerminalPayment — orphaned live checkout cancelled on INSERT failure
|
|
// =============================================================================
|
|
|
|
// fixedCheckoutClient returns a predetermined checkout ID so a test can force
|
|
// the terminal_checkouts INSERT to collide (PK) while delegating everything
|
|
// else to the real mock.
|
|
type fixedCheckoutClient struct {
|
|
square.SquareClient
|
|
checkoutID string
|
|
mu sync.Mutex
|
|
cancelled []string
|
|
}
|
|
|
|
func (c *fixedCheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) {
|
|
return &square.CheckoutResult{
|
|
ID: c.checkoutID,
|
|
Status: "PENDING",
|
|
AmountMoney: req.Amount,
|
|
Currency: "GBP",
|
|
ReferenceID: req.ReferenceID,
|
|
}, nil
|
|
}
|
|
|
|
func (c *fixedCheckoutClient) CancelCheckout(ctx context.Context, checkoutID string) error {
|
|
c.mu.Lock()
|
|
c.cancelled = append(c.cancelled, checkoutID)
|
|
c.mu.Unlock()
|
|
return c.SquareClient.CancelCheckout(ctx, checkoutID)
|
|
}
|
|
|
|
func (c *fixedCheckoutClient) cancelCalls() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.cancelled...)
|
|
}
|
|
|
|
func TestCreateTerminalPayment_RecordInsertFailure_CancelsOrphanedCheckout(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
// R3: the handler now inserts the tracked row FIRST with a provisional
|
|
// (tmp-) checkout_id, then CreateCheckout, then UPDATEs the row to the real
|
|
// Square id. A COMPLETED terminal_checkouts row with the same checkout_id
|
|
// the client will return makes that tracking UPDATE collide on the PK while
|
|
// the active-checkout guard (PENDING/IN_PROGRESS only) does not fire — the
|
|
// checkout is live at Square but untracked, so the handler must cancel it.
|
|
const dupCheckoutID = "chk_dup_insert_01"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
|
|
VALUES ($1, $2, 'full', 'COMPLETED', 50.00)
|
|
`, dupCheckoutID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed duplicate checkout row: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
client := &fixedCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: dupCheckoutID}
|
|
SquareClient = client
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
handler := CreateTerminalPayment
|
|
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 for the failed terminal_checkouts INSERT, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The orphaned live checkout must have been cancelled at Square.
|
|
if calls := client.cancelCalls(); len(calls) != 1 || calls[0] != dupCheckoutID {
|
|
t.Errorf("expected exactly one CancelCheckout for the orphaned checkout %q, got %v", dupCheckoutID, calls)
|
|
}
|
|
}
|
|
|
|
func TestCreateTerminalPayment_CreateCheckoutFailure_MarksProvisionalRowFailed(t *testing.T) {
|
|
// R3: the handler inserts the tracked row FIRST with a provisional
|
|
// ("tmp-") checkout_id, THEN calls Square CreateCheckout. When Square fails
|
|
// AFTER that insert, the handler must mark the provisional row failed so a
|
|
// retry can proceed — otherwise the "tmp-" row (provably pre-Square) wedges
|
|
// the booking's in-flight guard forever.
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
origClient := SquareClient
|
|
mc := square.NewDevClient().(*square.MockClient)
|
|
mc.FailCreateCheckout = true
|
|
SquareClient = mc
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
handler := CreateTerminalPayment
|
|
req := CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full"}
|
|
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", req, adminToken, ctx)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 when Square CreateCheckout fails, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The provisional row must have been marked failed, and its checkout_id
|
|
// must still be the provisional "tmp-" value (Square never returned a real
|
|
// id, so nothing may overwrite it).
|
|
var status, checkoutID string
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT status, checkout_id FROM terminal_checkouts WHERE booking_id = $1
|
|
`, bookingID).Scan(&status, &checkoutID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query terminal checkout row: %v", err)
|
|
}
|
|
if status != "failed" {
|
|
t.Errorf("expected provisional terminal_checkouts row status 'failed', got %q", status)
|
|
}
|
|
if !strings.HasPrefix(checkoutID, "tmp-") {
|
|
t.Errorf("expected checkout_id to remain provisional (tmp- prefix), got %q", checkoutID)
|
|
}
|
|
|
|
// A retry after the failure must be able to create a fresh checkout: the
|
|
// in-flight guard must not see the failed row as active.
|
|
var activeCount int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM terminal_checkouts WHERE booking_id = $1 AND status IN ('PENDING', 'IN_PROGRESS')
|
|
`, bookingID).Scan(&activeCount); err != nil {
|
|
t.Fatalf("failed to count active terminal checkouts: %v", err)
|
|
}
|
|
if activeCount != 0 {
|
|
t.Errorf("expected 0 active terminal checkouts after the failure, got %d", activeCount)
|
|
}
|
|
}
|