//go:build test && dev package payments import ( "bytes" "context" "encoding/json" "errors" "fmt" "log" "math" "net/http" "net/http/httptest" "strings" "sync" "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) 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: 50, 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_CompletedDedup_AmountMismatch_Rejected verifies the // C4-class dedup guard on the COMPLETED path (the C4 fix covered refunds and // the till PENDING path, but the completed dedup echoed the freshly-requested // amount and accepted any amount): a same-key retry of a completed sale with a // different amount must be rejected (400), never silently reported as the new // amount. func TestCreateTillSale_CompletedDedup_AmountMismatch_Rejected(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") // Seed a COMPLETED till sale funded at £50.00. key := "till-completed-dedup-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, square_payment_id, idempotency_key, created_by, created_at, updated_at) VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'completed', NULL, 'sqp_completed_dedup', $2, $3, NOW(), NOW()) `, giftCardID, key, adminID) if err != nil { t.Fatalf("failed to seed completed 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 (completed dedup amount mismatch), got %d. body: %s", w.Code, w.Body.String()) } // The completed sale must be untouched. var saleCount int var saleStatus string var saleAmount float64 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 != "completed" { t.Errorf("expected completed sale to remain completed, got %s", saleStatus) } if saleAmount != 50.00 { t.Errorf("expected sale amount to remain 50.00, got %.2f", saleAmount) } } // TestCreateTillSale_CompletedDedup_ReturnsStoredAmount verifies the C4-class // dedup fix on the COMPLETED till-sale path: a same-key retry reports the // STORED sale (amount, payment method, item type), not the freshly-requested // values — a retry with a different amount would otherwise mislead the till. func TestCreateTillSale_CompletedDedup_ReturnsStoredAmount(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") key := "till-completed-dedup-stored-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_payment_id, idempotency_key, created_by, created_at, updated_at) VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'completed', NULL, 'sqp_completed_dedup_stored', $2, $3, NOW(), NOW()) `, giftCardID, key, adminID) if err != nil { t.Fatalf("failed to seed completed till sale: %v", err) } reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "cash", // differs from the stored 'online_square' — must NOT be echoed 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 { t.Fatalf("expected 200 (completed dedup), got %d. body: %s", w.Code, w.Body.String()) } var resp TillSaleResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.TotalAmount != 50.00 { t.Errorf("expected dedup response to report the STORED total 50.00, got %.2f", resp.TotalAmount) } if resp.PaymentMethod != "online_square" { t.Errorf("expected dedup response to report the STORED payment method 'online_square', got %q", resp.PaymentMethod) } if resp.Status != "completed" { t.Errorf("expected dedup response status 'completed', got %q", resp.Status) } } // TestCreateTillSale_PendingRetry_Cash_CompletesRow verifies that a same-key // retry resolved by cash on a pending sale whose Square charge PROVABLY failed // (HIGH-3: a FAILED status means no money landed, so cash is safe) 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) { // Not parallel: swaps the package-global SquareClient. 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) // whose Square charge is recorded as FAILED — provably no money landed. 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, square_payment_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, 'sqp_cash_retry_failed', $2, $3, NOW(), NOW()) `, giftCardID, key, adminID) if err != nil { t.Fatalf("failed to seed pending till sale: %v", err) } origClient := SquareClient SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: "FAILED", SquarePayID: "sqp_cash_retry_failed"}} defer func() { SquareClient = origClient }() // 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_Cash_NotFound_CompletesRow verifies the // HIGH-3 NOT_FOUND branch: a pending sale whose square_payment_id does NOT // resolve at Square is provably never charged, so cash may complete it. func TestCreateTillSale_PendingRetry_Cash_NotFound_CompletesRow(t *testing.T) { // Not parallel: swaps the package-global SquareClient. 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") key := "till-pending-cash-notfound-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_payment_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, 'sqp_cash_retry_not_found', $2, $3, NOW(), NOW()) `, giftCardID, key, adminID) if err != nil { t.Fatalf("failed to seed pending till sale: %v", err) } origClient := SquareClient SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), err: fmt.Errorf("square: GET /v2/payments/sqp_cash_retry_not_found: [PAYMENT_NOT_FOUND/NOT_FOUND] payment does not exist")} defer func() { SquareClient = origClient }() 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()) } var saleStatus string if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil { t.Fatalf("failed to query till sale: %v", err) } if saleStatus != "completed" { t.Errorf("expected pending sale to be completed by cash retry after NOT_FOUND reconcile, got %s", saleStatus) } } // TestCreateTillSale_PendingRetry_Cash_Rejected_LostResponse verifies the // HIGH-3 lost-response branch: a pending sale with NO square_payment_id cannot // be resolved with cash — the charge may have landed at Square and cannot be // looked up, so the retry must stay on the original card method (Square dedups // on the same idempotency key and resolves the lost response). func TestCreateTillSale_PendingRetry_Cash_Rejected_LostResponse(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") key := "till-pending-cash-lost-response-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) } // No square_payment_id — the lost-response case. _, 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) } 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 (force original card method), got %d. body: %s", w.Code, w.Body.String()) } // The pending sale must be untouched. var saleStatus string if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil { t.Fatalf("failed to query till sale: %v", err) } if saleStatus != "pending" { t.Errorf("expected pending sale to remain pending after rejected cash retry, got %s", saleStatus) } } // TestCreateTillSale_PendingRetry_Cash_Rejected_AlreadyPaid verifies the // HIGH-3 COMPLETED branch: a pending sale whose Square charge is COMPLETED has // already been paid — the sale is rescued to 'completed' and the cash is // refused (double payment). The setup is COMMITTED so the handler runs at pool // level: the rescue UPDATE is issued via db.Conn.Exec on a connection // independent of the handler's transaction (as in production) and must persist // even though the handler returns 409 before committing its own tx. func TestCreateTillSale_PendingRetry_Cash_Rejected_AlreadyPaid(t *testing.T) { // Not parallel: swaps the package-global SquareClient. 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") key := "till-pending-cash-already-paid-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_payment_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, 'sqp_cash_retry_completed', $2, $3, NOW(), NOW()) `, giftCardID, key, adminID) if err != nil { t.Fatalf("failed to seed pending till sale: %v", err) } // Commit the setup so the handler runs at pool level — the rescue UPDATE // (db.Conn.Exec) then commits on its own connection exactly as in prod. innerTx := db.TxFromContext(ctx) if innerTx == nil { t.Fatal("no transaction in context") } if err := innerTx.Commit(ctx); err != nil { t.Fatalf("failed to commit setup tx: %v", err) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID) _, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE idempotency_key = $1`, key) _, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID) }) origClient := SquareClient SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_cash_retry_completed"}} defer func() { SquareClient = origClient }() 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(pool) 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 (already paid by card), got %d. body: %s", w.Code, w.Body.String()) } // The sale must have been rescued to 'completed' at pool level. var saleStatus string if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil { t.Fatalf("failed to query till sale: %v", err) } if saleStatus != "completed" { t.Errorf("expected already-paid pending sale rescued to completed, got %s", saleStatus) } } // TestCreateTillSale_PendingRetry_Cash_Rejected_Ambiguous verifies the HIGH-3 // ambiguous branch: a transport/server error from GetPayment leaves the charge // outcome unknown — cash must not be taken and the retry gets 503. func TestCreateTillSale_PendingRetry_Cash_Rejected_Ambiguous(t *testing.T) { // Not parallel: swaps the package-global SquareClient. 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") key := "till-pending-cash-ambiguous-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_payment_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, 'sqp_cash_retry_ambiguous', $2, $3, NOW(), NOW()) `, giftCardID, key, adminID) if err != nil { t.Fatalf("failed to seed pending till sale: %v", err) } origClient := SquareClient SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), err: fmt.Errorf("network error: connection reset by peer")} defer func() { SquareClient = origClient }() 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.StatusServiceUnavailable { t.Fatalf("expected 503 (unable to confirm card status), got %d. body: %s", w.Code, w.Body.String()) } // The pending sale must be untouched. var saleStatus string if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil { t.Fatalf("failed to query till sale: %v", err) } if saleStatus != "pending" { t.Errorf("expected pending sale to remain pending after ambiguous reconcile, got %s", saleStatus) } } // TestCreateTillSale_PendingRetry_OnTheHouse_DefinitivelyFailed_CompletesRow // verifies the HIGH-3 guard also fires for on_the_house: a pending sale whose // Square charge provably failed can be resolved with on_the_house. func TestCreateTillSale_PendingRetry_OnTheHouse_DefinitivelyFailed_CompletesRow(t *testing.T) { // Not parallel: swaps the package-global SquareClient. 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") key := "till-pending-oth-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, square_payment_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, 'sqp_oth_retry_failed', $2, $3, NOW(), NOW()) `, giftCardID, key, adminID) if err != nil { t.Fatalf("failed to seed pending till sale: %v", err) } origClient := SquareClient SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: "CANCELED", SquarePayID: "sqp_oth_retry_failed"}} defer func() { SquareClient = origClient }() reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "on_the_house", 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()) } var saleStatus string if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil { t.Fatalf("failed to query till sale: %v", err) } if saleStatus != "completed" { t.Errorf("expected pending sale completed by on_the_house retry after definitive failure, 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 // ============================================================================= // TestRevertGiftCardFunding_TopupPartiallySpent_ClampsAndFlags locks the M6 // fix: the clawback's balance guard no longer leaves the funding in place when // some of the top-up was already spent. The reversal CLAMPS the card to zero // (everything still on the card is reclaimed), the sale is still marked failed, // and the unreclaimable spent portion surfaces as a CRITICAL admin notification // + errClawbackPartiallyReversed so every caller flags reconciliation. func TestRevertGiftCardFunding_TopupPartiallySpent_ClampsAndFlags(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) — some of the funding was // already spent. M6: the clawback must CLAMP the card to zero and return // errClawbackPartiallyReversed (funding reverts except the unrecoverable // spent portion), never leave the funding on the card. err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID) if !errors.Is(err, errClawbackPartiallyReversed) { t.Fatalf("expected errClawbackPartiallyReversed when the top-up was partially spent, 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 != 0.00 || totalAdded != 0.00 { t.Errorf("expected the partially-spent top-up clamped to zero, 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 partial reversal, got %q", saleStatus) } // This request's top-up transaction must be removed — the sale failed and // its funding is gone from the card. 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) } } // TestRevertGiftCardFunding_NotPending_LeavesCardUntouched locks the claim-first // gate: when the till sale is no longer 'pending' (already completed/failed), // revertGiftCardFunding returns errTillSaleNotPending and the gift card (and // its transaction) is left exactly as it was — the funding is no longer ours // to revert. func TestRevertGiftCardFunding_NotPending_LeavesCardUntouched(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, 50.00, $1, FALSE) RETURNING id `, adminID).Scan(&cardID); err != nil { t.Fatalf("failed to seed gift card: %v", err) } // A COMPLETED sale — the claim-first gating UPDATE must match zero rows. var saleID string if err := tx.QueryRow(ctx, ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at) VALUES ('gift_card', $1, 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'completed', $2, NOW(), NOW()) RETURNING id `, cardID, 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) } err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID) if !errors.Is(err, errTillSaleNotPending) { t.Fatalf("expected errTillSaleNotPending when the sale is not pending, 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 != 50.00 || totalAdded != 50.00 { t.Errorf("claim-failed clawback must leave the card untouched, got remaining=%.2f total_funds_added=%.2f", remaining, totalAdded) } var txCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1`, cardID).Scan(&txCount); err != nil { t.Fatalf("failed to count gift card transactions: %v", err) } if txCount != 1 { t.Errorf("claim-failed clawback must leave the transaction untouched, got %d transactions", txCount) } } // TestCreateTillSale_PostCheckoutFailure_CancelsOrphanedCheckout locks the // HIGH-2 fix: when a card_machine checkout is created at Square inside the tx // but a later pre-commit failure aborts the request (here: the till_sales // INSERT collides on the idempotency_key UNIQUE constraint), the orphaned live // checkout must be cancelled at Square — otherwise it stays live at the // terminal as an invisible, untracked charge. func TestCreateTillSale_PostCheckoutFailure_CancelsOrphanedCheckout(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") // A till_sales row already holding the request's idempotency key makes the // handler's till_sales INSERT collide on the UNIQUE constraint AFTER the // Square CreateCheckout succeeded. Seeding item_id = NULL makes the dedup // SELECT's NULL-into-string scan FAIL with a non-ErrNoRows error, so the // dedup is skipped, existingPendingID stays empty, and the create path runs // its INSERT into the collision. const key = "till-high2-orphan-key" if _, err := tx.Exec(ctx, ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, idempotency_key, created_at, updated_at) VALUES ('gift_card', NULL, 'blocker row', 1, 50.00, 50.00, 'online_square', 'pending', $1, $2, NOW(), NOW()) `, adminID, key); err != nil { t.Fatalf("failed to seed blocker till sale: %v", err) } origClient := SquareClient client := &fixedCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: "chk_high2_orphaned"} SquareClient = client defer func() { SquareClient = origClient }() 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.StatusInternalServerError { t.Fatalf("expected 500 from the failed till_sales INSERT, got %d. body: %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] != "chk_high2_orphaned" { t.Errorf("expected exactly one CancelCheckout for the orphaned checkout %q, got %v", "chk_high2_orphaned", calls) } } // TestGetTillCheckoutStatus_AlreadyFailed_DoesNotResurrect locks the LOW // resurrection guard: a poll of a checkout whose till sale the sweep already // failed (e.g. a clawback reverted the gift card) must NOT complete the sale — // the completion UPDATE's status='pending' guard matches zero rows and the // poll fails loudly with 404 instead of reporting COMPLETED for a sale whose // card no longer exists. func TestGetTillCheckoutStatus_AlreadyFailed_DoesNotResurrect(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) } pool := context.Background() const checkoutID = "chk_till_already_failed" var saleID string if err := tx.QueryRow(ctx, ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, square_checkout_id, created_by, created_at, updated_at) VALUES ('gift_card', NULL, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'failed', $1, $2, NOW(), NOW()) RETURNING id `, checkoutID, adminID).Scan(&saleID); err != nil { t.Fatalf("failed to seed failed till sale: %v", err) } pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit setup tx: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID) }) origClient := SquareClient SquareClient = &completedCheckoutClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{ Status: "COMPLETED", SquarePayID: "sqp_poll_already_failed", }} defer func() { SquareClient = origClient }() req := httptest.NewRequest("GET", "/api/admin/till/checkout/"+checkoutID+"/status", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("checkout_id", checkoutID) 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.Fatalf("expected 404 for a poll of a failed sale, got %d. body: %s", w.Code, w.Body.String()) } // The sale must STILL be failed — the poll must not have resurrected it. var status string if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil { t.Fatalf("failed to query till sale: %v", err) } if status != "failed" { t.Errorf("expected failed sale to remain failed after the guarded poll, got %q", status) } } // countingPaymentClient wraps the Square client and records every CreatePayment // invocation (its idempotency key and the returned payment) so tests can prove // a retry issued exactly ONE charge (Square dedup returns the original payment) // and that the charge reused the expected idempotency key. type countingPaymentClient struct { square.SquareClient mu sync.Mutex keys []string payments []*square.PaymentResult } func (c *countingPaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { res, err := c.SquareClient.CreatePayment(ctx, req) c.mu.Lock() defer c.mu.Unlock() c.keys = append(c.keys, req.IdempotencyKey) if err == nil { c.payments = append(c.payments, res) } return res, err } func (c *countingPaymentClient) callCount() int { c.mu.Lock() defer c.mu.Unlock() return len(c.keys) } // makeTillSaleRequest POSTs a till sale through the real router with the test // transaction embedded in the request context. func makeTillSaleRequest(t *testing.T, req TillSaleRequest, adminToken string, ctx context.Context, tx pgx.Tx) *httptest.ResponseRecorder { t.Helper() bodyBytes, _ := json.Marshal(req) r := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) r.Header.Set("Authorization", "Bearer "+adminToken) r.Header.Set("Content-Type", "application/json") r = r.WithContext(db.ContextWithTx(r.Context(), tx)) w := httptest.NewRecorder() router := chi.NewRouter() router.Use(mw.RequireAuth) router.Post("/api/admin/till/sale", CreateTillSale) router.ServeHTTP(w, r) return w } // TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge simulates // the P0 double-charge window: a charge lands at Square but the response is // lost, leaving the till_sale row 'pending'. A same-key retry must re-attempt // the charge with the SAME idempotency key — Square dedups on the key and // returns the ORIGINAL payment — so the customer is charged exactly once, the // same row is reused, and the gift card is funded exactly once. // This test asserts on a recording client swapped into the package-global // SquareClient, so it must NOT run in parallel: a concurrent test swapping // SquareClient to its own client can steal the recorded CreatePayment call // (rec.keys stays empty → index panic). func TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge(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") origClient := SquareClient rec := &countingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() key := "till-lost-response-single-charge" reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "online_square", CardToken: "cnon:lost-response-card", IdempotencyKey: key, } // First attempt: the charge succeeds at Square and the sale completes. first := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx)) if first.Code != http.StatusCreated { t.Fatalf("first attempt: expected 201, got %d. body: %s", first.Code, first.Body.String()) } var firstResp TillSaleResponse if err := json.NewDecoder(first.Body).Decode(&firstResp); err != nil { t.Fatalf("failed to decode first response: %v", err) } // Lost-response window: the charge keyed on `key` is live at Square (the // mock keeps it in paymentByKey) but the response never reached the // handler, so the sale row is stuck 'pending' with no payment id recorded. if _, err := tx.Exec(ctx, `UPDATE till_sales SET status = 'pending', square_payment_id = NULL WHERE id = $1`, firstResp.ID); err != nil { t.Fatalf("failed to simulate the lost response: %v", err) } // Retry with the same key: the pending-reuse path re-charges with the SAME // key, Square dedups to the original payment, and the same row completes. second := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx)) if second.Code != http.StatusOK && second.Code != http.StatusCreated { t.Fatalf("retry: expected 200/201, got %d. body: %s", second.Code, second.Body.String()) } var secondResp TillSaleResponse if err := json.NewDecoder(second.Body).Decode(&secondResp); err != nil { t.Fatalf("failed to decode retry response: %v", err) } // Exactly ONE till_sales row — the retry reused the pending row. if secondResp.ID != firstResp.ID { t.Errorf("expected the SAME till_sale row on retry, got %s then %s", firstResp.ID, secondResp.ID) } var saleCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE id = $1`, firstResp.ID).Scan(&saleCount); err != nil { t.Fatalf("failed to count till_sales: %v", err) } if saleCount != 1 { t.Errorf("expected 1 till_sale row (reused), got %d", saleCount) } // Square was invoked twice (attempt + retry) but the retry deduped on the // key: both invocations returned the SAME payment — one charge in total. if rec.callCount() != 2 { t.Errorf("expected 2 CreatePayment invocations (attempt + dedup retry), got %d", rec.callCount()) } rec.mu.Lock() oneCharge := len(rec.payments) == 2 && rec.payments[0].SquarePayID == rec.payments[1].SquarePayID rec.mu.Unlock() if !oneCharge { t.Errorf("expected the retry to dedup to the original payment (single charge), got %d distinct payments", len(rec.payments)) } // Exactly one funded gift card for this sale, still at the single value. var giftCardCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 50.00`).Scan(&giftCardCount); err != nil { t.Fatalf("failed to count gift cards: %v", err) } if giftCardCount != 1 { t.Errorf("expected 1 funded gift card (single funding), got %d", giftCardCount) } // The reused row must be completed with the payment id recorded. var saleStatus, sqPaymentID string if err := tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1`, firstResp.ID).Scan(&saleStatus, &sqPaymentID); err != nil { t.Fatalf("failed to read sale status: %v", err) } if saleStatus != "completed" { t.Errorf("expected sale to be completed after the retry, got %s", saleStatus) } if sqPaymentID == "" { t.Error("expected square_payment_id to be recorded after the retry") } } // TestCreateTillSale_PendingRetry_FreshKey_ReusesPendingRowViaGiftCard proves // the backend defense-in-depth: even when a retry arrives with a FRESH // idempotency key (an old frontend, or a regenerated key), a same-amount // top-up on the SAME gift card reuses the pending till_sale row and adopts its // STORED idempotency key — the Square charge dedups on the stored key instead // of charging the customer twice. func TestCreateTillSale_PendingRetry_FreshKey_ReusesPendingRowViaGiftCard(t *testing.T) { // Not t.Parallel(): asserts on rec.keys of the global SquareClient (see // TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge note). 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 funded gift card with a pending top-up till_sale (the first // attempt's Square charge failed after the DB commit — card already funded). var giftCardID string if 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); err != nil { t.Fatalf("failed to create gift card: %v", err) } storedKey := "till-original-topup-key" if _, 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 topup', 1, 50.00, 50.00, 'online_square', 'pending', NULL, NULL, $2, $3, NOW(), NOW()) `, giftCardID, storedKey, adminID); err != nil { t.Fatalf("failed to seed pending top-up: %v", err) } origClient := SquareClient rec := &countingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() // Retry with a GENUINELY FRESH key: same gift card, same amount, but a key // the backend has never seen (simulating an old/regenerated key). reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "topup", Amount: 50.00, GiftCardID: &giftCardID, PaymentMethod: "online_square", CardToken: "cnon:fresh-key-topup-card", IdempotencyKey: "till-fresh-key-topup", } w := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx)) if w.Code != http.StatusOK && w.Code != http.StatusCreated { t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String()) } // The pending row was reused, not duplicated, and keeps its stored key. var saleCount int var saleStatus, usedKey string if err := tx.QueryRow(ctx, ` SELECT COUNT(*), MAX(status), MAX(idempotency_key) FROM till_sales WHERE item_id = $1 `, giftCardID).Scan(&saleCount, &saleStatus, &usedKey); err != nil { t.Fatalf("failed to query till_sales: %v", err) } if saleCount != 1 { t.Errorf("expected 1 till_sale row (reused), got %d", saleCount) } if saleStatus != "completed" { t.Errorf("expected the reused row to complete, got %s", saleStatus) } if usedKey != storedKey { t.Errorf("expected the sale row to keep its stored key %q, got %q", storedKey, usedKey) } // The Square charge reused the STORED key (the dedup target), not the // fresh request key — a second charge on the fresh key would double-charge. rec.mu.Lock() gotKey := rec.keys[0] rec.mu.Unlock() if gotKey != storedKey { t.Errorf("expected Square charge to use the stored key %q, got %q", storedKey, gotKey) } // The gift card was funded exactly once — a second charge would have funded // it again. var remaining float64 if err := tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&remaining); err != nil { t.Fatalf("failed to read gift card: %v", err) } if remaining != 50.00 { t.Errorf("expected gift card funded once (£50.00), got £%.2f (double-funding!)", remaining) } } // TestCreateTillSale_PendingRetry_FreshKey_DifferentAmount_NewCharge proves a // retry with a genuinely different amount on the same gift card is a NEW sale: // the pending row is left untouched and a new Square charge is issued (the // amount mismatch is the only signal distinguishing a retry from a fresh // top-up). func TestCreateTillSale_PendingRetry_FreshKey_DifferentAmount_NewCharge(t *testing.T) { // Not t.Parallel(): asserts on rec.keys of the global SquareClient (see // TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge note). 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") var giftCardID string if 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); err != nil { t.Fatalf("failed to create gift card: %v", err) } if _, 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 topup', 1, 50.00, 50.00, 'online_square', 'pending', NULL, NULL, 'till-original-topup-key-2', $2, NOW(), NOW()) `, giftCardID, adminID); err != nil { t.Fatalf("failed to seed pending top-up: %v", err) } origClient := SquareClient rec := &countingPaymentClient{SquareClient: square.NewDevClient()} SquareClient = rec defer func() { SquareClient = origClient }() // Different amount on the same card with a fresh key: a genuinely // different sale — a new charge is allowed. reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "topup", Amount: 25.00, GiftCardID: &giftCardID, PaymentMethod: "online_square", CardToken: "cnon:new-amount-topup-card", IdempotencyKey: "till-fresh-key-topup-25", } w := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx)) if w.Code != http.StatusOK && w.Code != http.StatusCreated { t.Fatalf("expected 200/201 for the new sale, got %d. body: %s", w.Code, w.Body.String()) } // Two till_sales rows now: the untouched pending one and the new completed // one. var saleCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE item_id = $1`, giftCardID).Scan(&saleCount); err != nil { t.Fatalf("failed to count till_sales: %v", err) } if saleCount != 2 { t.Errorf("expected 2 till_sale rows (pending original + new sale), got %d", saleCount) } // The new sale charged Square exactly once, with its own fresh key. if rec.callCount() != 1 { t.Errorf("expected exactly 1 new Square charge, got %d", rec.callCount()) } rec.mu.Lock() gotKey := rec.keys[0] rec.mu.Unlock() if gotKey != "till-fresh-key-topup-25" { t.Errorf("expected the new charge to use the request's fresh key, got %q", gotKey) } // The card now holds £50 (original pending funding) + £25 (new top-up). var totalAdded float64 if err := tx.QueryRow(ctx, `SELECT total_funds_added FROM gift_cards WHERE id = $1`, giftCardID).Scan(&totalAdded); err != nil { t.Fatalf("failed to read gift card: %v", err) } if totalAdded != 75.00 { t.Errorf("expected gift card total funded to £75.00, got £%.2f", totalAdded) } // The original pending row is untouched. var origStatus string if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = 'till-original-topup-key-2'`).Scan(&origStatus); err != nil { t.Fatalf("failed to read original row: %v", err) } if origStatus != "pending" { t.Errorf("expected the original pending row to be untouched, got %s", origStatus) } } // ============================================================================= // CreateTillSale — the post-charge completion UPDATE is claim-first: a sale // resolved to 'failed' by the stale-pending sweep / gift-card clawback while // the Square charge is in flight must NOT be resurrected to 'completed'. // ============================================================================= // lockedBuffer is a mutex-guarded bytes.Buffer so the standard logger's output // can be captured and asserted on even when concurrent writers (mock // auto-complete goroutines) log independently. type lockedBuffer struct { buf bytes.Buffer mu sync.Mutex } func (b *lockedBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *lockedBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // captureStdLog redirects the standard logger's output to a mutex-guarded // buffer until the returned restore function is called. func captureStdLog(t *testing.T) (*lockedBuffer, func()) { t.Helper() l := &lockedBuffer{} orig := log.Writer() log.SetOutput(l) return l, func() { log.SetOutput(orig) } } // tillSweepResolveClient returns a successful CreatePayment but flips the // till_sales row to 'failed' first — simulating the stale-pending sweep / // gift-card clawback resolving the sale mid-flight (status change, row still // exists) while the Square charge is in flight. The retry's post-charge // completion UPDATE must then hit 0 rows instead of resurrecting the sale. type tillSweepResolveClient struct { square.SquareClient saleID string } func (c *tillSweepResolveClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { if _, err := db.Conn.Exec(context.Background(), `UPDATE till_sales SET status = 'failed', updated_at = NOW() WHERE id = $1`, c.saleID); err != nil { log.Printf("failed to simulate sweep resolution for till sale %s: %v", c.saleID, err) } return &square.PaymentResult{ Status: "COMPLETED", SquarePayID: "sqp_sweep_resolved_midflight", Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242", }, nil } // TestCreateTillSale_SweepResolvedMidFlight_NoResurrect locks the claim-first // guard: a pending till sale retried while the stale-pending sweep / gift-card // clawback marks the sale 'failed' (the charge was proven never to complete, so // the funded gift card is clawed back) must NOT be resurrected by the retry's // post-charge completion UPDATE. The Square charge still succeeds (money moves // at Square), so the handler detects RowsAffected()==0, logs CRITICAL and fails // the response — the sale stays 'failed', the row is never flipped back to // 'completed'. func TestCreateTillSale_SweepResolvedMidFlight_NoResurrect(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) } 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 (the retry target) with its funded gift card, // exactly like TestCreateTillSale_PendingRetry_ReattemptsSquare. key := "till-sweep-resolve-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) } var saleID string err = tx.QueryRow(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()) RETURNING id `, giftCardID, userID, cardID, key, adminID).Scan(&saleID) if err != nil { t.Fatalf("failed to seed pending till sale: %v", err) } pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit setup tx: %v", err) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID) _, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID) _, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID) _, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE id = $1`, cardID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id IN ($1, $2)`, userID, adminID) }) // The charge lands at Square while the sweep/clawback flips the sale to // 'failed' mid-flight. origClient := SquareClient SquareClient = &tillSweepResolveClient{SquareClient: square.NewDevClient(), saleID: saleID} defer func() { SquareClient = origClient }() logBuf, restore := captureStdLog(t) defer restore() 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") 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 the sweep-resolved retry to fail the response (500), got %d. body: %s", w.Code, w.Body.String()) } // The sale must NOT be resurrected to 'completed' by the post-charge // UPDATE — the sweep already resolved it to 'failed'. var status string var sqPayID *string if err := db.Conn.QueryRow(pool, `SELECT status, square_payment_id FROM till_sales WHERE id = $1`, saleID).Scan(&status, &sqPayID); err != nil { t.Fatalf("failed to query till sale: %v", err) } if status != "failed" { t.Errorf("expected the sweep-resolved sale to stay 'failed' (not resurrected), got %q", status) } if sqPayID != nil { t.Errorf("expected no square_payment_id written on the sweep-resolved sale, got %q", *sqPayID) } // The RowsAffected()==0 branch must have fired its CRITICAL reconciliation // log (money taken at Square + card funded + row already resolved). if got := logBuf.String(); !strings.Contains(got, "already resolved (0 rows updated)") { t.Errorf("expected the CRITICAL 0-rows reconciliation log, got: %s", got) } } // TestCreateTillSale_SavedCard_UserMismatch_Rejected locks the money-F1 fix: // when the admin names the customer being served (user_id), the saved card MUST // be owned by that customer. A card owned by a different user is rejected with // 400 before any Square charge — the ownership invariant every other charge // surface enforces, now enforced unconditionally at the till. func TestCreateTillSale_SavedCard_UserMismatch_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") // The customer the admin claims to be serving. claimedUserID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create claimed user: %v", err) } // The customer who actually owns the card. ownerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create card owner user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(tx, ownerID, "ccof:sq_test_card_id", "VISA", "1234") if err != nil { t.Fatalf("failed to create saved card: %v", err) } reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &claimedUserID, } 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 (ownership mismatch), got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "does not belong to the specified user") { t.Errorf("expected ownership-mismatch error body, got: %s", w.Body.String()) } // The rejected request must not have created a sale or funded a gift card // (the transaction is rolled back before commit). var saleCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE user_saved_card_id = $1`, cardID).Scan(&saleCount); err != nil { t.Fatalf("failed to query till_sales: %v", err) } if saleCount != 0 { t.Errorf("expected no till_sale for a rejected ownership mismatch, got %d", saleCount) } var gcCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE created_by = $1`, adminID).Scan(&gcCount); err != nil { t.Fatalf("failed to query gift_cards: %v", err) } if gcCount != 0 { t.Errorf("expected no gift card funded by a rejected ownership mismatch, got %d", gcCount) } } // TestCreateTillSale_SavedCard_OmittedUser_ChargesResolvedOwner locks the // money-F1 omitted-user path: when the admin does not name a customer, the till // still resolves the card's owner from the row and charges under it (the // admin-till legit use — the admin may charge any customer's card). Because the // card was never associated with a user named by the request, the charge // surfaces a CRITICAL 'critical_payment_log' operator notification for the // RESOLVED owner — the confused-deputy catch. func TestCreateTillSale_SavedCard_OmittedUser_ChargesResolvedOwner(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") ownerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create card owner user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(tx, ownerID, "ccof:sq_test_card_id", "VISA", "1234") if err != nil { t.Fatalf("failed to create saved card: %v", err) } reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, // UserID intentionally omitted — the admin-till legit use. } 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 (resolved-owner charge), 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) } // No user was named, so the sale row stores no user attribution. var saleUserID *string if err := tx.QueryRow(ctx, `SELECT user_id FROM till_sales WHERE id = $1`, resp.ID).Scan(&saleUserID); err != nil { t.Fatalf("failed to query till sale user: %v", err) } if saleUserID != nil { t.Errorf("expected the till sale to have no user attribution (user_id omitted), got %q", *saleUserID) } // The unassociated (omitted-user) charge must have raised the CRITICAL // operator notification for the RESOLVED owner. var notifCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1 AND booking_id IS NULL`, ownerID).Scan(¬ifCount); err != nil { t.Fatalf("failed to count admin notifications: %v", err) } if notifCount < 1 { t.Errorf("expected a CRITICAL admin notification for the resolved owner, got %d", notifCount) } } // TestCreateTillSale_SavedCard_ProvidedUserMatch_NoOwnerAudit locks the // money-F1 provided-user match path: when the admin names the customer and the // card IS owned by them, the charge proceeds normally and no confused-deputy // notification is raised (the card was associated with the sale's stated user). func TestCreateTillSale_SavedCard_ProvidedUserMatch_NoOwnerAudit(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") ownerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create card owner user: %v", err) } cardID, err := fixtures.CreateTestPaymentMethod(tx, ownerID, "ccof:sq_test_card_id", "VISA", "1234") if err != nil { t.Fatalf("failed to create saved card: %v", err) } reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "saved_card", UserSavedCardID: &cardID, UserID: &ownerID, } 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 (matching-owner charge), 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) } var notifCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, ownerID).Scan(¬ifCount); err != nil { t.Fatalf("failed to count admin notifications: %v", err) } if notifCount != 0 { t.Errorf("expected no confused-deputy notification when the card owner matches the stated user, got %d", notifCount) } } // TestCreateTillSale_TopupExpiredCard_Rejected locks the money-F4 fix: the till // top-up must reject an EXPIRED gift card with 400 and must NOT reset its // expiry_date — the top-up UPDATE rolls expiry forward to NOW()+months and // would otherwise resurrect a card the nightly cleanup job already forfeited. func TestCreateTillSale_TopupExpiredCard_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") // An expired card that still carries balance (the nightly cleanup has not // run yet). var cardID string err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date) VALUES (50.00, 50.00, $1, FALSE, NOW() - INTERVAL '1 day') RETURNING id `, adminID).Scan(&cardID) if err != nil { t.Fatalf("failed to insert expired gift card: %v", err) } 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.Fatalf("expected 400 (expired gift card), got %d. body: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "Gift card has expired") { t.Errorf("expected expired-card error body, got: %s", w.Body.String()) } // The card must be untouched: no funds added, no transaction, no sale, and // the expiry_date must NOT be reset to the future. var totalAdded, remaining float64 var expiry time.Time if err := tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&totalAdded, &remaining, &expiry); err != nil { t.Fatalf("failed to query gift card: %v", err) } if totalAdded != 50.00 || remaining != 50.00 { t.Errorf("expected funds untouched (50.00/50.00), got %.2f/%.2f", totalAdded, remaining) } if !expiry.Before(time.Now().UTC()) { t.Errorf("expected the expired card's expiry_date to NOT be reset (still in the past), got %v", expiry) } var txCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1`, cardID).Scan(&txCount); err != nil { t.Fatalf("failed to count gift_card_transactions: %v", err) } if txCount != 0 { t.Errorf("expected no gift_card_transaction on the rejected top-up, got %d", txCount) } var saleCount int if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE item_id = $1`, cardID).Scan(&saleCount); err != nil { t.Fatalf("failed to count till_sales: %v", err) } if saleCount != 0 { t.Errorf("expected no till_sale on the rejected top-up, got %d", saleCount) } } // TestCreateTillSale_TopupUnexpiredCard_Succeeds is the positive control for // the money-F4 expiry gate: a LIVE card can still be topped up and its expiry // is rolled forward to NOW()+expiryMonths as designed. func TestCreateTillSale_TopupUnexpiredCard_Succeeds(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") var cardID string err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date) VALUES (50.00, 50.00, $1, FALSE, NOW() + INTERVAL '1 year') 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: 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.StatusCreated { t.Fatalf("expected 201 for a live-card top-up, got %d. body: %s", w.Code, w.Body.String()) } var totalAdded, remaining float64 var expiry time.Time if err := tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&totalAdded, &remaining, &expiry); err != nil { t.Fatalf("failed to query gift card: %v", err) } if totalAdded != 75.00 || remaining != 75.00 { t.Errorf("expected funds topped up to 75.00, got %.2f/%.2f", totalAdded, remaining) } if !expiry.After(time.Now().UTC()) { t.Errorf("expected the live card's expiry to be rolled forward, got %v", expiry) } } // ============================================================================= // F5 — £5,000/day admin gift-card cap is race-free across concurrent till sales // ============================================================================= // TestCreateTillSale_DailyCap_Concurrent pins the F5 daily-cap serialization // fix: N concurrent till gift-card sales by the same admin must never let the // cumulative issued value exceed the £5,000/day cap. The till's cap check // (adminGiftCardValueToday) and the sale's gift-card creation run under the // SAME per-admin advisory lock the admin API surfaces use // (acquireGiftCardDailyCapLock), so every check sees the previous sale's // committed row — the excess requests are rejected. Without that per-admin lock // two DISTINCT concurrent sales by the same admin both read the same // pre-write cumulative value and both pass, over-issuing value (the per-sale // crussell:till: lock serializes ONE sale's retries, not // distinct concurrent sales). // // The per-transaction £250 cap bounds each till sale, so exceeding the £5,000 // daily cap needs 21 sales of £250; a semaphore bounds how many run // simultaneously (each handler holds one pool conn per advisory lock and one // for its transaction). The admin and every handler invocation run directly // against the REAL pool (no per-test transaction), so the batches exercise // genuine cross-connection concurrency exactly like production. func TestCreateTillSale_DailyCap_Concurrent(t *testing.T) { ctx := context.Background() adminID, err := fixtures.CreateTestAdminUser(db.Conn) if err != nil { t.Fatalf("failed to create admin: %v", err) } t.Cleanup(func() { cctx := context.Background() _, _ = db.Conn.Exec(cctx, `DELETE FROM admin_audit_log WHERE admin_id = $1`, adminID) _, _ = db.Conn.Exec(cctx, `DELETE FROM gift_card_transactions WHERE gift_card_id IN (SELECT id FROM gift_cards WHERE created_by = $1)`, adminID) _, _ = db.Conn.Exec(cctx, `DELETE FROM till_sales WHERE created_by = $1`, adminID) _, _ = db.Conn.Exec(cctx, `DELETE FROM gift_cards WHERE created_by = $1`, adminID) _, _ = db.Conn.Exec(cctx, `DELETE FROM users WHERE id = $1`, adminID) }) token := jwt.GenerateTestToken(adminID, "admin") const perSale = 250.00 // £250 per sale (at the £250 per-transaction cap) const totalOps = 21 // 21 × £250 = £5,250 > the £5,000 daily cap const concurrencyLimit = 4 // at most 4 handlers in flight (each holds 3 pool conns) sem := make(chan struct{}, concurrencyLimit) start := make(chan struct{}) var wg sync.WaitGroup var mu sync.Mutex successes := 0 failCodes := map[int]int{} for i := 0; i < totalOps; i++ { wg.Add(1) go func() { defer wg.Done() <-start sem <- struct{}{} defer func() { <-sem }() body, _ := json.Marshal(TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: perSale, PaymentMethod: "cash", }) r := httptest.NewRequest(http.MethodPost, "/api/admin/till/sale", bytes.NewReader(body)) r.Header.Set("Authorization", "Bearer "+token) r.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router := chi.NewRouter() router.Use(mw.RequireAuth) router.Post("/api/admin/till/sale", CreateTillSale) router.ServeHTTP(w, r) mu.Lock() if w.Code == http.StatusCreated { successes++ } else { failCodes[w.Code]++ } mu.Unlock() }() } close(start) wg.Wait() // Money-safety invariant under serialization: at most 20 of the 21 sales // may succeed (20 × £250 = £5,000 = the inclusive cap; the 21st would land // the day on £5,250 and must be rejected). A rejected attempt surfaces as // either the 400 cap rejection or a 409 from the bounded try-lock giving up // under heavy contention — both are the designed backpressure and neither // records value. Without the per-admin cap lock each batch reads the // pre-write cumulative value so all 21 succeed, overshooting the cap — // `successes > 20` (or a cumulative over the cap below) is the regression // signal this test must catch. if successes < 1 || successes > 20 { t.Errorf("expected between 1 and 20 of 21 concurrent till sales to succeed under the £5,000 cap, got %d (cumulative value £%.2f); failure codes: %v", successes, perSale*float64(successes), failCodes) } // The day's issued value (the cap signal) must never exceed the cap. issuedToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID) if err != nil { t.Fatalf("failed to query today's issued value: %v", err) } if int64(math.Round(issuedToday*100)) > maxAdminGiftCardDailyPence { t.Errorf("cumulative daily issued value £%.2f exceeds the £5,000 cap", issuedToday) } // The created cards must hold exactly the value of the successful sales. var totalCreated float64 if err := db.Conn.QueryRow(ctx, `SELECT COALESCE(SUM(total_funds_added), 0) FROM gift_cards WHERE created_by = $1`, adminID).Scan(&totalCreated); err != nil { t.Fatalf("failed to query created gift-card value: %v", err) } if totalCreated != perSale*float64(successes) { t.Errorf("expected issued gift-card value £%.2f, got £%.2f", perSale*float64(successes), totalCreated) } }