CRITICAL fixes: - C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode) - C2: OverrideAmount validated post-substitution (prevents negative money minting) - C3: Terminal gift-card payments store gift_card_id; refund credits user balance - C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead) - C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation) - C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund) MAJOR fixes: - M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed - M3: ProcessCancellationRefund returns commit error (was swallowed) - M5: Dispute webhook handling (created + state.updated + disputes table) MEDIUM fixes: - ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any) - ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens - ME3: Webhook handlers now mutate state (payment.updated, refund.updated) Frontend fixes: - Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows - CHARGE_AND_STORE intent for save-card flows (SCA compliance) - Nonce staleness check verified across all flows Additional fixes from adversarial re-review: - F1: Till-sale completed dedup echoes stored amount (C4-class) - F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class) - F3: Square-success UPDATE checks RowsAffected (till sales) - F4: Dispute reason truncated to 192 chars (prevents INSERT failure) - F5: Booking-user lookup failure marks refund failed (prevents silent money loss) - F6: Saved-card/tip rechecks wrapped in transaction (C5 residual) Tests: - 15 adversarial attack tests (negative override, zero override, terminal gift card, refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge amount, raw PAN, missing auth, gift card balance, concurrent refunds) - 14 webhook state tests (dispute created/state, payment/refund updated) - 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test - Full suite passes with -race (25 packages, 0 failures) 25 files changed, +1532/-275 lines
2715 lines
98 KiB
Go
2715 lines
98 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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)
|
|
req = adminRequestCtx(req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CreateTillSale — Validation gap tests
|
|
// =============================================================================
|
|
|
|
func TestCreateTillSale_InvalidItemType(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "booking",
|
|
Action: "create",
|
|
Amount: 1000,
|
|
PaymentMethod: "on_the_house",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateTillSale_InvalidAction(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "delete",
|
|
Amount: 1000,
|
|
PaymentMethod: "on_the_house",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateTillSale_TopupMissingGiftCardID(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "topup",
|
|
Amount: 1000,
|
|
PaymentMethod: "on_the_house",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateTillSale_SavedCardNoUserSavedCardID(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 1000,
|
|
PaymentMethod: "saved_card",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateTillSale_OnlineSquareNoCardToken(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 1000,
|
|
PaymentMethod: "online_square",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateTillSale_OnlineSquareWithToken(t *testing.T) {
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 1000,
|
|
PaymentMethod: "online_square",
|
|
CardToken: "cnon:visa",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
|
t.Errorf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateTillSale_PendingRetry_ReattemptsSquare verifies that a same-key
|
|
// retry after a failed Square charge (sale stuck 'pending', gift card already
|
|
// funded) re-attempts the charge and completes the sale — it must NOT return
|
|
// the stale 'pending' status without re-charging (silent money loss).
|
|
func TestCreateTillSale_PendingRetry_ReattemptsSquare(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
if err != nil {
|
|
t.Fatalf("failed to create saved card: %v", err)
|
|
}
|
|
|
|
// Seed a PENDING till_sale with the same key and a funded gift card —
|
|
// simulates a prior attempt where the Square charge failed after the DB
|
|
// transaction committed (card already funded).
|
|
key := "till-pending-retry-key"
|
|
var giftCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
|
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
|
RETURNING id
|
|
`, adminID).Scan(&giftCardID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create gift card: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
|
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
|
$2, $3, $4, $5, NOW(), NOW())
|
|
`, giftCardID, userID, cardID, key, adminID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending till sale: %v", err)
|
|
}
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "saved_card",
|
|
UserSavedCardID: &cardID,
|
|
UserID: &userID,
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The sale must now be 'completed' (Square re-attempted and succeeded).
|
|
var saleStatus string
|
|
var saleCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if saleCount != 1 {
|
|
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
|
|
}
|
|
if saleStatus != "completed" {
|
|
t.Errorf("expected pending sale to be completed after retry, got %s", saleStatus)
|
|
}
|
|
}
|
|
|
|
func TestCreateTillSale_CreateCash(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "cash",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp TillSaleResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.Status != "completed" {
|
|
t.Errorf("expected status 'completed', got '%s'", resp.Status)
|
|
}
|
|
if resp.PaymentMethod != "cash" {
|
|
t.Errorf("expected payment method 'cash', got '%s'", resp.PaymentMethod)
|
|
}
|
|
if resp.ItemType != "gift_card" {
|
|
t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType)
|
|
}
|
|
|
|
var saleCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount)
|
|
if err != nil {
|
|
t.Errorf("failed to query till_sales: %v", err)
|
|
}
|
|
if saleCount != 1 {
|
|
t.Errorf("expected 1 till_sale, got %d", saleCount)
|
|
}
|
|
}
|
|
|
|
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed verifies that two
|
|
// identical keyless cash gift-card creations both return 201. The idempotency-key
|
|
// fallback must be unique per request so legitimate repeat sales don't collide
|
|
// on the till_sales idempotency_key UNIQUE constraint.
|
|
func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
|
|
t.Parallel()
|
|
_, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
for i := 0; i < 2; i++ {
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "cash",
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code, "sale %d: expected 201, got %d. body: %s", i+1, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce verifies that
|
|
// a same-key retry of a pending create-with-redeem sale does NOT re-credit the
|
|
// user's balance. The redeem (zeroing the gift card + crediting
|
|
// user_giftcard_balances) must only run on the FIRST attempt of a create; a
|
|
// pending retry reuses the already-redeemed gift card and would otherwise credit
|
|
// the user a second time (money loss to the business).
|
|
func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
|
|
if err != nil {
|
|
t.Fatalf("failed to create saved card: %v", err)
|
|
}
|
|
|
|
// Seed a PENDING till_sale (prior attempt where Square failed after the DB
|
|
// transaction committed) whose gift card was already redeemed and the user
|
|
// already credited the first-attempt amount (£50).
|
|
key := "till-pending-retry-redeem-key"
|
|
var giftCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at, is_inventory, voucher_type_at_purchase)
|
|
VALUES (50.00, 0.00, $1, $2, NOW(), FALSE, 'SPV')
|
|
RETURNING id
|
|
`, adminID, userID).Scan(&giftCardID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed redeemed gift card: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
|
VALUES ($1, 50.00, NOW())
|
|
`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed user gift card balance: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
|
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
|
$2, $3, $4, $5, NOW(), NOW())
|
|
`, giftCardID, userID, cardID, key, adminID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending till sale: %v", err)
|
|
}
|
|
|
|
// Retry with the same key, requesting the redeem again.
|
|
redeemUserID := userID
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "saved_card",
|
|
UserSavedCardID: &cardID,
|
|
UserID: &userID,
|
|
IdempotencyKey: key,
|
|
RedeemToUserID: &redeemUserID,
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The sale must now be 'completed' (Square re-attempted and succeeded).
|
|
var saleStatus string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus)
|
|
if err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if saleStatus != "completed" {
|
|
t.Errorf("expected sale status 'completed' after retry, got %s", saleStatus)
|
|
}
|
|
|
|
// The user balance must still be £50 — credited EXACTLY ONCE, not £100.
|
|
var balance float64
|
|
err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
|
|
if err != nil {
|
|
t.Fatalf("failed to query user gift card balance: %v", err)
|
|
}
|
|
if balance != 50.00 {
|
|
t.Errorf("expected balance 50.00 (credited once), got %.2f", balance)
|
|
}
|
|
|
|
// The gift card must remain fully redeemed (amount_remaining still 0).
|
|
var amountRemaining float64
|
|
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&amountRemaining)
|
|
if err != nil {
|
|
t.Fatalf("failed to query gift card: %v", err)
|
|
}
|
|
if amountRemaining != 0.00 {
|
|
t.Errorf("expected amount_remaining 0.00, got %.2f", amountRemaining)
|
|
}
|
|
}
|
|
|
|
// TestCreateTillSale_TopupWithRedeem_Rejected verifies that a top-up request
|
|
// carrying a redeem_to_user_id is rejected outright. Allowing redeem on a first
|
|
// attempt topup destroys money: the topup branch accepts unredeemed cards with
|
|
// residual balance, and topping up £20 onto a card with £40 residual then
|
|
// redeeming would zero amount_remaining (£40 lost) while crediting the user only
|
|
// the top-up amount.
|
|
func TestCreateTillSale_TopupWithRedeem_Rejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
redeemerID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create redeemer user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
// An unredeemed card carrying residual balance — the dangerous topup+redeem case.
|
|
var cardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
|
VALUES (40.00, 40.00, $1, FALSE, 'SPV')
|
|
RETURNING id
|
|
`, adminID).Scan(&cardID)
|
|
if err != nil {
|
|
t.Fatalf("failed to insert gift card: %v", err)
|
|
}
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "topup",
|
|
Amount: 20.00,
|
|
PaymentMethod: "on_the_house",
|
|
GiftCardID: &cardID,
|
|
RedeemToUserID: &redeemerID,
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// No rows created: no till_sale, card untouched, no user balance credited.
|
|
var saleCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales`).Scan(&saleCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query till_sales: %v", err)
|
|
}
|
|
if saleCount != 0 {
|
|
t.Errorf("expected 0 till_sales, got %d", saleCount)
|
|
}
|
|
|
|
var totalFunds, amountRemaining float64
|
|
err = tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&totalFunds, &amountRemaining)
|
|
if err != nil {
|
|
t.Fatalf("failed to query gift card: %v", err)
|
|
}
|
|
if totalFunds != 40.00 {
|
|
t.Errorf("expected total_funds_added 40.00, got %.2f", totalFunds)
|
|
}
|
|
if amountRemaining != 40.00 {
|
|
t.Errorf("expected amount_remaining 40.00, got %.2f", amountRemaining)
|
|
}
|
|
|
|
var balanceCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, redeemerID).Scan(&balanceCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query user_giftcard_balances: %v", err)
|
|
}
|
|
if balanceCount != 0 {
|
|
t.Errorf("expected no user gift card balance row, got %d", balanceCount)
|
|
}
|
|
}
|
|
|
|
// TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout verifies that a
|
|
// same-key retry of a pending card_machine sale reuses the checkout already
|
|
// stored on the till_sales row instead of calling Square CreateCheckout a second
|
|
// time. The original checkout may still be live at the terminal; a fresh
|
|
// checkout would orphan it into an untracked charge.
|
|
func TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
// Seed a PENDING card_machine till_sale that already has a Square checkout.
|
|
key := "till-card-machine-reuse-key"
|
|
storedCheckoutID := "chk_pending_retry_stored"
|
|
var giftCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
|
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
|
RETURNING id
|
|
`, adminID).Scan(&giftCardID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create gift card: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
|
payment_method, status, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
|
|
$2, $3, $4, NOW(), NOW())
|
|
`, giftCardID, storedCheckoutID, key, adminID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending card_machine till sale: %v", err)
|
|
}
|
|
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 50.00,
|
|
PaymentMethod: "card_machine",
|
|
IdempotencyKey: key,
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp TillSaleResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
// The response must reference the STORED checkout, not a freshly created one.
|
|
// CreateCheckout always generates a new chk_mock_* id, so any second call
|
|
// would surface a different id here.
|
|
if resp.CheckoutID == nil || *resp.CheckoutID != storedCheckoutID {
|
|
t.Errorf("expected checkout_id to be the stored %q, got %v", storedCheckoutID, resp.CheckoutID)
|
|
}
|
|
if resp.Status != "pending" {
|
|
t.Errorf("expected status 'pending', got %s", resp.Status)
|
|
}
|
|
|
|
// The till_sales row must still reference the same checkout id, unchanged.
|
|
var rowCheckoutID string
|
|
var saleCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(square_checkout_id), '') FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &rowCheckoutID)
|
|
if err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if saleCount != 1 {
|
|
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
|
|
}
|
|
if rowCheckoutID != storedCheckoutID {
|
|
t.Errorf("expected till_sales.square_checkout_id to remain %q, got %q", storedCheckoutID, rowCheckoutID)
|
|
}
|
|
}
|
|
|
|
// TestCreateTillSale_PendingRetry_AmountMismatch_Rejected verifies that a
|
|
// same-key retry with a different amount is rejected (400) instead of reusing
|
|
// the pending sale — the gift card was already funded at the old amount, so
|
|
// charging a new amount would leave the card funded at the wrong value.
|
|
// Mirrors the tip/gift-card retry amount guard.
|
|
func TestCreateTillSale_PendingRetry_AmountMismatch_Rejected(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
|
|
|
// Seed a PENDING till_sale funded at £50.00.
|
|
key := "till-pending-amount-mismatch-key"
|
|
var giftCardID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
|
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
|
RETURNING id
|
|
`, adminID).Scan(&giftCardID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create gift card: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
|
payment_method, status, user_id, idempotency_key, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
|
NULL, $2, $3, NOW(), NOW())
|
|
`, giftCardID, key, adminID)
|
|
if err != nil {
|
|
t.Fatalf("failed to seed pending till sale: %v", err)
|
|
}
|
|
|
|
// Retry with the same key but a different amount (£60 instead of £50).
|
|
reqBody := TillSaleRequest{
|
|
ItemType: "gift_card",
|
|
Action: "create",
|
|
Amount: 60.00,
|
|
PaymentMethod: "saved_card",
|
|
IdempotencyKey: key,
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
|
|
|
w := httptest.NewRecorder()
|
|
r := chi.NewRouter()
|
|
r.Use(mw.RequireAuth)
|
|
r.Post("/api/admin/till/sale", CreateTillSale)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 (amount mismatch), got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The pending sale must be untouched.
|
|
var saleStatus string
|
|
var saleAmount float64
|
|
var saleCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), COALESCE(MAX(total_amount), 0) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus, &saleAmount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if saleCount != 1 {
|
|
t.Errorf("expected 1 till sale, got %d", saleCount)
|
|
}
|
|
if saleStatus != "pending" {
|
|
t.Errorf("expected pending sale to remain pending after rejected retry, got %s", saleStatus)
|
|
}
|
|
if saleAmount != 50.00 {
|
|
t.Errorf("expected sale amount to remain 50.00, got %.2f", saleAmount)
|
|
}
|
|
}
|
|
|
|
// TestCreateTillSale_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_LogsCritical_StillFailsSale locks
|
|
// the claim-first top-up guard: the gating claim succeeds (sale pending), then
|
|
// the guarded reversal is blocked because some of the top-up was already spent.
|
|
// The clawback must NOT fail (the sale still has to be marked failed) and must
|
|
// leave the card amounts untouched.
|
|
func TestRevertGiftCardFunding_TopupPartiallySpent_LogsCritical_StillFailsSale(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
|
|
var cardID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
|
|
VALUES (50.00, 10.00, $1, FALSE)
|
|
RETURNING id
|
|
`, adminID).Scan(&cardID); err != nil {
|
|
t.Fatalf("failed to seed gift card: %v", err)
|
|
}
|
|
|
|
var saleID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
|
|
VALUES ('gift_card', 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
|
|
RETURNING id
|
|
`, adminID).Scan(&saleID); err != nil {
|
|
t.Fatalf("failed to seed till sale: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
|
|
VALUES ($1, 'topup', 50.00, 'till_sale', $2)
|
|
`, cardID, saleID); err != nil {
|
|
t.Fatalf("failed to seed gift card transaction: %v", err)
|
|
}
|
|
|
|
// amount_remaining (10.00) < top-up (50.00) — the guarded UPDATE matches 0
|
|
// rows. The clawback must NOT fail (the sale still has to be marked failed)
|
|
// and must log CRITICAL for manual reconciliation.
|
|
err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID)
|
|
if err != nil {
|
|
t.Fatalf("revertGiftCardFunding must not fail when the guard blocks the reversal, got: %v", err)
|
|
}
|
|
|
|
var remaining, totalAdded float64
|
|
if err := tx.QueryRow(ctx, `SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &totalAdded); err != nil {
|
|
t.Fatalf("failed to query gift card: %v", err)
|
|
}
|
|
if remaining != 10.00 || totalAdded != 50.00 {
|
|
t.Errorf("guard-blocked reversal must leave the card amounts untouched, got remaining=%.2f total_funds_added=%.2f", remaining, totalAdded)
|
|
}
|
|
|
|
// The sale must still be marked failed — the whole point of the clawback.
|
|
var saleStatus string
|
|
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&saleStatus); err != nil {
|
|
t.Fatalf("failed to query till sale: %v", err)
|
|
}
|
|
if saleStatus != "failed" {
|
|
t.Errorf("expected till sale marked failed despite the guard-blocked reversal, got %q", saleStatus)
|
|
}
|
|
|
|
// This request's top-up transaction must be removed even though the card
|
|
// amount could not be reversed.
|
|
var txCount int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, cardID, saleID).Scan(&txCount); err != nil {
|
|
t.Fatalf("failed to count gift card transactions: %v", err)
|
|
}
|
|
if txCount != 0 {
|
|
t.Errorf("expected this request's top-up transaction removed, got %d", txCount)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|