Files
popertotsandSisyphus 985b114c8b test: payments round-2 — webhook gate/M2 refund, gift-card cancel re-issue, till lock contention, sweep VAT rescue coverage
- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete
- giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed
- sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT
- till: suffixed-key slot scan lock held across Square round-trip

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

3565 lines
132 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build test && dev
package payments
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/internal/square"
"crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures"
"crussell/testutils/jwt"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
func TestCreateTillSale_OnTheHouse(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status 'completed', got '%s'", resp.Status)
}
if resp.PaymentMethod != "on_the_house" {
t.Errorf("expected payment method 'on_the_house', got '%s'", resp.PaymentMethod)
}
if resp.TotalAmount != 50.00 {
t.Errorf("expected total amount 50.00, got %.2f", resp.TotalAmount)
}
if resp.ItemType != "gift_card" {
t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType)
}
if resp.ItemID == nil || *resp.ItemID == "" {
t.Error("expected item_id to be set (gift card ID)")
}
// Verify till_sale was created in DB
var saleCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale, got %d", saleCount)
}
// Verify gift card was created
var gcCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE id = $1", *resp.ItemID).Scan(&gcCount)
if err != nil {
t.Errorf("failed to query gift_cards: %v", err)
}
if gcCount != 1 {
t.Errorf("expected 1 gift_card, got %d", gcCount)
}
}
func TestCreateTillSale_Idempotency(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
idempotencyKey := "test-idempotency-key-001"
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 25.00,
PaymentMethod: "on_the_house",
IdempotencyKey: idempotencyKey,
}
bodyBytes, _ := json.Marshal(reqBody)
// First request
req1 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req1.Header.Set("Authorization", "Bearer "+adminToken)
req1.Header.Set("Content-Type", "application/json")
req1 = req1.WithContext(db.ContextWithTx(req1.Context(), tx.(pgx.Tx)))
w1 := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w1, req1)
if w1.Code != http.StatusCreated {
t.Errorf("first request: expected status 201, got %d. body: %s", w1.Code, w1.Body.String())
}
var resp1 TillSaleResponse
if err := json.NewDecoder(w1.Body).Decode(&resp1); err != nil {
t.Fatalf("failed to decode first response: %v", err)
}
if resp1.ID == "" {
t.Fatal("expected first till_sale ID to be set")
}
// Second request with same idempotency key
req2 := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req2.Header.Set("Authorization", "Bearer "+adminToken)
req2.Header.Set("Content-Type", "application/json")
req2 = req2.WithContext(db.ContextWithTx(req2.Context(), tx.(pgx.Tx)))
w2 := httptest.NewRecorder()
r2 := chi.NewRouter()
r2.Use(mw.RequireAuth)
r2.Post("/api/admin/till/sale", CreateTillSale)
r2.ServeHTTP(w2, req2)
// Idempotent response returns 200 (the handler does not set WriteHeader in the idempotency path)
if w2.Code != http.StatusOK && w2.Code != http.StatusCreated {
t.Errorf("second request: expected status 200 or 201, got %d. body: %s", w2.Code, w2.Body.String())
}
var resp2 TillSaleResponse
if err := json.NewDecoder(w2.Body).Decode(&resp2); err != nil {
t.Fatalf("failed to decode second response: %v", err)
}
if resp1.ID != resp2.ID {
t.Errorf("expected same till_sale ID for idempotent request, got %s and %s", resp1.ID, resp2.ID)
}
// Verify only one till_sale exists
var saleCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales").Scan(&saleCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale (idempotent), got %d", saleCount)
}
}
func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.ItemID == nil || *resp.ItemID == "" {
t.Fatal("expected item_id to be set")
}
// Verify gift_card_transactions was created
var txCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'purchase'", *resp.ItemID).Scan(&txCount)
if err != nil {
t.Errorf("failed to query gift_card_transactions: %v", err)
}
if txCount != 1 {
t.Errorf("expected 1 gift_card_transaction with type 'purchase', got %d", txCount)
}
// Verify the transaction has reference_type = 'till_sale' and reference_id is set
var refCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id IS NOT NULL", *resp.ItemID).Scan(&refCount)
if err != nil {
t.Errorf("failed to query gift_card_transactions with reference: %v", err)
}
if refCount != 1 {
t.Errorf("expected 1 gift_card_transaction with reference_type 'till_sale' and reference_id set, got %d", refCount)
}
}
func TestCreateTillSale_InvalidPaymentMethod(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "invalid_method",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a test user to act as the redeemer
redeemerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create redeemer user: %v", err)
}
// Insert a gift card that is already redeemed
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at)
VALUES (50.00, 0.00, $1, $2, NOW())
RETURNING id
`, adminID, redeemerID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert redeemed gift card: %v", err)
}
// Try to topup the redeemed card
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 25.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_SavedCard_TransactionFailure_SkipsSquare(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(cancelCtx, tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d. body: %s", w.Code, w.Body.String())
}
var completedCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE status = 'completed'").Scan(&completedCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if completedCount != 0 {
t.Errorf("expected 0 completed till_sales, got %d", completedCount)
}
}
// =============================================================================
// GetTillCheckoutStatus — GET /api/admin/till/checkout/{checkout_id}/status
// =============================================================================
func TestGetTillCheckoutStatus_NotFound(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/till/checkout/nonexistent/status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "nonexistent")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
req = adminRequestCtx(req)
w := httptest.NewRecorder()
GetTillCheckoutStatus(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestGetTillCheckoutStatus_Pending(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
// Hold the mock checkout so it stays PENDING for testing
if mc, ok := SquareClient.(*square.MockClient); ok {
mc.HoldCheckouts = true
t.Cleanup(func() { mc.HoldCheckouts = false })
}
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a till sale with card_machine payment to generate a Square checkout.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var createResp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.CheckoutID == nil || *createResp.CheckoutID == "" {
t.Fatal("expected checkout_id to be set for card_machine payment")
}
// Now call GetTillCheckoutStatus with the checkout_id.
statusReq := httptest.NewRequest("GET", "/api/admin/till/checkout/"+*createResp.CheckoutID+"/status", nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", *createResp.CheckoutID)
statusReqCtx := context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusRCtx)
statusReqCtx = db.ContextWithTx(statusReqCtx, tx.(pgx.Tx))
statusReq = statusReq.WithContext(statusReqCtx)
statusReq = adminRequestCtx(statusReq)
wStatus := httptest.NewRecorder()
GetTillCheckoutStatus(wStatus, statusReq)
if wStatus.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", wStatus.Code, wStatus.Body.String())
}
var statusResp PaymentStatusResponse
if err := json.NewDecoder(wStatus.Body).Decode(&statusResp); err != nil {
t.Fatalf("failed to parse status response: %v", err)
}
if statusResp.Status != "PENDING" {
t.Errorf("expected status PENDING, got %s", statusResp.Status)
}
}
func TestGetTillCheckoutStatus_Completed(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a till sale with card_machine payment to generate a Square checkout.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var createResp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.CheckoutID == nil || *createResp.CheckoutID == "" {
t.Fatal("expected checkout_id to be set for card_machine payment")
}
// Wait for the mock goroutine to complete the checkout with polling.
require.Eventually(t, func() bool {
_, err := SquareClient.GetCheckout(context.Background(), *createResp.CheckoutID)
return err == nil
}, 5*time.Second, 100*time.Millisecond)
// Now call GetTillCheckoutStatus — should return COMPLETED.
statusReq := httptest.NewRequest("GET", "/api/admin/till/checkout/"+*createResp.CheckoutID+"/status", nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", *createResp.CheckoutID)
statusReqCtx := context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusRCtx)
statusReqCtx = db.ContextWithTx(statusReqCtx, tx.(pgx.Tx))
statusReq = statusReq.WithContext(statusReqCtx)
statusReq = adminRequestCtx(statusReq)
wStatus := httptest.NewRecorder()
GetTillCheckoutStatus(wStatus, statusReq)
if wStatus.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d. body: %s", wStatus.Code, wStatus.Body.String())
}
var statusResp PaymentStatusResponse
if err := json.NewDecoder(wStatus.Body).Decode(&statusResp); err != nil {
t.Fatalf("failed to parse status response: %v", err)
}
if statusResp.Status != "COMPLETED" {
t.Errorf("expected status COMPLETED, got %s", statusResp.Status)
}
if statusResp.PaymentID == "" {
t.Error("expected payment_id to be set")
}
}
func TestGetTillCheckoutStatus_AlreadyCompleted(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Create a till sale with on_the_house so it's immediately completed.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 25.00,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var createResp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil {
t.Fatalf("failed to decode create response: %v", err)
}
if createResp.ID == "" {
t.Fatal("expected till sale ID")
}
// on_the_house doesn't create a Square checkout, so GetTillCheckoutStatus
// with a non-existent checkout_id should return 404.
statusReq := httptest.NewRequest("GET", "/api/admin/till/checkout/nonexistent/status", nil)
statusRCtx := chi.NewRouteContext()
statusRCtx.URLParams.Add("checkout_id", "nonexistent")
statusReqCtx := context.WithValue(statusReq.Context(), chi.RouteCtxKey, statusRCtx)
statusReq = statusReq.WithContext(statusReqCtx)
statusReq = adminRequestCtx(statusReq)
wStatus := httptest.NewRecorder()
GetTillCheckoutStatus(wStatus, statusReq)
if wStatus.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d. body: %s", wStatus.Code, wStatus.Body.String())
}
}
func TestGetTillCheckoutStatus_EmptyCheckoutID(t *testing.T) {
_, _ = testutils.SetupTestTx(t)
req := httptest.NewRequest("GET", "/api/admin/till/checkout//status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", "")
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
req = adminRequestCtx(req)
w := httptest.NewRecorder()
GetTillCheckoutStatus(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
// =============================================================================
// CreateTillSale — Validation gap tests
// =============================================================================
func TestCreateTillSale_InvalidItemType(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "booking",
Action: "create",
Amount: 1000,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_InvalidAction(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "delete",
Amount: 1000,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_TopupMissingGiftCardID(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 1000,
PaymentMethod: "on_the_house",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_SavedCardNoUserSavedCardID(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 1000,
PaymentMethod: "saved_card",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_OnlineSquareNoCardToken(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 1000,
PaymentMethod: "online_square",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
}
}
func TestCreateTillSale_OnlineSquareWithToken(t *testing.T) {
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50,
PaymentMethod: "online_square",
CardToken: "cnon:visa",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Errorf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestCreateTillSale_PendingRetry_ReattemptsSquare verifies that a same-key
// retry after a failed Square charge (sale stuck 'pending', gift card already
// funded) re-attempts the charge and completes the sale — it must NOT return
// the stale 'pending' status without re-charging (silent money loss).
func TestCreateTillSale_PendingRetry_ReattemptsSquare(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale with the same key and a funded gift card —
// simulates a prior attempt where the Square charge failed after the DB
// transaction committed (card already funded).
key := "till-pending-retry-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The sale must now be 'completed' (Square re-attempted and succeeded).
var saleStatus string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if saleStatus != "completed" {
t.Errorf("expected pending sale to be completed after retry, got %s", saleStatus)
}
}
func TestCreateTillSale_CreateCash(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status 'completed', got '%s'", resp.Status)
}
if resp.PaymentMethod != "cash" {
t.Errorf("expected payment method 'cash', got '%s'", resp.PaymentMethod)
}
if resp.ItemType != "gift_card" {
t.Errorf("expected item type 'gift_card', got '%s'", resp.ItemType)
}
var saleCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM till_sales WHERE id = $1", resp.ID).Scan(&saleCount)
if err != nil {
t.Errorf("failed to query till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale, got %d", saleCount)
}
}
// TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed verifies that two
// identical keyless cash gift-card creations both return 201. The idempotency-key
// fallback must be unique per request so legitimate repeat sales don't collide
// on the till_sales idempotency_key UNIQUE constraint.
func TestCreateTillSale_TwoIdenticalCreateSales_BothSucceed(t *testing.T) {
t.Parallel()
_, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
require.NoError(t, err)
adminToken := jwt.GenerateTestToken(adminID, "admin")
for i := 0; i < 2; i++ {
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "sale %d: expected 201, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
// TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce verifies that
// a same-key retry of a pending create-with-redeem sale does NOT re-credit the
// user's balance. The redeem (zeroing the gift card + crediting
// user_giftcard_balances) must only run on the FIRST attempt of a create; a
// pending retry reuses the already-redeemed gift card and would otherwise credit
// the user a second time (money loss to the business).
func TestCreateTillSale_PendingRetry_RedeemToUser_BalanceCreditedOnce(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale (prior attempt where Square failed after the DB
// transaction committed) whose gift card was already redeemed and the user
// already credited the first-attempt amount (£50).
key := "till-pending-retry-redeem-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, redeemed_by, redeemed_at, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 0.00, $1, $2, NOW(), FALSE, 'SPV')
RETURNING id
`, adminID, userID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to seed redeemed gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, 50.00, NOW())
`, userID)
if err != nil {
t.Fatalf("failed to seed user gift card balance: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
`, giftCardID, userID, cardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Retry with the same key, requesting the redeem again.
redeemUserID := userID
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
RedeemToUserID: &redeemUserID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The sale must now be 'completed' (Square re-attempted and succeeded).
var saleStatus string
err = tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected sale status 'completed' after retry, got %s", saleStatus)
}
// The user balance must still be £50 — credited EXACTLY ONCE, not £100.
var balance float64
err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query user gift card balance: %v", err)
}
if balance != 50.00 {
t.Errorf("expected balance 50.00 (credited once), got %.2f", balance)
}
// The gift card must remain fully redeemed (amount_remaining still 0).
var amountRemaining float64
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 0.00 {
t.Errorf("expected amount_remaining 0.00, got %.2f", amountRemaining)
}
}
// TestCreateTillSale_TopupWithRedeem_Rejected verifies that a top-up request
// carrying a redeem_to_user_id is rejected outright. Allowing redeem on a first
// attempt topup destroys money: the topup branch accepts unredeemed cards with
// residual balance, and topping up £20 onto a card with £40 residual then
// redeeming would zero amount_remaining (£40 lost) while crediting the user only
// the top-up amount.
func TestCreateTillSale_TopupWithRedeem_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
redeemerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create redeemer user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// An unredeemed card carrying residual balance — the dangerous topup+redeem case.
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (40.00, 40.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 20.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
RedeemToUserID: &redeemerID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d. body: %s", w.Code, w.Body.String())
}
// No rows created: no till_sale, card untouched, no user balance credited.
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales`).Scan(&saleCount)
if err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if saleCount != 0 {
t.Errorf("expected 0 till_sales, got %d", saleCount)
}
var totalFunds, amountRemaining float64
err = tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&totalFunds, &amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if totalFunds != 40.00 {
t.Errorf("expected total_funds_added 40.00, got %.2f", totalFunds)
}
if amountRemaining != 40.00 {
t.Errorf("expected amount_remaining 40.00, got %.2f", amountRemaining)
}
var balanceCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances WHERE user_id = $1`, redeemerID).Scan(&balanceCount)
if err != nil {
t.Fatalf("failed to query user_giftcard_balances: %v", err)
}
if balanceCount != 0 {
t.Errorf("expected no user gift card balance row, got %d", balanceCount)
}
}
// TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout verifies that a
// same-key retry of a pending card_machine sale reuses the checkout already
// stored on the till_sales row instead of calling Square CreateCheckout a second
// time. The original checkout may still be live at the terminal; a fresh
// checkout would orphan it into an untracked charge.
func TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING card_machine till_sale that already has a Square checkout.
key := "till-card-machine-reuse-key"
storedCheckoutID := "chk_pending_retry_stored"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
$2, $3, $4, NOW(), NOW())
`, giftCardID, storedCheckoutID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending card_machine till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// The response must reference the STORED checkout, not a freshly created one.
// CreateCheckout always generates a new chk_mock_* id, so any second call
// would surface a different id here.
if resp.CheckoutID == nil || *resp.CheckoutID != storedCheckoutID {
t.Errorf("expected checkout_id to be the stored %q, got %v", storedCheckoutID, resp.CheckoutID)
}
if resp.Status != "pending" {
t.Errorf("expected status 'pending', got %s", resp.Status)
}
// The till_sales row must still reference the same checkout id, unchanged.
var rowCheckoutID string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(MAX(square_checkout_id), '') FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &rowCheckoutID)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if rowCheckoutID != storedCheckoutID {
t.Errorf("expected till_sales.square_checkout_id to remain %q, got %q", storedCheckoutID, rowCheckoutID)
}
}
// TestCreateTillSale_PendingRetry_AmountMismatch_Rejected verifies that a
// same-key retry with a different amount is rejected (400) instead of reusing
// the pending sale — the gift card was already funded at the old amount, so
// charging a new amount would leave the card funded at the wrong value.
// Mirrors the tip/gift-card retry amount guard.
func TestCreateTillSale_PendingRetry_AmountMismatch_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING till_sale funded at £50.00.
key := "till-pending-amount-mismatch-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Retry with the same key but a different amount (£60 instead of £50).
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 60.00,
PaymentMethod: "saved_card",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 (amount mismatch), got %d. body: %s", w.Code, w.Body.String())
}
// The pending sale must be untouched.
var saleStatus string
var saleAmount float64
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), COALESCE(MAX(total_amount), 0) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus, &saleAmount)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale, got %d", saleCount)
}
if saleStatus != "pending" {
t.Errorf("expected pending sale to remain pending after rejected retry, got %s", saleStatus)
}
if saleAmount != 50.00 {
t.Errorf("expected sale amount to remain 50.00, got %.2f", saleAmount)
}
}
// TestCreateTillSale_CompletedDedup_AmountMismatch_Rejected verifies the
// C4-class dedup guard on the COMPLETED path (the C4 fix covered refunds and
// the till PENDING path, but the completed dedup echoed the freshly-requested
// amount and accepted any amount): a same-key retry of a completed sale with a
// different amount must be rejected (400), never silently reported as the new
// amount.
func TestCreateTillSale_CompletedDedup_AmountMismatch_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a COMPLETED till sale funded at £50.00.
key := "till-completed-dedup-mismatch-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'completed',
NULL, 'sqp_completed_dedup', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed completed till sale: %v", err)
}
// Retry with the same key but a different amount (£60 instead of £50).
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 60.00,
PaymentMethod: "saved_card",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 (completed dedup amount mismatch), got %d. body: %s", w.Code, w.Body.String())
}
// The completed sale must be untouched.
var saleCount int
var saleStatus string
var saleAmount float64
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), COALESCE(MAX(total_amount), 0) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus, &saleAmount)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale, got %d", saleCount)
}
if saleStatus != "completed" {
t.Errorf("expected completed sale to remain completed, got %s", saleStatus)
}
if saleAmount != 50.00 {
t.Errorf("expected sale amount to remain 50.00, got %.2f", saleAmount)
}
}
// TestCreateTillSale_CompletedDedup_ReturnsStoredAmount verifies the C4-class
// dedup fix on the COMPLETED till-sale path: a same-key retry reports the
// STORED sale (amount, payment method, item type), not the freshly-requested
// values — a retry with a different amount would otherwise mislead the till.
func TestCreateTillSale_CompletedDedup_ReturnsStoredAmount(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
key := "till-completed-dedup-stored-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'completed',
NULL, 'sqp_completed_dedup_stored', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed completed till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash", // differs from the stored 'online_square' — must NOT be echoed
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (completed dedup), got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.TotalAmount != 50.00 {
t.Errorf("expected dedup response to report the STORED total 50.00, got %.2f", resp.TotalAmount)
}
if resp.PaymentMethod != "online_square" {
t.Errorf("expected dedup response to report the STORED payment method 'online_square', got %q", resp.PaymentMethod)
}
if resp.Status != "completed" {
t.Errorf("expected dedup response status 'completed', got %q", resp.Status)
}
}
// TestCreateTillSale_PendingRetry_Cash_CompletesRow verifies that a same-key
// retry resolved by cash on a pending sale whose Square charge PROVABLY failed
// (HIGH-3: a FAILED status means no money landed, so cash is safe) explicitly
// flips the row to 'completed' — previously the response claimed success while
// the DB row stayed pending forever (unreconciled).
func TestCreateTillSale_PendingRetry_Cash_CompletesRow(t *testing.T) {
// Not parallel: swaps the package-global SquareClient.
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING till_sale (prior online_square attempt failed post-commit)
// whose Square charge is recorded as FAILED — provably no money landed.
key := "till-pending-cash-retry-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, 'sqp_cash_retry_failed', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: "FAILED", SquarePayID: "sqp_cash_retry_failed"}}
defer func() { SquareClient = origClient }()
// Retry with the same key, same amount, cash payment.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The reused row must now be 'completed' — the cash retry explicitly flips it.
var saleStatus string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
}
if saleStatus != "completed" {
t.Errorf("expected pending sale to be completed by cash retry, got %s", saleStatus)
}
}
// TestCreateTillSale_PendingRetry_Cash_NotFound_CompletesRow verifies the
// HIGH-3 NOT_FOUND branch: a pending sale whose square_payment_id does NOT
// resolve at Square is provably never charged, so cash may complete it.
func TestCreateTillSale_PendingRetry_Cash_NotFound_CompletesRow(t *testing.T) {
// Not parallel: swaps the package-global SquareClient.
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
key := "till-pending-cash-notfound-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, 'sqp_cash_retry_not_found', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(),
err: fmt.Errorf("square: GET /v2/payments/sqp_cash_retry_not_found: [PAYMENT_NOT_FOUND/NOT_FOUND] payment does not exist")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
var saleStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected pending sale to be completed by cash retry after NOT_FOUND reconcile, got %s", saleStatus)
}
}
// TestCreateTillSale_PendingRetry_Cash_Rejected_LostResponse verifies the
// HIGH-3 lost-response branch: a pending sale with NO square_payment_id cannot
// be resolved with cash — the charge may have landed at Square and cannot be
// looked up, so the retry must stay on the original card method (Square dedups
// on the same idempotency key and resolves the lost response).
func TestCreateTillSale_PendingRetry_Cash_Rejected_LostResponse(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
key := "till-pending-cash-lost-response-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
// No square_payment_id — the lost-response case.
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 (force original card method), got %d. body: %s", w.Code, w.Body.String())
}
// The pending sale must be untouched.
var saleStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "pending" {
t.Errorf("expected pending sale to remain pending after rejected cash retry, got %s", saleStatus)
}
}
// TestCreateTillSale_PendingRetry_Cash_Rejected_AlreadyPaid verifies the
// HIGH-3 COMPLETED branch: a pending sale whose Square charge is COMPLETED has
// already been paid — the sale is rescued to 'completed' and the cash is
// refused (double payment). The setup is COMMITTED so the handler runs at pool
// level: the rescue UPDATE is issued via db.Conn.Exec on a connection
// independent of the handler's transaction (as in production) and must persist
// even though the handler returns 409 before committing its own tx.
func TestCreateTillSale_PendingRetry_Cash_Rejected_AlreadyPaid(t *testing.T) {
// Not parallel: swaps the package-global SquareClient.
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
key := "till-pending-cash-already-paid-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, 'sqp_cash_retry_completed', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
// Commit the setup so the handler runs at pool level — the rescue UPDATE
// (db.Conn.Exec) then commits on its own connection exactly as in prod.
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE idempotency_key = $1`, key)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
})
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: "COMPLETED", SquarePayID: "sqp_cash_retry_completed"}}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(pool)
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 (already paid by card), got %d. body: %s", w.Code, w.Body.String())
}
// The sale must have been rescued to 'completed' at pool level.
var saleStatus string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected already-paid pending sale rescued to completed, got %s", saleStatus)
}
}
// TestCreateTillSale_PendingRetry_Cash_Rejected_Ambiguous verifies the HIGH-3
// ambiguous branch: a transport/server error from GetPayment leaves the charge
// outcome unknown — cash must not be taken and the retry gets 503.
func TestCreateTillSale_PendingRetry_Cash_Rejected_Ambiguous(t *testing.T) {
// Not parallel: swaps the package-global SquareClient.
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
key := "till-pending-cash-ambiguous-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, 'sqp_cash_retry_ambiguous', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), err: fmt.Errorf("network error: connection reset by peer")}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 (unable to confirm card status), got %d. body: %s", w.Code, w.Body.String())
}
// The pending sale must be untouched.
var saleStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "pending" {
t.Errorf("expected pending sale to remain pending after ambiguous reconcile, got %s", saleStatus)
}
}
// TestCreateTillSale_PendingRetry_OnTheHouse_DefinitivelyFailed_CompletesRow
// verifies the HIGH-3 guard also fires for on_the_house: a pending sale whose
// Square charge provably failed can be resolved with on_the_house.
func TestCreateTillSale_PendingRetry_OnTheHouse_DefinitivelyFailed_CompletesRow(t *testing.T) {
// Not parallel: swaps the package-global SquareClient.
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
key := "till-pending-oth-retry-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_payment_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, 'sqp_oth_retry_failed', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
origClient := SquareClient
SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: "CANCELED", SquarePayID: "sqp_oth_retry_failed"}}
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "on_the_house",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
var saleStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected pending sale completed by on_the_house retry after definitive failure, got %s", saleStatus)
}
}
// TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected verifies that a
// pending card_machine sale with a live checkout cannot be retried via a
// different method — the original terminal checkout is still live and could
// complete, causing a double charge.
func TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a PENDING card_machine sale with a stored live checkout.
key := "till-pending-method-switch-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
NULL, 'chk_live_switch', $2, $3, NOW(), NOW())
`, giftCardID, key, adminID)
if err != nil {
t.Fatalf("failed to seed pending card-machine till sale: %v", err)
}
// Retry with the same key, same amount, but cash — must be rejected (409).
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "cash",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409 (method switch on live checkout), got %d. body: %s", w.Code, w.Body.String())
}
// The pending sale must be untouched.
var saleStatus string
var saleCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
if err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till sale, got %d", saleCount)
}
if saleStatus != "pending" {
t.Errorf("expected pending sale to remain pending after rejected switch, got %s", saleStatus)
}
}
// =============================================================================
// revertGiftCardFunding — top-up guard-blocked reversal still fails the sale
// =============================================================================
// TestRevertGiftCardFunding_TopupPartiallySpent_ClampsAndFlags locks the M6
// fix: the clawback's balance guard no longer leaves the funding in place when
// some of the top-up was already spent. The reversal CLAMPS the card to zero
// (everything still on the card is reclaimed), the sale is still marked failed,
// and the unreclaimable spent portion surfaces as a CRITICAL admin notification
// + errClawbackPartiallyReversed so every caller flags reconciliation.
func TestRevertGiftCardFunding_TopupPartiallySpent_ClampsAndFlags(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var cardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES (50.00, 10.00, $1, FALSE)
RETURNING id
`, adminID).Scan(&cardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
var saleID string
if err := tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending', $1, NOW(), NOW())
RETURNING id
`, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed till sale: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'topup', 50.00, 'till_sale', $2)
`, cardID, saleID); err != nil {
t.Fatalf("failed to seed gift card transaction: %v", err)
}
// amount_remaining (10.00) < top-up (50.00) — some of the funding was
// already spent. M6: the clawback must CLAMP the card to zero and return
// errClawbackPartiallyReversed (funding reverts except the unrecoverable
// spent portion), never leave the funding on the card.
err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID)
if !errors.Is(err, errClawbackPartiallyReversed) {
t.Fatalf("expected errClawbackPartiallyReversed when the top-up was partially spent, got %v", err)
}
var remaining, totalAdded float64
if err := tx.QueryRow(ctx, `SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &totalAdded); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if remaining != 0.00 || totalAdded != 0.00 {
t.Errorf("expected the partially-spent top-up clamped to zero, got remaining=%.2f total_funds_added=%.2f", remaining, totalAdded)
}
// The sale must still be marked failed — the whole point of the clawback.
var saleStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&saleStatus); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if saleStatus != "failed" {
t.Errorf("expected till sale marked failed despite the partial reversal, got %q", saleStatus)
}
// This request's top-up transaction must be removed — the sale failed and
// its funding is gone from the card.
var txCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, cardID, saleID).Scan(&txCount); err != nil {
t.Fatalf("failed to count gift card transactions: %v", err)
}
if txCount != 0 {
t.Errorf("expected this request's top-up transaction removed, got %d", txCount)
}
}
// TestRevertGiftCardFunding_NotPending_LeavesCardUntouched locks the claim-first
// gate: when the till sale is no longer 'pending' (already completed/failed),
// revertGiftCardFunding returns errTillSaleNotPending and the gift card (and
// its transaction) is left exactly as it was — the funding is no longer ours
// to revert.
func TestRevertGiftCardFunding_NotPending_LeavesCardUntouched(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
var cardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory)
VALUES (50.00, 50.00, $1, FALSE)
RETURNING id
`, adminID).Scan(&cardID); err != nil {
t.Fatalf("failed to seed gift card: %v", err)
}
// A COMPLETED sale — the claim-first gating UPDATE must match zero rows.
var saleID string
if err := tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'completed', $2, NOW(), NOW())
RETURNING id
`, cardID, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed till sale: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'topup', 50.00, 'till_sale', $2)
`, cardID, saleID); err != nil {
t.Fatalf("failed to seed gift card transaction: %v", err)
}
err = revertGiftCardFunding(ctx, "topup", cardID, 50.00, nil, saleID)
if !errors.Is(err, errTillSaleNotPending) {
t.Fatalf("expected errTillSaleNotPending when the sale is not pending, got %v", err)
}
var remaining, totalAdded float64
if err := tx.QueryRow(ctx, `SELECT amount_remaining, total_funds_added FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &totalAdded); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if remaining != 50.00 || totalAdded != 50.00 {
t.Errorf("claim-failed clawback must leave the card untouched, got remaining=%.2f total_funds_added=%.2f", remaining, totalAdded)
}
var txCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1`, cardID).Scan(&txCount); err != nil {
t.Fatalf("failed to count gift card transactions: %v", err)
}
if txCount != 1 {
t.Errorf("claim-failed clawback must leave the transaction untouched, got %d transactions", txCount)
}
}
// TestCreateTillSale_PostCheckoutFailure_CancelsOrphanedCheckout locks the
// HIGH-2 fix: when a card_machine checkout is created at Square inside the tx
// but a later pre-commit failure aborts the request (here: the till_sales
// INSERT collides on the idempotency_key UNIQUE constraint), the orphaned live
// checkout must be cancelled at Square — otherwise it stays live at the
// terminal as an invisible, untracked charge.
func TestCreateTillSale_PostCheckoutFailure_CancelsOrphanedCheckout(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// A till_sales row already holding the request's idempotency key makes the
// handler's till_sales INSERT collide on the UNIQUE constraint AFTER the
// Square CreateCheckout succeeded. Seeding item_id = NULL makes the dedup
// SELECT's NULL-into-string scan FAIL with a non-ErrNoRows error, so the
// dedup is skipped, existingPendingID stays empty, and the create path runs
// its INSERT into the collision.
const key = "till-high2-orphan-key"
if _, err := tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, created_by, idempotency_key, created_at, updated_at)
VALUES ('gift_card', NULL, 'blocker row', 1, 50.00, 50.00, 'online_square', 'pending',
$1, $2, NOW(), NOW())
`, adminID, key); err != nil {
t.Fatalf("failed to seed blocker till sale: %v", err)
}
origClient := SquareClient
client := &fixedCheckoutClient{SquareClient: square.NewDevClient(), checkoutID: "chk_high2_orphaned"}
SquareClient = client
defer func() { SquareClient = origClient }()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "card_machine",
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 from the failed till_sales INSERT, got %d. body: %s", w.Code, w.Body.String())
}
// The orphaned live checkout must have been cancelled at Square.
if calls := client.cancelCalls(); len(calls) != 1 || calls[0] != "chk_high2_orphaned" {
t.Errorf("expected exactly one CancelCheckout for the orphaned checkout %q, got %v", "chk_high2_orphaned", calls)
}
}
// TestGetTillCheckoutStatus_AlreadyFailed_DoesNotResurrect locks the LOW
// resurrection guard: a poll of a checkout whose till sale the sweep already
// failed (e.g. a clawback reverted the gift card) must NOT complete the sale —
// the completion UPDATE's status='pending' guard matches zero rows and the
// poll fails loudly with 404 instead of reporting COMPLETED for a sale whose
// card no longer exists.
func TestGetTillCheckoutStatus_AlreadyFailed_DoesNotResurrect(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
pool := context.Background()
const checkoutID = "chk_till_already_failed"
var saleID string
if err := tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, square_checkout_id, created_by, created_at, updated_at)
VALUES ('gift_card', NULL, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'failed',
$1, $2, NOW(), NOW())
RETURNING id
`, checkoutID, adminID).Scan(&saleID); err != nil {
t.Fatalf("failed to seed failed till sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, adminID)
})
origClient := SquareClient
SquareClient = &completedCheckoutClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{
Status: "COMPLETED",
SquarePayID: "sqp_poll_already_failed",
}}
defer func() { SquareClient = origClient }()
req := httptest.NewRequest("GET", "/api/admin/till/checkout/"+checkoutID+"/status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("checkout_id", checkoutID)
reqCtx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
req = adminRequestCtx(req)
w := httptest.NewRecorder()
GetTillCheckoutStatus(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for a poll of a failed sale, got %d. body: %s", w.Code, w.Body.String())
}
// The sale must STILL be failed — the poll must not have resurrected it.
var status string
if err := db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected failed sale to remain failed after the guarded poll, got %q", status)
}
}
// countingPaymentClient wraps the Square client and records every CreatePayment
// invocation (its idempotency key and the returned payment) so tests can prove
// a retry issued exactly ONE charge (Square dedup returns the original payment)
// and that the charge reused the expected idempotency key.
type countingPaymentClient struct {
square.SquareClient
mu sync.Mutex
keys []string
payments []*square.PaymentResult
}
func (c *countingPaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
res, err := c.SquareClient.CreatePayment(ctx, req)
c.mu.Lock()
defer c.mu.Unlock()
c.keys = append(c.keys, req.IdempotencyKey)
if err == nil {
c.payments = append(c.payments, res)
}
return res, err
}
func (c *countingPaymentClient) callCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.keys)
}
// makeTillSaleRequest POSTs a till sale through the real router with the test
// transaction embedded in the request context.
func makeTillSaleRequest(t *testing.T, req TillSaleRequest, adminToken string, ctx context.Context, tx pgx.Tx) *httptest.ResponseRecorder {
t.Helper()
bodyBytes, _ := json.Marshal(req)
r := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
r.Header.Set("Authorization", "Bearer "+adminToken)
r.Header.Set("Content-Type", "application/json")
r = r.WithContext(db.ContextWithTx(r.Context(), tx))
w := httptest.NewRecorder()
router := chi.NewRouter()
router.Use(mw.RequireAuth)
router.Post("/api/admin/till/sale", CreateTillSale)
router.ServeHTTP(w, r)
return w
}
// TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge simulates
// the P0 double-charge window: a charge lands at Square but the response is
// lost, leaving the till_sale row 'pending'. A same-key retry must re-attempt
// the charge with the SAME idempotency key — Square dedups on the key and
// returns the ORIGINAL payment — so the customer is charged exactly once, the
// same row is reused, and the gift card is funded exactly once.
// This test asserts on a recording client swapped into the package-global
// SquareClient, so it must NOT run in parallel: a concurrent test swapping
// SquareClient to its own client can steal the recorded CreatePayment call
// (rec.keys stays empty → index panic).
func TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
origClient := SquareClient
rec := &countingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
key := "till-lost-response-single-charge"
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "online_square",
CardToken: "cnon:lost-response-card",
IdempotencyKey: key,
}
// First attempt: the charge succeeds at Square and the sale completes.
first := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx))
if first.Code != http.StatusCreated {
t.Fatalf("first attempt: expected 201, got %d. body: %s", first.Code, first.Body.String())
}
var firstResp TillSaleResponse
if err := json.NewDecoder(first.Body).Decode(&firstResp); err != nil {
t.Fatalf("failed to decode first response: %v", err)
}
// Lost-response window: the charge keyed on `key` is live at Square (the
// mock keeps it in paymentByKey) but the response never reached the
// handler, so the sale row is stuck 'pending' with no payment id recorded.
if _, err := tx.Exec(ctx, `UPDATE till_sales SET status = 'pending', square_payment_id = NULL WHERE id = $1`, firstResp.ID); err != nil {
t.Fatalf("failed to simulate the lost response: %v", err)
}
// Retry with the same key: the pending-reuse path re-charges with the SAME
// key, Square dedups to the original payment, and the same row completes.
second := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx))
if second.Code != http.StatusOK && second.Code != http.StatusCreated {
t.Fatalf("retry: expected 200/201, got %d. body: %s", second.Code, second.Body.String())
}
var secondResp TillSaleResponse
if err := json.NewDecoder(second.Body).Decode(&secondResp); err != nil {
t.Fatalf("failed to decode retry response: %v", err)
}
// Exactly ONE till_sales row — the retry reused the pending row.
if secondResp.ID != firstResp.ID {
t.Errorf("expected the SAME till_sale row on retry, got %s then %s", firstResp.ID, secondResp.ID)
}
var saleCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE id = $1`, firstResp.ID).Scan(&saleCount); err != nil {
t.Fatalf("failed to count till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale row (reused), got %d", saleCount)
}
// Square was invoked twice (attempt + retry) but the retry deduped on the
// key: both invocations returned the SAME payment — one charge in total.
if rec.callCount() != 2 {
t.Errorf("expected 2 CreatePayment invocations (attempt + dedup retry), got %d", rec.callCount())
}
rec.mu.Lock()
oneCharge := len(rec.payments) == 2 && rec.payments[0].SquarePayID == rec.payments[1].SquarePayID
rec.mu.Unlock()
if !oneCharge {
t.Errorf("expected the retry to dedup to the original payment (single charge), got %d distinct payments", len(rec.payments))
}
// Exactly one funded gift card for this sale, still at the single value.
var giftCardCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 50.00`).Scan(&giftCardCount); err != nil {
t.Fatalf("failed to count gift cards: %v", err)
}
if giftCardCount != 1 {
t.Errorf("expected 1 funded gift card (single funding), got %d", giftCardCount)
}
// The reused row must be completed with the payment id recorded.
var saleStatus, sqPaymentID string
if err := tx.QueryRow(ctx, `SELECT status, COALESCE(square_payment_id, '') FROM till_sales WHERE id = $1`, firstResp.ID).Scan(&saleStatus, &sqPaymentID); err != nil {
t.Fatalf("failed to read sale status: %v", err)
}
if saleStatus != "completed" {
t.Errorf("expected sale to be completed after the retry, got %s", saleStatus)
}
if sqPaymentID == "" {
t.Error("expected square_payment_id to be recorded after the retry")
}
}
// TestCreateTillSale_PendingRetry_FreshKey_ReusesPendingRowViaGiftCard proves
// the backend defense-in-depth: even when a retry arrives with a FRESH
// idempotency key (an old frontend, or a regenerated key), a same-amount
// top-up on the SAME gift card reuses the pending till_sale row and adopts its
// STORED idempotency key — the Square charge dedups on the stored key instead
// of charging the customer twice.
func TestCreateTillSale_PendingRetry_FreshKey_ReusesPendingRowViaGiftCard(t *testing.T) {
// Not t.Parallel(): asserts on rec.keys of the global SquareClient (see
// TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge note).
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// Seed a funded gift card with a pending top-up till_sale (the first
// attempt's Square charge failed after the DB commit — card already funded).
var giftCardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
storedKey := "till-original-topup-key"
if _, err := tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, NULL, $2, $3, NOW(), NOW())
`, giftCardID, storedKey, adminID); err != nil {
t.Fatalf("failed to seed pending top-up: %v", err)
}
origClient := SquareClient
rec := &countingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
// Retry with a GENUINELY FRESH key: same gift card, same amount, but a key
// the backend has never seen (simulating an old/regenerated key).
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 50.00,
GiftCardID: &giftCardID,
PaymentMethod: "online_square",
CardToken: "cnon:fresh-key-topup-card",
IdempotencyKey: "till-fresh-key-topup",
}
w := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx))
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
}
// The pending row was reused, not duplicated, and keeps its stored key.
var saleCount int
var saleStatus, usedKey string
if err := tx.QueryRow(ctx, `
SELECT COUNT(*), MAX(status), MAX(idempotency_key) FROM till_sales WHERE item_id = $1
`, giftCardID).Scan(&saleCount, &saleStatus, &usedKey); err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if saleCount != 1 {
t.Errorf("expected 1 till_sale row (reused), got %d", saleCount)
}
if saleStatus != "completed" {
t.Errorf("expected the reused row to complete, got %s", saleStatus)
}
if usedKey != storedKey {
t.Errorf("expected the sale row to keep its stored key %q, got %q", storedKey, usedKey)
}
// The Square charge reused the STORED key (the dedup target), not the
// fresh request key — a second charge on the fresh key would double-charge.
rec.mu.Lock()
gotKey := rec.keys[0]
rec.mu.Unlock()
if gotKey != storedKey {
t.Errorf("expected Square charge to use the stored key %q, got %q", storedKey, gotKey)
}
// The gift card was funded exactly once — a second charge would have funded
// it again.
var remaining float64
if err := tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, giftCardID).Scan(&remaining); err != nil {
t.Fatalf("failed to read gift card: %v", err)
}
if remaining != 50.00 {
t.Errorf("expected gift card funded once (£50.00), got £%.2f (double-funding!)", remaining)
}
}
// TestCreateTillSale_PendingRetry_FreshKey_DifferentAmount_NewCharge proves a
// retry with a genuinely different amount on the same gift card is a NEW sale:
// the pending row is left untouched and a new Square charge is issued (the
// amount mismatch is the only signal distinguishing a retry from a fresh
// top-up).
func TestCreateTillSale_PendingRetry_FreshKey_DifferentAmount_NewCharge(t *testing.T) {
// Not t.Parallel(): asserts on rec.keys of the global SquareClient (see
// TestCreateTillSale_PendingRetry_LostResponse_SingleSquareCharge note).
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
var giftCardID string
if err := tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID); err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card topup', 1, 50.00, 50.00, 'online_square', 'pending',
NULL, NULL, 'till-original-topup-key-2', $2, NOW(), NOW())
`, giftCardID, adminID); err != nil {
t.Fatalf("failed to seed pending top-up: %v", err)
}
origClient := SquareClient
rec := &countingPaymentClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
// Different amount on the same card with a fresh key: a genuinely
// different sale — a new charge is allowed.
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 25.00,
GiftCardID: &giftCardID,
PaymentMethod: "online_square",
CardToken: "cnon:new-amount-topup-card",
IdempotencyKey: "till-fresh-key-topup-25",
}
w := makeTillSaleRequest(t, reqBody, adminToken, ctx, tx.(pgx.Tx))
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
t.Fatalf("expected 200/201 for the new sale, got %d. body: %s", w.Code, w.Body.String())
}
// Two till_sales rows now: the untouched pending one and the new completed
// one.
var saleCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE item_id = $1`, giftCardID).Scan(&saleCount); err != nil {
t.Fatalf("failed to count till_sales: %v", err)
}
if saleCount != 2 {
t.Errorf("expected 2 till_sale rows (pending original + new sale), got %d", saleCount)
}
// The new sale charged Square exactly once, with its own fresh key.
if rec.callCount() != 1 {
t.Errorf("expected exactly 1 new Square charge, got %d", rec.callCount())
}
rec.mu.Lock()
gotKey := rec.keys[0]
rec.mu.Unlock()
if gotKey != "till-fresh-key-topup-25" {
t.Errorf("expected the new charge to use the request's fresh key, got %q", gotKey)
}
// The card now holds £50 (original pending funding) + £25 (new top-up).
var totalAdded float64
if err := tx.QueryRow(ctx, `SELECT total_funds_added FROM gift_cards WHERE id = $1`, giftCardID).Scan(&totalAdded); err != nil {
t.Fatalf("failed to read gift card: %v", err)
}
if totalAdded != 75.00 {
t.Errorf("expected gift card total funded to £75.00, got £%.2f", totalAdded)
}
// The original pending row is untouched.
var origStatus string
if err := tx.QueryRow(ctx, `SELECT status FROM till_sales WHERE idempotency_key = 'till-original-topup-key-2'`).Scan(&origStatus); err != nil {
t.Fatalf("failed to read original row: %v", err)
}
if origStatus != "pending" {
t.Errorf("expected the original pending row to be untouched, got %s", origStatus)
}
}
// =============================================================================
// CreateTillSale — the post-charge completion UPDATE is claim-first: a sale
// resolved to 'failed' by the stale-pending sweep / gift-card clawback while
// the Square charge is in flight must NOT be resurrected to 'completed'.
// =============================================================================
// lockedBuffer is a mutex-guarded bytes.Buffer so the standard logger's output
// can be captured and asserted on even when concurrent writers (mock
// auto-complete goroutines) log independently.
type lockedBuffer struct {
buf bytes.Buffer
mu sync.Mutex
}
func (b *lockedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
func (b *lockedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// captureStdLog redirects the standard logger's output to a mutex-guarded
// buffer until the returned restore function is called.
func captureStdLog(t *testing.T) (*lockedBuffer, func()) {
t.Helper()
l := &lockedBuffer{}
orig := log.Writer()
log.SetOutput(l)
return l, func() { log.SetOutput(orig) }
}
// tillSweepResolveClient returns a successful CreatePayment but flips the
// till_sales row to 'failed' first — simulating the stale-pending sweep /
// gift-card clawback resolving the sale mid-flight (status change, row still
// exists) while the Square charge is in flight. The retry's post-charge
// completion UPDATE must then hit 0 rows instead of resurrecting the sale.
type tillSweepResolveClient struct {
square.SquareClient
saleID string
}
func (c *tillSweepResolveClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
if _, err := db.Conn.Exec(context.Background(), `UPDATE till_sales SET status = 'failed', updated_at = NOW() WHERE id = $1`, c.saleID); err != nil {
log.Printf("failed to simulate sweep resolution for till sale %s: %v", c.saleID, err)
}
return &square.PaymentResult{
Status: "COMPLETED",
SquarePayID: "sqp_sweep_resolved_midflight",
Amount: 5000,
Fees: 88,
CardBrand: "VISA",
CardLast4: "4242",
}, nil
}
// tillMidFlightClawbackClient simulates the FULL sweep resolution racing a
// same-key retry: the sale is marked failed AND the funded gift card is
// CLAWED BACK (deleted for a create, exactly like RevertGiftCardFunding) while
// the retry's Square charge is in flight. GetPayment confirms the landed
// charge — the retry's 0-row branch must then re-credit the funding.
type tillMidFlightClawbackClient struct {
square.SquareClient
saleID string
giftCardID string
chargeID string
}
func (c *tillMidFlightClawbackClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
if _, err := db.Conn.Exec(context.Background(), `UPDATE till_sales SET status = 'failed', updated_at = NOW() WHERE id = $1`, c.saleID); err != nil {
log.Printf("failed to simulate sweep resolution for till sale %s: %v", c.saleID, err)
}
if _, err := db.Conn.Exec(context.Background(), `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, c.giftCardID); err != nil {
log.Printf("failed to simulate clawback transaction delete: %v", err)
}
if _, err := db.Conn.Exec(context.Background(), `DELETE FROM gift_cards WHERE id = $1`, c.giftCardID); err != nil {
log.Printf("failed to simulate clawback card delete: %v", err)
}
return &square.PaymentResult{
Status: "COMPLETED",
SquarePayID: c.chargeID,
Amount: 5000,
Fees: 88,
CardBrand: "VISA",
CardLast4: "4242",
}, nil
}
func (c *tillMidFlightClawbackClient) GetPayment(ctx context.Context, paymentID string) (*square.PaymentResult, error) {
return &square.PaymentResult{Status: "COMPLETED", SquarePayID: c.chargeID}, nil
}
// TestCreateTillSale_SweepResolvedMidFlight_NoResurrect locks the claim-first
// guard: a pending till sale retried while the stale-pending sweep / gift-card
// clawback marks the sale 'failed' (the charge was proven never to complete, so
// the funded gift card is clawed back) must NOT be resurrected by the retry's
// post-charge completion UPDATE. The Square charge still succeeds (money moves
// at Square), so the handler detects RowsAffected()==0, logs CRITICAL and fails
// the response — the sale stays 'failed', the row is never flipped back to
// 'completed'.
func TestCreateTillSale_SweepResolvedMidFlight_NoResurrect(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
// Seed a PENDING till_sale (the retry target) with its funded gift card,
// exactly like TestCreateTillSale_PendingRetry_ReattemptsSquare.
key := "till-sweep-resolve-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
RETURNING id
`, giftCardID, userID, cardID, key, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE id = $1`, cardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id IN ($1, $2)`, userID, adminID)
})
// The charge lands at Square while the sweep/clawback flips the sale to
// 'failed' mid-flight.
origClient := SquareClient
SquareClient = &tillSweepResolveClient{SquareClient: square.NewDevClient(), saleID: saleID}
defer func() { SquareClient = origClient }()
logBuf, restore := captureStdLog(t)
defer restore()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected the sweep-resolved retry to fail the response (500), got %d. body: %s", w.Code, w.Body.String())
}
// The sale must NOT be resurrected to 'completed' by the post-charge
// UPDATE — the sweep already resolved it to 'failed'.
var status string
var sqPayID *string
if err := db.Conn.QueryRow(pool, `SELECT status, square_payment_id FROM till_sales WHERE id = $1`, saleID).Scan(&status, &sqPayID); err != nil {
t.Fatalf("failed to query till sale: %v", err)
}
if status != "failed" {
t.Errorf("expected the sweep-resolved sale to stay 'failed' (not resurrected), got %q", status)
}
if sqPayID != nil {
t.Errorf("expected no square_payment_id written on the sweep-resolved sale, got %q", *sqPayID)
}
// The RowsAffected()==0 branch must have fired its CRITICAL reconciliation
// log (money taken at Square + card funded + row already resolved).
if got := logBuf.String(); !strings.Contains(got, "already resolved (0 rows updated)") {
t.Errorf("expected the CRITICAL 0-rows reconciliation log, got: %s", got)
}
}
// TestCreateTillSale_SavedCard_UserMismatch_Rejected locks the money-F1 fix:
// when the admin names the customer being served (user_id), the saved card MUST
// be owned by that customer. A card owned by a different user is rejected with
// 400 before any Square charge — the ownership invariant every other charge
// surface enforces, now enforced unconditionally at the till.
func TestCreateTillSale_SavedCard_UserMismatch_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// The customer the admin claims to be serving.
claimedUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create claimed user: %v", err)
}
// The customer who actually owns the card.
ownerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create card owner user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(tx, ownerID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &claimedUserID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 (ownership mismatch), got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "does not belong to the specified user") {
t.Errorf("expected ownership-mismatch error body, got: %s", w.Body.String())
}
// The rejected request must not have created a sale or funded a gift card
// (the transaction is rolled back before commit).
var saleCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE user_saved_card_id = $1`, cardID).Scan(&saleCount); err != nil {
t.Fatalf("failed to query till_sales: %v", err)
}
if saleCount != 0 {
t.Errorf("expected no till_sale for a rejected ownership mismatch, got %d", saleCount)
}
var gcCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE created_by = $1`, adminID).Scan(&gcCount); err != nil {
t.Fatalf("failed to query gift_cards: %v", err)
}
if gcCount != 0 {
t.Errorf("expected no gift card funded by a rejected ownership mismatch, got %d", gcCount)
}
}
// TestCreateTillSale_SavedCard_OmittedUser_ChargesResolvedOwner locks the
// money-F1 omitted-user path: when the admin does not name a customer, the till
// still resolves the card's owner from the row and charges under it (the
// admin-till legit use — the admin may charge any customer's card). Because the
// card was never associated with a user named by the request, the charge
// surfaces a CRITICAL 'critical_payment_log' operator notification for the
// RESOLVED owner — the confused-deputy catch.
func TestCreateTillSale_SavedCard_OmittedUser_ChargesResolvedOwner(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
ownerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create card owner user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(tx, ownerID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
// UserID intentionally omitted — the admin-till legit use.
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 (resolved-owner charge), got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status 'completed', got '%s'", resp.Status)
}
// No user was named, so the sale row stores no user attribution.
var saleUserID *string
if err := tx.QueryRow(ctx, `SELECT user_id FROM till_sales WHERE id = $1`, resp.ID).Scan(&saleUserID); err != nil {
t.Fatalf("failed to query till sale user: %v", err)
}
if saleUserID != nil {
t.Errorf("expected the till sale to have no user attribution (user_id omitted), got %q", *saleUserID)
}
// The unassociated (omitted-user) charge must have raised the CRITICAL
// operator notification for the RESOLVED owner.
var notifCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1 AND booking_id IS NULL`, ownerID).Scan(&notifCount); err != nil {
t.Fatalf("failed to count admin notifications: %v", err)
}
if notifCount < 1 {
t.Errorf("expected a CRITICAL admin notification for the resolved owner, got %d", notifCount)
}
}
// TestCreateTillSale_SavedCard_ProvidedUserMatch_NoOwnerAudit locks the
// money-F1 provided-user match path: when the admin names the customer and the
// card IS owned by them, the charge proceeds normally and no confused-deputy
// notification is raised (the card was associated with the sale's stated user).
func TestCreateTillSale_SavedCard_ProvidedUserMatch_NoOwnerAudit(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
ownerID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create card owner user: %v", err)
}
cardID, err := fixtures.CreateTestPaymentMethod(tx, ownerID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &ownerID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 (matching-owner charge), got %d. body: %s", w.Code, w.Body.String())
}
var resp TillSaleResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != "completed" {
t.Errorf("expected status 'completed', got '%s'", resp.Status)
}
var notifCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND user_id = $1`, ownerID).Scan(&notifCount); err != nil {
t.Fatalf("failed to count admin notifications: %v", err)
}
if notifCount != 0 {
t.Errorf("expected no confused-deputy notification when the card owner matches the stated user, got %d", notifCount)
}
}
// TestCreateTillSale_TopupExpiredCard_Rejected locks the money-F4 fix: the till
// top-up must reject an EXPIRED gift card with 400 and must NOT reset its
// expiry_date — the top-up UPDATE rolls expiry forward to NOW()+months and
// would otherwise resurrect a card the nightly cleanup job already forfeited.
func TestCreateTillSale_TopupExpiredCard_Rejected(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
// An expired card that still carries balance (the nightly cleanup has not
// run yet).
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date)
VALUES (50.00, 50.00, $1, FALSE, NOW() - INTERVAL '1 day')
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert expired gift card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 25.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 (expired gift card), got %d. body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Gift card has expired") {
t.Errorf("expected expired-card error body, got: %s", w.Body.String())
}
// The card must be untouched: no funds added, no transaction, no sale, and
// the expiry_date must NOT be reset to the future.
var totalAdded, remaining float64
var expiry time.Time
if err := tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&totalAdded, &remaining, &expiry); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if totalAdded != 50.00 || remaining != 50.00 {
t.Errorf("expected funds untouched (50.00/50.00), got %.2f/%.2f", totalAdded, remaining)
}
if !expiry.Before(clock.Now()) {
t.Errorf("expected the expired card's expiry_date to NOT be reset (still in the past), got %v", expiry)
}
var txCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1`, cardID).Scan(&txCount); err != nil {
t.Fatalf("failed to count gift_card_transactions: %v", err)
}
if txCount != 0 {
t.Errorf("expected no gift_card_transaction on the rejected top-up, got %d", txCount)
}
var saleCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE item_id = $1`, cardID).Scan(&saleCount); err != nil {
t.Fatalf("failed to count till_sales: %v", err)
}
if saleCount != 0 {
t.Errorf("expected no till_sale on the rejected top-up, got %d", saleCount)
}
}
// TestCreateTillSale_TopupUnexpiredCard_Succeeds is the positive control for
// the money-F4 expiry gate: a LIVE card can still be topped up and its expiry
// is rolled forward to NOW()+expiryMonths as designed.
func TestCreateTillSale_TopupUnexpiredCard_Succeeds(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
var cardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, expiry_date)
VALUES (50.00, 50.00, $1, FALSE, NOW() + INTERVAL '1 year')
RETURNING id
`, adminID).Scan(&cardID)
if err != nil {
t.Fatalf("failed to insert gift card: %v", err)
}
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "topup",
Amount: 25.00,
PaymentMethod: "on_the_house",
GiftCardID: &cardID,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for a live-card top-up, got %d. body: %s", w.Code, w.Body.String())
}
var totalAdded, remaining float64
var expiry time.Time
if err := tx.QueryRow(ctx, `SELECT total_funds_added, amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&totalAdded, &remaining, &expiry); err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if totalAdded != 75.00 || remaining != 75.00 {
t.Errorf("expected funds topped up to 75.00, got %.2f/%.2f", totalAdded, remaining)
}
if !expiry.After(clock.Now()) {
t.Errorf("expected the live card's expiry to be rolled forward, got %v", expiry)
}
}
// =============================================================================
// F5 — £5,000/day admin gift-card cap is race-free across concurrent till sales
// =============================================================================
// TestCreateTillSale_DailyCap_Concurrent pins the F5 daily-cap serialization
// fix: N concurrent till gift-card sales by the same admin must never let the
// cumulative issued value exceed the £5,000/day cap. The till's cap check
// (adminGiftCardValueToday) and the sale's gift-card creation run under the
// SAME per-admin advisory lock the admin API surfaces use
// (acquireGiftCardDailyCapLock), so every check sees the previous sale's
// committed row — the excess requests are rejected. Without that per-admin lock
// two DISTINCT concurrent sales by the same admin both read the same
// pre-write cumulative value and both pass, over-issuing value (the per-sale
// crussell:till:<idempotencyKey> lock serializes ONE sale's retries, not
// distinct concurrent sales).
//
// The per-transaction £250 cap bounds each till sale, so exceeding the £5,000
// daily cap needs 21 sales of £250; a semaphore bounds how many run
// simultaneously (each handler holds one pool conn per advisory lock and one
// for its transaction). The admin and every handler invocation run directly
// against the REAL pool (no per-test transaction), so the batches exercise
// genuine cross-connection concurrency exactly like production.
func TestCreateTillSale_DailyCap_Concurrent(t *testing.T) {
ctx := context.Background()
adminID, err := fixtures.CreateTestAdminUser(db.Conn)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
t.Cleanup(func() {
cctx := context.Background()
_, _ = db.Conn.Exec(cctx, `DELETE FROM admin_audit_log WHERE admin_id = $1`, adminID)
_, _ = db.Conn.Exec(cctx, `DELETE FROM gift_card_transactions WHERE gift_card_id IN (SELECT id FROM gift_cards WHERE created_by = $1)`, adminID)
_, _ = db.Conn.Exec(cctx, `DELETE FROM till_sales WHERE created_by = $1`, adminID)
_, _ = db.Conn.Exec(cctx, `DELETE FROM gift_cards WHERE created_by = $1`, adminID)
_, _ = db.Conn.Exec(cctx, `DELETE FROM users WHERE id = $1`, adminID)
})
token := jwt.GenerateTestToken(adminID, "admin")
const perSale = 250.00 // £250 per sale (at the £250 per-transaction cap)
const totalOps = 21 // 21 × £250 = £5,250 > the £5,000 daily cap
const concurrencyLimit = 4 // at most 4 handlers in flight (each holds 3 pool conns)
sem := make(chan struct{}, concurrencyLimit)
start := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
successes := 0
failCodes := map[int]int{}
for i := 0; i < totalOps; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
sem <- struct{}{}
defer func() { <-sem }()
body, _ := json.Marshal(TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: perSale,
PaymentMethod: "cash",
})
r := httptest.NewRequest(http.MethodPost, "/api/admin/till/sale", bytes.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router := chi.NewRouter()
router.Use(mw.RequireAuth)
router.Post("/api/admin/till/sale", CreateTillSale)
router.ServeHTTP(w, r)
mu.Lock()
if w.Code == http.StatusCreated {
successes++
} else {
failCodes[w.Code]++
}
mu.Unlock()
}()
}
close(start)
wg.Wait()
// Money-safety invariant under serialization: at most 20 of the 21 sales
// may succeed (20 × £250 = £5,000 = the inclusive cap; the 21st would land
// the day on £5,250 and must be rejected). A rejected attempt surfaces as
// either the 400 cap rejection or a 409 from the bounded try-lock giving up
// under heavy contention — both are the designed backpressure and neither
// records value. Without the per-admin cap lock each batch reads the
// pre-write cumulative value so all 21 succeed, overshooting the cap —
// `successes > 20` (or a cumulative over the cap below) is the regression
// signal this test must catch.
if successes < 1 || successes > 20 {
t.Errorf("expected between 1 and 20 of 21 concurrent till sales to succeed under the £5,000 cap, got %d (cumulative value £%.2f); failure codes: %v", successes, perSale*float64(successes), failCodes)
}
// The day's issued value (the cap signal) must never exceed the cap.
issuedToday, err := adminGiftCardValueToday(ctx, db.Conn, adminID)
if err != nil {
t.Fatalf("failed to query today's issued value: %v", err)
}
if int64(math.Round(issuedToday*100)) > maxAdminGiftCardDailyPence {
t.Errorf("cumulative daily issued value £%.2f exceeds the £5,000 cap", issuedToday)
}
// The created cards must hold exactly the value of the successful sales.
var totalCreated float64
if err := db.Conn.QueryRow(ctx, `SELECT COALESCE(SUM(total_funds_added), 0) FROM gift_cards WHERE created_by = $1`, adminID).Scan(&totalCreated); err != nil {
t.Fatalf("failed to query created gift-card value: %v", err)
}
if totalCreated != perSale*float64(successes) {
t.Errorf("expected issued gift-card value £%.2f, got £%.2f", perSale*float64(successes), totalCreated)
}
}
// TestCreateTillSale_SweepClawedBackMidFlight_RecreditsFunding locks the FIX 1
// till side: when the stale-pending sweep (or webhook clawback) marked a
// pending till sale failed AND clawed back its funded gift card while a
// same-key retry's Square charge was mid-flight, the retry's post-charge
// completion UPDATE hits 0 rows — but the charge DID land, so the handler must
// re-credit the clawed-back funding before failing. The customer ends "charged
// AND funded" instead of "charged AND clawed back"; the sale row stays failed
// for manual reconciliation.
func TestCreateTillSale_SweepClawedBackMidFlight_RecreditsFunding(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_test_card_id", "VISA", "1234")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
key := "till-midflight-clawback-key"
var giftCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
RETURNING id
`, adminID).Scan(&giftCardID)
if err != nil {
t.Fatalf("failed to create gift card: %v", err)
}
var saleID string
err = tx.QueryRow(ctx, `
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
payment_method, status, user_id, user_saved_card_id, idempotency_key, created_by, created_at, updated_at)
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
$2, $3, $4, $5, NOW(), NOW())
RETURNING id
`, giftCardID, userID, cardID, key, adminID).Scan(&saleID)
if err != nil {
t.Fatalf("failed to seed pending till sale: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
pool := context.Background()
t.Cleanup(func() {
var recreditedIDs []string
rows, qErr := db.Conn.Query(pool, `SELECT id FROM gift_cards WHERE created_by = $1 AND id <> $2`, adminID, giftCardID)
if qErr == nil {
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
recreditedIDs = append(recreditedIDs, id)
}
}
rows.Close()
}
for _, id := range recreditedIDs {
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, id)
}
_, _ = db.Conn.Exec(pool, `DELETE FROM till_sales WHERE id = $1`, saleID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, giftCardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, giftCardID)
for _, id := range recreditedIDs {
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, id)
}
_, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE id = $1`, cardID)
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id IN ($1, $2)`, userID, adminID)
})
const chargeID = "sqp_midflight_clawback_charge"
origClient := SquareClient
SquareClient = &tillMidFlightClawbackClient{SquareClient: square.NewDevClient(), saleID: saleID, giftCardID: giftCardID, chargeID: chargeID}
defer func() { SquareClient = origClient }()
logBuf, restore := captureStdLog(t)
defer restore()
reqBody := TillSaleRequest{
ItemType: "gift_card",
Action: "create",
Amount: 50.00,
PaymentMethod: "saved_card",
UserSavedCardID: &cardID,
UserID: &userID,
IdempotencyKey: key,
}
bodyBytes, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r := chi.NewRouter()
r.Use(mw.RequireAuth)
r.Post("/api/admin/till/sale", CreateTillSale)
r.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected the clawed-back mid-flight retry to fail the response (500), got %d. body: %s", w.Code, w.Body.String())
}
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 the sweep-resolved sale to stay 'failed' (not resurrected), got %q", status)
}
// FIX 1: the funded gift card the clawback deleted must have been RE-CREATED
// with the sale's value, and the sale re-pointed at it.
var recreditedID string
var recreditedTotal, recreditedRemaining float64
if err := db.Conn.QueryRow(pool, `
SELECT id, total_funds_added, amount_remaining FROM gift_cards
WHERE created_by = $1 AND id <> $2
`, adminID, giftCardID).Scan(&recreditedID, &recreditedTotal, &recreditedRemaining); err != nil {
t.Fatalf("expected a re-credited gift card for the mid-flight clawback, none found: %v", err)
}
if recreditedTotal != 50.00 || recreditedRemaining != 50.00 {
t.Errorf("expected the re-credited gift card funded at £50.00, got total £%.2f remaining £%.2f", recreditedTotal, recreditedRemaining)
}
var itemID string
if err := db.Conn.QueryRow(pool, `SELECT COALESCE(item_id, '') FROM till_sales WHERE id = $1`, saleID).Scan(&itemID); err != nil {
t.Fatalf("failed to query till sale item_id: %v", err)
}
if itemID != recreditedID {
t.Errorf("expected till sale %s re-pointed at the re-credited card %s, got item_id %s", saleID, recreditedID, itemID)
}
var txCount int
if err := db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND reference_type = 'till_sale' AND reference_id = $2`, recreditedID, saleID).Scan(&txCount); err != nil {
t.Fatalf("failed to count re-credited purchase transaction: %v", err)
}
if txCount != 1 {
t.Errorf("expected exactly 1 purchase transaction on the re-credited card, got %d", txCount)
}
if got := logBuf.String(); !strings.Contains(got, "already resolved (0 rows updated)") {
t.Errorf("expected the CRITICAL 0-rows reconciliation log, got: %s", got)
}
if got := logBuf.String(); !strings.Contains(got, "gift-card funding re-credited") {
t.Errorf("expected the funding-re-credited log, got: %s", got)
}
}