//go:build test && dev package payments import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) func TestCreateTillSale_OnTheHouse(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "on_the_house", } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var resp TillSaleResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Status != "completed" { t.Errorf("expected status 'completed', got '%s'", resp.Status) } if resp.PaymentMethod != "on_the_house" { t.Errorf("expected payment method 'on_the_house', got '%s'", resp.PaymentMethod) } if resp.TotalAmount != 50.00 { t.Errorf("expected total amount 50.00, got %.2f", resp.TotalAmount) } if resp.ItemType != "gift_card" { t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType) } if resp.ItemID == nil || *resp.ItemID == "" { t.Error("expected item_id to be set (gift card ID)") } // Verify till_sale was created in DB var saleCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount) if err != nil { t.Errorf("failed to query till_sales: %v", err) } if saleCount != 1 { t.Errorf("expected 1 till_sale, got %d", saleCount) } // Verify gift card was created var gcCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", *resp.ItemID).Scan(&gcCount) if err != nil { t.Errorf("failed to query gift_cards: %v", err) } if gcCount != 1 { t.Errorf("expected 1 gift_card, got %d", gcCount) } } func TestCreateTillSale_Idempotency(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") idempotencyKey := "test-idempotency-key-001" reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 25.00, PaymentMethod: "on_the_house", IdempotencyKey: idempotencyKey, } bodyBytes, _ := json.Marshal(reqBody) // First request req1 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req1.Header.Set("Authorization", "Bearer "+adminToken) req1.Header.Set("Content-Type", "application/json") req1 = req1.WithContext(db.ContextWithTx(req1.Context(), tx.(pgx.Tx))) w1 := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w1, req1) if w1.Code != http.StatusCreated { t.Errorf("first request: expected status 201, got %d. body: %s", w1.Code, w1.Body.String()) } var resp1 TillSaleResponse if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil { t.Fatalf("failed to decode first response: %v", err) } if resp1.ID == "" { t.Fatal("expected first till_sale ID to be set") } // Second request with same idempotency key req2 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req2.Header.Set("Authorization", "Bearer "+adminToken) req2.Header.Set("Content-Type", "application/json") req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx))) w2 := httptest.NewRecorder() r2 := chi.NewRouter() r2.Use(mw.RequireAuth) r2.Post("/api/admin/till/sale", CreateTillSale) r2.ServeHTTP(w2, req2) // Idempotent response returns 200 (the handler does not set WriteHeader in the idempotency path) if w2.Code != http.StatusOK && w2.Code != http.StatusCreated { t.Errorf("second request: expected status 200 or 201, got %d. body: %s", w2.Code, w2.Body.String()) } var resp2 TillSaleResponse if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil { t.Fatalf("failed to decode second response: %v", err) } if resp1.ID != resp2.ID { t.Errorf("expected same till_sale ID for idempotent request, got %s and %s", resp1.ID, resp2.ID) } // Verify only one till_sale exists var saleCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount) if err != nil { t.Errorf("failed to query till_sales: %v", err) } if saleCount != 1 { t.Errorf("expected 1 till_sale (idempotent), got %d", saleCount) } } func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "on_the_house", } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String()) } var resp TillSaleResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.ItemID == nil || *resp.ItemID == "" { t.Fatal("expected item_id to be set") } // Verify gift_card_transactions was created var txCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase'", *resp.ItemID).Scan(&txCount) if err != nil { t.Errorf("failed to query gift_card_transactions: %v", err) } if txCount != 1 { t.Errorf("expected 1 gift_card_transaction with type 'purchase', got %d", txCount) } // Verify the transaction has reference_type = 'till_sale' and reference_id is set var refCount int err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id IS NOT NULL", *resp.ItemID).Scan(&refCount) if err != nil { t.Errorf("failed to query gift_card_transactions with reference: %v", err) } if refCount != 1 { t.Errorf("expected 1 gift_card_transaction with reference_type 'till_sale' and reference_id set, got %d", refCount) } } func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) { t.Parallel() _, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "invalid_method", } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") // Create a test user to act as the redeemer redeemerID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create redeemer user: %v", err) } // Insert a gift card that is already redeemed var cardID string err = tx.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at) VALUES (50.00, 0.00, $1, $2, NOW()) RETURNING id `, adminID, redeemerID).Scan(&cardID) if err != nil { t.Fatalf("failed to insert redeemed gift card: %v", err) } // Try to topup the redeemed card reqBody := TillSaleRequest{ ItemType: "gift_card", Action: "topup", Amount: 25.00, PaymentMethod: "on_the_house", GiftCardID: &cardID, } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String()) } } func TestCreateTillSale_SavedCard_TransactionFailure_SkipsSquare(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "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) 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) 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) 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) 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) 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: 1000, PaymentMethod: "online_square", CardToken: "cnon:visa", } bodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/admin/till/sale", CreateTillSale) r.ServeHTTP(w, req) if w.Code != http.StatusCreated && w.Code != http.StatusOK { t.Errorf("expected 200/201, got %d. body: %s", w.Code, w.Body.String()) } } // TestCreateTillSale_PendingRetry_ReattemptsSquare verifies that a same-key // retry after a failed Square charge (sale stuck 'pending', gift card already // funded) re-attempts the charge and completes the sale — it must NOT return // the stale 'pending' status without re-charging (silent money loss). func TestCreateTillSale_PendingRetry_ReattemptsSquare(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) if err != nil { t.Fatalf("failed to create admin user: %v", err) } userID, err := fixtures.CreateTestUser(tx) if err != nil { t.Fatalf("failed to create user: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "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, "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) } }