//go:build test && dev package payments import ( "bytes" "context" "database/sql" "fmt" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" ) // ============================================================================= // R6: ccof charges must ALWAYS carry a CustomerID; one-off non-save charges // use the cnon: nonce directly (no card-on-file, no customer). // ============================================================================= // TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges // covers the lazy Square-customer provisioning for saved-card rows that // predate P14 (square_customer_id empty): the row must be provisioned AND the // provisioned id persisted on the row BEFORE the ccof: charge goes out. func TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_legacy", "VISA", "4242") require.NoError(t, err) // Row is seeded with square_customer_id empty (legacy). origClient := SquareClient rec := &recordingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", CardID: &cardID, IdempotencyKey: "legacy-cust-" + 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() got := rec.lastReq.CustomerID rec.mu.Unlock() require.NotEmpty(t, got, "a legacy ccof: charge must be provisioned a Square customer before charging") var persisted string require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&persisted)) require.Equal(t, got, persisted, "the provisioned customer id must be persisted on the saved-card row") } // TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID covers the save // path of the new-card flow: the ccof: charge MUST carry the same provisioned // customer id that CreateCardOnFile used. func TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} rec := &recordingPaymentClient{SquareClient: cof} SquareClient = rec defer func() { SquareClient = origClient }() cardToken := "cnon:save-card-nonce" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, SaveCard: true, IdempotencyKey: "save-charge-cust-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once") require.NotEmpty(t, cof.lastCustomerID(), "a save-card flow must provision a Square customer") rec.mu.Lock() got := rec.lastReq.CustomerID rec.mu.Unlock() require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) charge must carry the provisioned customer id") require.NotEmpty(t, got) } // TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID is the BuyGiftCard // counterpart of the above: the gift-card purchase's ccof: charge must carry // the provisioned customer id. func TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) origClient := SquareClient cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()} rec := &recordingPaymentClient{SquareClient: cof} SquareClient = rec defer func() { SquareClient = origClient }() newToken := "cnon:save-gift-card-nonce" req := BuyGiftCardRequest{ Amount: 1000, RecipientType: "self", NewCardToken: &newToken, SaveCard: true, IdempotencyKey: "buy-gc-save-charge-cust", } w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once") rec.mu.Lock() got := rec.lastReq.CustomerID rec.mu.Unlock() require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) gift-card purchase must carry the provisioned customer id") require.NotEmpty(t, got) } // ============================================================================= // R2: BuyGiftCard requires an idempotency key // ============================================================================= // TestBuyGiftCard_MissingIdempotencyKey_Accepted verifies that M1: a missing // idempotency key is accepted and a deterministic key is generated server-side. // The purchase should succeed, not be rejected. func TestBuyGiftCard_MissingIdempotencyKey_Accepted(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateUserToken(userID) newToken := "cnon:test-card" req := BuyGiftCardRequest{ Amount: 1000, RecipientType: "self", NewCardToken: &newToken, SaveCard: false, } w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx) require.Equal(t, http.StatusCreated, w.Code, "M1: missing idempotency key should be accepted (server generates deterministic key)") } // ============================================================================= // R3: provisional (pre-Square) terminal checkout rows // ============================================================================= // TestActiveTerminalCheckoutID_ResolvesProvisionalRow covers the crash-window // guard: a PENDING row with a synthetic "tmp-" checkout_id never had a checkout // created at Square, so it is provably not live — the guard must mark it failed // and allow a fresh checkout instead of wedging the booking. func TestActiveTerminalCheckoutID_ResolvesProvisionalRow(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupTestData(t, ctx, tx) provisionalID := "tmp-crash-window-1" if _, err := tx.Exec(ctx, ` INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount) VALUES ($1, $2, 'full', 'PENDING', 50.00) `, provisionalID, bookingID); err != nil { t.Fatalf("failed to seed provisional terminal checkout: %v", err) } got := activeTerminalCheckoutID(ctx, bookingID) require.Equal(t, "", got, "a provisional (pre-Square) row must not block a new checkout") var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status)) require.Equal(t, "failed", status, "the provisional row must be marked failed") } // TestSweepStaleTerminalCheckouts_TmpProvisional_ResolvesAgainstSquare locks // the H4 fix: a provisional "tmp-" terminal_checkouts row is resolved against // Square FIRST, never blind-failed. A hard crash between the row insert and // the provisional→real UPDATE can leave a LIVE checkout at Square (created // under the idempotency key embedded in the tmp id) while the row still // carries the synthetic id — so a tmp- row is NO LONGER provably not live. // A tmp- id Square has never seen (NOT_FOUND — the crash happened before the // Square call, or the id cannot be resolved) is the expected outcome and is // safely resolved to failed. func TestSweepStaleTerminalCheckouts_TmpProvisional_ResolvesAgainstSquare(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, serviceID := setupTestData(t, ctx, tx) provisionalID := "tmp-sweep-provisional-1" if _, err := tx.Exec(ctx, ` INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at) VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours') `, provisionalID, bookingID); err != nil { t.Fatalf("failed to seed provisional terminal checkout: %v", err) } pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID) _, _ = 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) }) freshCtx := context.Background() // Drop stale rows left by other sweep tests so the count is deterministic. if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, provisionalID); err != nil { t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err) } if _, err := db.Conn.Exec(freshCtx, `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) } // A fresh mock holds no checkout under the tmp id → GetCheckout returns // the mock's plain "checkout not found" error (NOT_FOUND), the expected // outcome for a tmp- id that never reached Square → the row is resolved // to failed. origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() n, err := SweepStaleTerminalCheckouts(freshCtx) require.NoError(t, err) require.Equal(t, 1, n, "the stale provisional terminal checkout must be resolved by the sweep") var status string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status)) require.Equal(t, "failed", status) } // ============================================================================= // R5: GetDiscountPreviewHandler ownership (IDOR) // ============================================================================= // TestGetDiscountPreviewHandler_WrongOwner_Forbidden covers the IDOR fix: a // non-admin user must not read another user's booking discount preview. func TestGetDiscountPreviewHandler_WrongOwner_Forbidden(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx) otherUserID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) otherToken := jwt.GenerateUserToken(otherUserID) w := serveDiscountPreviewHandler(bookingID, otherUserID, otherToken, ctx) require.Equal(t, http.StatusForbidden, w.Code, "a non-owner must not read another user's discount preview") } // TestGetDiscountPreviewHandler_NoUserContext_Unauthorized covers the fail-closed // 401 for requests that carry no user id. func TestGetDiscountPreviewHandler_NoUserContext_Unauthorized(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx) req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", bookingID) req = req.WithContext(ctx) req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) w := httptest.NewRecorder() GetDiscountPreviewHandler(w, req) require.Equal(t, http.StatusUnauthorized, w.Code) } // ============================================================================= // R8: DeletePaymentMethod disables the Square card before the local soft-delete // ============================================================================= type deletingCardClient struct { square.SquareClient mu sync.Mutex deleted []string } func (c *deletingCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error { c.mu.Lock() c.deleted = append(c.deleted, cardID) c.mu.Unlock() return c.SquareClient.DeleteCardOnFile(ctx, cardID) } func (c *deletingCardClient) deletedIDs() []string { c.mu.Lock() defer c.mu.Unlock() return append([]string(nil), c.deleted...) } func TestDeletePaymentMethod_DisablesSquareCard(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_disable", "VISA", "9999") require.NoError(t, err) origClient := SquareClient rec := &deletingCardClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() svc := NewPaymentService() require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID)) require.Equal(t, []string{"ccof:sq_del_disable"}, rec.deletedIDs(), "the Square card must be disabled on local delete (R8)") var deletedAt string require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt)) require.NotEqual(t, "", deletedAt, "the card must still be soft-deleted locally") } // TestDeletePaymentMethod_SquareFailure_StillDeletesLocally covers the // best-effort contract: a Square disable failure must never block the local // soft-delete. func TestDeletePaymentMethod_SquareFailure_StillDeletesLocally(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_fail", "VISA", "8888") require.NoError(t, err) origClient := SquareClient SquareClient = &failingDeleteClient{SquareClient: square.NewDevClient()} defer func() { SquareClient = origClient }() svc := NewPaymentService() require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID)) var deletedAt string require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt)) require.NotEqual(t, "", deletedAt, "the local soft-delete must proceed even when the Square call fails") } type failingDeleteClient struct { square.SquareClient } func (c *failingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error { return fmt.Errorf("square: network error disabling card %s", cardID) } // syncBuffer is a mutex-guarded slog writer so log records can be read safely // under -race. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // tokenSafeFailingDeleteClient fails with a token-free error so the // log-redaction test isolates redaction of the square_card_id attribute rather // than the error string. type tokenSafeFailingDeleteClient struct { square.SquareClient } func (c *tokenSafeFailingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error { return fmt.Errorf("square: network error disabling card at Square") } // TestDeletePaymentMethod_LogsRedactCardToken verifies the local card-delete // warning logs the redacted tokenPrefix form of the square_card_id (a ccof: // token), never the full value (SECURITY: full ccof tokens must not reach // server logs). func TestDeletePaymentMethod_LogsRedactCardToken(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) fullToken := "ccof:secret_token_123456" cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, fullToken, "VISA", "9999") require.NoError(t, err) origClient := SquareClient SquareClient = &tokenSafeFailingDeleteClient{SquareClient: square.NewDevClient()} defer func() { SquareClient = origClient }() var sb syncBuffer origLogger := slog.Default() slog.SetDefault(slog.New(slog.NewTextHandler(&sb, nil))) defer slog.SetDefault(origLogger) svc := NewPaymentService() require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID)) logs := sb.String() if strings.Contains(logs, fullToken) { t.Errorf("full ccof token %q leaked into logs: %q", fullToken, logs) } if !strings.Contains(logs, "ccof:sec...") { t.Errorf("expected redacted token prefix in logs, got %q", logs) } } // TestSaveCardForUser_RevivesSoftDeletedCard verifies the SaveCardForUser // upsert: a user who soft-deleted a card (DeletePaymentMethod sets deleted_at, // but the row still occupies the UNIQUE (user_id, square_card_id) slot) and // then re-saves the SAME physical card via a save_card=true charge must get // the existing row revived — NOT a pgx.ErrNoRows 500 from the old // DO NOTHING + deleted_at IS NULL fallback. func TestSaveCardForUser_RevivesSoftDeletedCard(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) origClient := SquareClient rec := &deletingCardClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_revive", "VISA", "4242") require.NoError(t, err) svc := NewPaymentService() require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID), "soft-delete must succeed") var deletedAt string require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt)) require.NotEqual(t, "", deletedAt, "precondition: card must be soft-deleted") // Re-save the same physical card (same square_card_id → same UNIQUE slot). revivedID, err := svc.SaveCardForUser(ctx, userID, "cus_sq_revive", "ccof:sq_revive", "VISA", "4242", 12, 2030, "revive_fp") require.NoError(t, err, "re-saving a soft-deleted card must not error") require.Equal(t, cardID, revivedID, "the revived card must be the existing row, not a new insert") var revivedDeletedAt sql.NullString require.NoError(t, tx.QueryRow(ctx, `SELECT deleted_at FROM user_saved_cards WHERE id = $1`, revivedID).Scan(&revivedDeletedAt)) require.False(t, revivedDeletedAt.Valid, "the revived card must have deleted_at cleared") } // TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers proves the // validate:"max=45/64" caps on client-supplied idempotency keys: a key longer // than Square's per-endpoint limit would otherwise 400 at Square (misclassified // as a definitive 402 by chargeFailureStatus) with a confusingly worded error. func TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, _ := setupTestData(t, ctx, tx) _, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) userToken := jwt.GenerateUserToken(userID) adminToken := jwt.GenerateAdminToken() paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed") require.NoError(t, err) // 46 chars — exceeds Square's 45-char /v2/payments, /v2/cards, /v2/refunds cap. tooLong := strings.Repeat("k", 46) // 65 chars — exceeds Square's 64-char terminal-checkout cap. tooLongCheckout := strings.Repeat("c", 65) cases := []struct { name string handler http.HandlerFunc method string path string body any token string }{ { name: "terminal-saved-card", handler: CreateTerminalPayment, method: "POST", path: "/api/admin/bookings/" + bookingID + "/payment", body: CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", UserSavedCardID: strPtr("000000000001"), IdempotencyKey: tooLong}, token: adminToken, }, { name: "booking-payment", handler: CreateBookingPayment, method: "POST", path: "/api/bookings/" + bookingID + "/payment", body: CreateBookingPaymentRequest{Amount: 5000, PaymentType: "deposit", IdempotencyKey: tooLong}, token: userToken, }, { name: "tip", handler: CreateTipPayment, method: "POST", path: "/api/bookings/" + bookingID + "/tip", body: CreateTipPaymentRequest{Amount: 500, IdempotencyKey: tooLong}, token: userToken, }, { name: "gift-card-buy", handler: BuyGiftCard, method: "POST", path: "/api/gift-cards/buy", body: BuyGiftCardRequest{Amount: 1000, RecipientType: "self", NewCardToken: strPtr("cnon:test-card"), IdempotencyKey: tooLong}, token: userToken, }, { name: "till-terminal", handler: CreateTillSale, method: "POST", path: "/api/admin/till/sale", body: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "cash", IdempotencyKey: tooLongCheckout}, token: adminToken, }, { name: "refund", handler: RefundPayment, method: "POST", path: "/api/admin/payments/" + paymentID + "/refund", body: RefundRequest{Amount: 1000, Reason: "customer request", IdempotencyKey: tooLong}, token: adminToken, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := makePaymentRequest(tc.handler, tc.method, tc.path, tc.body, tc.token, ctx) require.Equal(t, http.StatusBadRequest, w.Code, "an over-length idempotency key must be rejected before reaching Square (got %d: %s)", w.Code, w.Body.String()) }) } }