Files
Crussell/backend/handlers/payments/till_test.go
T
popertots 54a5b1024e Implement full Square payment review fixes + frontend polish
Implement every finding from the deep payment review (P0-P2, minors,
nitpicks), then close the post-implementation re-review items, then
align card-form typography and roll out the Square trust badge.

Backend - Square API alignment:
- tip_settings.allow_tipping nested under device_options (was top-level:
  terminal tips were silently lost in prod)
- CreateCardOnFile now accepts customerID and sends card.customer_id;
  saved-card (ccof:) charges forward square_customer_id as CustomerID
- New SquareClient methods GetPayment, CreateCustomer, CancelCheckout
- SCA verification_token accepted + forwarded in all charge paths
- ExpMonth/ExpYear -> *int; URL-path id validation; CancelCheckout
  NOT_FOUND-only no-op (dropped unverified NOOP); exported ErrorCode/
  ErrorDetail helpers; mock rejects raw PANs, RList locks, redacts
  emails, ForceRefundPending hook

Backend - money safety:
- sweepManualPendingSquareRefunds reconciles rows WITH square_refund_id
  instead of stranding them forever
- SweepStalePendingPayments reconciles at Square before failing (tri-state:
  leave pending on transport error, rescue completed, fail definitively)
- GetCheckoutStatus cancellation-recheck; terminal CANCELED resolution;
  SweepStaleTerminalCheckouts covers terminal_checkouts table
- till gift-card clawback on definitive failure incl. retry path +
  INSUFFICIENT_FUNDS/ADDRESS_VERIFICATION_FAILURE/TRANSACTION_LIMIT
- cross-user saved-card collision fixed (UNIQUE(user_id,square_card_id))
- customer provisioning (lazy, save-only); one-off/guest mint no customer
- discount preview/apply unified in discounts.go (global-milestone visible
  in preview, N+1 eliminated, redemption counter preserved on failures)
- webhook event_id dedup; refund loop dedup; stale comment fixes
- test-isolation t.Cleanup on committed sweep tests

Frontend:
- SCA tokenizeWithVerification across all charge flows (amount as
  major-units decimal), 5-min token-expiry re-tokenize, verification_token
  in request bodies
- PaymentModal synchronous double-click + zero/negative-amount guards
- till online-card UI wired to /api/admin/till/sale
- policyPopover generalised; new /privacy-policy route; consent checkbox
  copy + Square privacy link
- Square card iframe styled to app typography (Inter 14px, oklch tokens);
  mock form md:text-sm parity
- 'Secure payment powered by Square' badge on all 8 card-payment flows

Schema/docs: terminal_checkouts + square_customer_id + per-user card
constraint in init-script.sql; README migrations; P14 plan + backlog +
Technical Manual updated.

Includes 39 modified/new test files; full backend suite (25 pkgs),
-race on payments+square, and frontend build are green.
2026-08-22 00:34:49 +01:00

1644 lines
55 KiB
Go

//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
func TestCreateTillSale_OnTheHouse(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status 'completed', got '%s'", resp.Status)
}
if resp.PaymentMethod != "on_the_house" {
t.Errorf("expected payment method 'on_the_house', got '%s'", resp.PaymentMethod)
}
if resp.TotalAmount != 50.00 {
t.Errorf("expected total amount 50.00, got %.2f", resp.TotalAmount)
}
if resp.ItemType != "gift_card" {
t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType)
}
if resp.ItemID == nil || *resp.ItemID == "" {
t.Error("expected item_id to be set (gift card ID)")
}
// Verify till_sale was created in DB
var saleCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale, got %d", saleCount)
}
// Verify gift card was created
var gcCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", *resp.ItemID).Scan(&gcCount)
if err != nil {
t.Errorf("failed to query gift_cards: %v", err)
}
if gcCount != 1 {
t.Errorf("expected 1 gift_card, got %d", gcCount)
}
}
func TestCreateTillSale_Idempotency(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
idempotencyKey := "test-idempotency-key-001"
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 25.00,
PaymentMethod: "on_the_house",
IdempotencyKey: idempotencyKey,
}
bodyBytes, _ := json.Marshal(reqBody)
// First request
req1 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req1.Header.Set("Authorization", "Bearer "+adminToken)
req1.Header.Set("Content-Type", "application/json")
req1 = req1.WithContext(db.ContextWithTx(req1.Context(), tx.(pgx.Tx)))
w1 := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w1, req1)
if w1.Code != http.StatusCreated {
t.Errorf("first request: expected status 201, got %d. body: %s", w1.Code, w1.Body.String())
}
var resp1 TillSaleResponse
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
t.Fatalf("failed to decode first response: %v", err)
}
if resp1.ID == "" {
t.Fatal("expected first till_sale ID to be set")
}
// Second request with same idempotency key
req2 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req2.Header.Set("Authorization", "Bearer "+adminToken)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx)))
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/till/sale", CreateTillSale)
r2.ServeHTTP(w2, req2)
// Idempotent response returns 200 (the handler does not set WriteHeader in the idempotency path)
if w2.Code != http.StatusOK && w2.Code != http.StatusCreated {
t.Errorf("second request: expected status 200 or 201, got %d. body: %s", w2.Code, w2.Body.String())
}
var resp2 TillSaleResponse
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
t.Fatalf("failed to decode second response: %v", err)
}
if resp1.ID != resp2.ID {
t.Errorf("expected same till_sale ID for idempotent request, got %s and %s", resp1.ID, resp2.ID)
}
// Verify only one till_sale exists
var saleCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale (idempotent), got %d", saleCount)
}
}
func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.ItemID == nil || *resp.ItemID == "" {
t.Fatal("expected item_id to be set")
}
// Verify gift_card_transactions was created
var txCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase'", *resp.ItemID).Scan(&txCount)
if err != nil {
t.Errorf("failed to query gift_card_transactions: %v", err)
}
if txCount != 1 {
t.Errorf("expected 1 gift_card_transaction with type 'purchase', got %d", txCount)
}
// Verify the transaction has reference_type = 'till_sale' and reference_id is set
var refCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id IS NOT NULL", *resp.ItemID).Scan(&refCount)
if err != nil {
t.Errorf("failed to query gift_card_transactions with reference: %v", err)
}
if refCount != 1 {
t.Errorf("expected 1 gift_card_transaction with reference_type 'till_sale' and reference_id set, got %d", refCount)
}
}
func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "invalid_method",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a test user to act as the redeemer
redeemerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create redeemer user: %v", err)
}
// Insert a gift card that is already redeemed
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at)
VALUES (50.00, 0.00, $1, $2, NOW())
RETURNING id
`, adminID, redeemerID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert redeemed gift card: %v", err)
}
// Try to topup the redeemed card
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 25.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_SavedCard_TransactionFailure_SkipsSquare(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(cancelCtx, tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
}
var completedCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE status = 'completed'").Scan(&completedCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if completedCount != 0 {
t.Errorf("expected 0 completed till_sales, got %d", completedCount)
}
}
// =============================================================================
// GetTillCheckoutStatus — GET /api/admin/till/checkout/{checkout_id}/status
// =============================================================================
func TestGetTillCheckoutStatus_NotFound(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/till/checkout/nonexistent/status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "nonexistent")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
req = adminRequestCtx(req)
w := httptest.NewRecorder()
GetTillCheckoutStatus(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetTillCheckoutStatus_Pending(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
// Hold the mock checkout so it stays PENDING for testing
if mc, ok := SquareClient.(*square.MockClient); ok {
mc.HoldCheckouts = true
t.Cleanup(func() { mc.HoldCheckouts = false })
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a till sale with card_machine payment to generate a Square checkout.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var createResp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.CheckoutID == nil || *createResp.CheckoutID == "" {
t.Fatal("expected checkout_id to be set for card_machine payment")
}
// Now call GetTillCheckoutStatus with the checkout_id.
statusReq := httptest.NewRequest("GET", "/api/admin/till/checkout/"+*createResp.CheckoutID+"/status", nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", *createResp.CheckoutID)
statusReqCtx := context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusRCtx)
statusReqCtx = db.ContextWithTx(statusReqCtx, tx.(pgx.Tx))
statusReq = statusReq.WithContext(statusReqCtx)
statusReq = adminRequestCtx(statusReq)
wStatus := httptest.NewRecorder()
GetTillCheckoutStatus(wStatus, statusReq)
if wStatus.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", wStatus.Code, wStatus.Body.String())
}
var statusResp PaymentStatusResponse
if err := json.NewDecoder(wStatus.Body).Decode(&statusResp); err != nil {
t.Fatalf("failed to parse status response: %v", err)
}
if statusResp.Status != "PENDING" {
t.Errorf("expected status PENDING, got %s", statusResp.Status)
}
}
func TestGetTillCheckoutStatus_Completed(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a till sale with card_machine payment to generate a Square checkout.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var createResp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.CheckoutID == nil || *createResp.CheckoutID == "" {
t.Fatal("expected checkout_id to be set for card_machine payment")
}
// Wait for the mock goroutine to complete the checkout with polling.
require.Eventually(t, func() bool {
_, err := SquareClient.GetCheckout(context.Background(), *createResp.CheckoutID)
return err == nil
}, 5*time.Second, 100*time.Millisecond)
// Now call GetTillCheckoutStatus — should return COMPLETED.
statusReq := httptest.NewRequest("GET", "/api/admin/till/checkout/"+*createResp.CheckoutID+"/status", nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", *createResp.CheckoutID)
statusReqCtx := context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusRCtx)
statusReqCtx = db.ContextWithTx(statusReqCtx, tx.(pgx.Tx))
statusReq = statusReq.WithContext(statusReqCtx)
statusReq = adminRequestCtx(statusReq)
wStatus := httptest.NewRecorder()
GetTillCheckoutStatus(wStatus, statusReq)
if wStatus.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", wStatus.Code, wStatus.Body.String())
}
var statusResp PaymentStatusResponse
if err := json.NewDecoder(wStatus.Body).Decode(&statusResp); err != nil {
t.Fatalf("failed to parse status response: %v", err)
}
if statusResp.Status != "COMPLETED" {
t.Errorf("expected status COMPLETED, got %s", statusResp.Status)
}
if statusResp.PaymentID == "" {
t.Error("expected payment_id to be set")
}
}
func TestGetTillCheckoutStatus_AlreadyCompleted(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a till sale with on_the_house so it's immediately completed.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 25.00,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var createResp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.ID == "" {
t.Fatal("expected till sale ID")
}
// on_the_house doesn't create a Square checkout, so GetTillCheckoutStatus
// with a non-existent checkout_id should return 404.
statusReq := httptest.NewRequest("GET", "/api/admin/till/checkout/nonexistent/status", nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", "nonexistent")
statusReqCtx := context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusRCtx)
statusReq = statusReq.WithContext(statusReqCtx)
statusReq = adminRequestCtx(statusReq)
wStatus := httptest.NewRecorder()
GetTillCheckoutStatus(wStatus, statusReq)
if wStatus.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", wStatus.Code, wStatus.Body.String())
}
}
func TestGetTillCheckoutStatus_EmptyCheckoutID(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/till/checkout//status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
req = adminRequestCtx(req)
w := httptest.NewRecorder()
GetTillCheckoutStatus(w, req)
req = adminRequestCtx(req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// CreateTillSale — Validation gap tests
// =============================================================================
func TestCreateTillSale_InvalidItemType(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "booking",
Action: "create",
Amount: 1000,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_InvalidAction(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "delete",
Amount: 1000,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_TopupMissingGiftCardID(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 1000,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_SavedCardNoUserSavedCardID(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 1000,
PaymentMethod: "saved_card",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_OnlineSquareNoCardToken(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 1000,
PaymentMethod: "online_square",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_OnlineSquareWithToken(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 1000,
PaymentMethod: "online_square",
CardToken: "cnon:visa",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Errorf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestCreateTillSale_PendingRetry_ReattemptsSquare verifies that a same-key
// retry after a failed Square charge (sale stuck 'pending', gift card already
// funded) re-attempts the charge and completes the sale — it must NOT return
// the stale 'pending' status without re-charging (silent money loss).
func TestCreateTillSale_PendingRetry_ReattemptsSquare(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale with the same key and a funded gift card —
// simulates a prior attempt where the Square charge failed after the DB
// transaction committed (card already funded).
key := "till-pending-retry-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The sale must now be 'completed' (Square re-attempted and succeeded).
var saleStatus string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if saleStatus != "completed" {
t.Errorf("expected pending sale to be completed after retry, got %s", saleStatus)
}
}
func TestCreateTillSale_CreateCash(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status 'completed', got '%s'", resp.Status)
}
if resp.PaymentMethod != "cash" {
t.Errorf("expected payment method 'cash', got '%s'", resp.PaymentMethod)
}
if resp.ItemType != "gift_card" {
t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType)
}
var saleCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale, got %d", saleCount)
}
}
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed verifies that two
// identical keyless cash gift-card creations both return 201. The idempotency-key
// fallback must be unique per request so legitimate repeat sales don't collide
// on the till_sales idempotency_key UNIQUE constraint.
func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
for i := 0; i < 2; i++ {
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "sale %d: expected 201, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
// TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce verifies that
// a same-key retry of a pending create-with-redeem sale does NOT re-credit the
// user's balance. The redeem (zeroing the gift card + crediting
// user_giftcard_balances) must only run on the FIRST attempt of a create; a
// pending retry reuses the already-redeemed gift card and would otherwise credit
// the user a second time (money loss to the business).
func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale (prior attempt where Square failed after the DB
// transaction committed) whose gift card was already redeemed and the user
// already credited the first-attempt amount (£50).
key := "till-pending-retry-redeem-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 0.00, $1, $2, NOW(), FALSE, 'SPV')
RETURNING id
`, adminID, userID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to seed redeemed gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, 50.00, NOW())
`, userID)
if err != nil {
t.Fatalf("failed to seed user gift card balance: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Retry with the same key, requesting the redeem again.
redeemUserID := userID
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
RedeemToUserID: &redeemUserID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The sale must now be 'completed' (Square re-attempted and succeeded).
var saleStatus string
err = tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected sale status 'completed' after retry, got %s", saleStatus)
}
// The user balance must still be £50 — credited EXACTLY ONCE, not £100.
var balance float64
err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query user gift card balance: %v", err)
}
if balance != 50.00 {
t.Errorf("expected balance 50.00 (credited once), got %.2f", balance)
}
// The gift card must remain fully redeemed (amount_remaining still 0).
var amountRemaining float64
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 0.00 {
t.Errorf("expected amount_remaining 0.00, got %.2f", amountRemaining)
}
}
// TestCreateTillSale_TopupWithRedeem_Rejected verifies that a top-up request
// carrying a redeem_to_user_id is rejected outright. Allowing redeem on a first
// attempt topup destroys money: the topup branch accepts unredeemed cards with
// residual balance, and topping up £20 onto a card with £40 residual then
// redeeming would zero amount_remaining (£40 lost) while crediting the user only
// the top-up amount.
func TestCreateTillSale_TopupWithRedeem_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
redeemerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create redeemer user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// An unredeemed card carrying residual balance — the dangerous topup+redeem case.
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (40.00, 40.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 20.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
RedeemToUserID: &redeemerID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d. body: %s", w.Code, w.Body.String())
}
// No rows created: no till_sale, card untouched, no user balance credited.
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales`).Scan(&saleCount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if saleCount != 0 {
t.Errorf("expected 0 till_sales, got %d", saleCount)
}
var totalFunds, amountRemaining float64
err = tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&totalFunds, &amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if totalFunds != 40.00 {
t.Errorf("expected total_funds_added 40.00, got %.2f", totalFunds)
}
if amountRemaining != 40.00 {
t.Errorf("expected amount_remaining 40.00, got %.2f", amountRemaining)
}
var balanceCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, redeemerID).Scan(&balanceCount)
if err != nil {
t.Fatalf("failed to query user_giftcard_balances: %v", err)
}
if balanceCount != 0 {
t.Errorf("expected no user gift card balance row, got %d", balanceCount)
}
}
// TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout verifies that a
// same-key retry of a pending card_machine sale reuses the checkout already
// stored on the till_sales row instead of calling Square CreateCheckout a second
// time. The original checkout may still be live at the terminal; a fresh
// checkout would orphan it into an untracked charge.
func TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING card_machine till_sale that already has a Square checkout.
key := "till-card-machine-reuse-key"
storedCheckoutID := "chk_pending_retry_stored"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
$2, $3, $4, NOW(), NOW())
`, giftCardID, storedCheckoutID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending card_machine till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// The response must reference the STORED checkout, not a freshly created one.
// CreateCheckout always generates a new chk_mock_* id, so any second call
// would surface a different id here.
if resp.CheckoutID == nil || *resp.CheckoutID != storedCheckoutID {
t.Errorf("expected checkout_id to be the stored %q, got %v", storedCheckoutID, resp.CheckoutID)
}
if resp.Status != "pending" {
t.Errorf("expected status 'pending', got %s", resp.Status)
}
// The till_sales row must still reference the same checkout id, unchanged.
var rowCheckoutID string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(square_checkout_id), '') FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &rowCheckoutID)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if rowCheckoutID != storedCheckoutID {
t.Errorf("expected till_sales.square_checkout_id to remain %q, got %q", storedCheckoutID, rowCheckoutID)
}
}
// TestCreateTillSale_PendingRetry_AmountMismatch_Rejected verifies that a
// same-key retry with a different amount is rejected (400) instead of reusing
// the pending sale — the gift card was already funded at the old amount, so
// charging a new amount would leave the card funded at the wrong value.
// Mirrors the tip/gift-card retry amount guard.
func TestCreateTillSale_PendingRetry_AmountMismatch_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING till_sale funded at £50.00.
key := "till-pending-amount-mismatch-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Retry with the same key but a different amount (£60 instead of £50).
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 60.00,
PaymentMethod: "saved_card",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 (amount mismatch), got %d. body: %s", w.Code, w.Body.String())
}
// The pending sale must be untouched.
var saleStatus string
var saleAmount float64
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), COALESCE(MAX(total_amount), 0) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus, &saleAmount)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale, got %d", saleCount)
}
if saleStatus != "pending" {
t.Errorf("expected pending sale to remain pending after rejected retry, got %s", saleStatus)
}
if saleAmount != 50.00 {
t.Errorf("expected sale amount to remain 50.00, got %.2f", saleAmount)
}
}
// TestCreateTillSale_PendingRetry_Cash_CompletesRow verifies that a same-key
// retry resolved by cash on a pending sale explicitly flips the row to
// 'completed' — previously the response claimed success while the DB row stayed
// pending forever (unreconciled).
func TestCreateTillSale_PendingRetry_Cash_CompletesRow(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING till_sale (prior online_square attempt failed post-commit).
key := "till-pending-cash-retry-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Retry with the same key, same amount, cash payment.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The reused row must now be 'completed' — the cash retry explicitly flips it.
var saleStatus string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if saleStatus != "completed" {
t.Errorf("expected pending sale to be completed by cash retry, got %s", saleStatus)
}
}
// TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected verifies that a
// pending card_machine sale with a live checkout cannot be retried via a
// different method — the original terminal checkout is still live and could
// complete, causing a double charge.
func TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING card_machine sale with a stored live checkout.
key := "till-pending-method-switch-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
NULL, 'chk_live_switch', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending card-machine till sale: %v", err)
}
// Retry with the same key, same amount, but cash — must be rejected (409).
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 (method switch on live checkout), got %d. body: %s", w.Code, w.Body.String())
}
// The pending sale must be untouched.
var saleStatus string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale, got %d", saleCount)
}
if saleStatus != "pending" {
t.Errorf("expected pending sale to remain pending after rejected switch, got %s", saleStatus)
}
}
// =============================================================================
// revertGiftCardFunding — top-up guard-blocked reversal still fails the sale
// =============================================================================
func TestRevertGiftCardFunding_TopupPartiallySpent_LogsCritical_StillFailsSale(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var cardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES (50.00, 10.00, $1, FALSE)
RETURNING id
`, adminID).Scan(&cardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
var saleID string
if err := tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed till sale: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'topup', 50.00, 'till_sale', $2)
`, cardID, saleID); err != nil {
t.Fatalf("failed to seed gift card transaction: %v", err)
}
// amount_remaining (10.00) < top-up (50.00) — the guarded UPDATE matches 0
// rows. The clawback must NOT fail (the sale still has to be marked failed)
// and must log CRITICAL for manual reconciliation.
err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID)
if err != nil {
t.Fatalf("revertGiftCardFunding must not fail when the guard blocks the reversal, got: %v", err)
}
var remaining, totalAdded float64
if err := tx.QueryRow(ctx, `SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &totalAdded); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if remaining != 10.00 || totalAdded != 50.00 {
t.Errorf("guard-blocked reversal must leave the card amounts untouched, got remaining=%.2f total_funds_added=%.2f", remaining, totalAdded)
}
// The sale must still be marked failed — the whole point of the clawback.
var saleStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "failed" {
t.Errorf("expected till sale marked failed despite the guard-blocked reversal, got %q", saleStatus)
}
// This request's top-up transaction must be removed even though the card
// amount could not be reversed.
var txCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, cardID, saleID).Scan(&txCount); err != nil {
t.Fatalf("failed to count gift card transactions: %v", err)
}
if txCount != 0 {
t.Errorf("expected this request's top-up transaction removed, got %d", txCount)
}
}