Fix payment review round: till integrity, HTTP client tests, concurrency tests, card-selection consolidation
Addresses the payment review (all 10 blocking + 2 minor findings): Till money-integrity (CreateTillSale): - Add pg_advisory_lock on the idempotency key (concurrent same-key double-funding race) - Guard amount on pending-reuse retry (mirrors tip/gift-card guards) - Explicitly complete the row for cash/on_the_house pending-reuse - Reject method-switch on a live card-machine checkout (double-charge guard) - 3 regression tests (amount-mismatch, cash-completes-row, method-switch) BookingFlow: - Fetch saved cards at the deposit step (was dead code) - Charge the server-computed deposit_amount, not the client estimate HTTP client tests (was untested): doJSON error parsing, refund sentinel classification, payment/refund/card wire shapes, checkout polling states, list-refunds pagination + 20-page guard, sha256 card idempotency key Concurrency regression tests: real two-goroutine races for BuyGiftCard, tip, and booking-payment locks asserting exactly-one record each Frontend: - Fix CRIT-1: zero-saved-card users blocked (all flows now handle it) - Consolidate tip/deposit/Buy-Gift-Card card UI onto CardSelection - Explicit save-card consent checkbox (was silent/inconsistent) - Fix stale saved-card field names in BookingFlow (last4 -> last_4) - Unique instance ids (crypto.randomUUID) in CardSelection/SquareCardInput - UserPaymentModal: keep card form mounted on error + Try Again button Health/docs: /api/health reports square state (mock/ok, was not_implemented), close P1 backlog, correct stale webhook and env-var claims
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
//go:build test && dev
|
||||
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
)
|
||||
|
||||
// slowCreatePaymentClient delays the Square charge so each handler holds its
|
||||
// advisory lock long enough that a concurrent same-key request would race it
|
||||
// without the lock (two goroutines both reading "no record", both charging).
|
||||
type slowCreatePaymentClient struct {
|
||||
square.SquareClient
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (c *slowCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
|
||||
time.Sleep(c.delay)
|
||||
return c.SquareClient.CreatePayment(ctx, req)
|
||||
}
|
||||
|
||||
// cleanupConcurrentTestRows deletes the rows a concurrency test committed at
|
||||
// pool level. These tests must COMMIT their setup so the advisory locks work
|
||||
// across independent connections, which leaves committed rows in the shared
|
||||
// test DB — without cleanup they leak into parallel tests (e.g. GetGiftCards
|
||||
// counts gift_cards/user_giftcard_balances globally). FK-safe deletion order.
|
||||
func cleanupConcurrentTestRows(t *testing.T, pool context.Context, userID, bookingID string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
var gcIDs []string
|
||||
rows, err := db.Conn.Query(pool, `SELECT id FROM gift_cards WHERE created_by = $1`, userID)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
gcIDs = append(gcIDs, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
for _, gcID := range gcIDs {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, gcID)
|
||||
}
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM user_giftcard_balances WHERE user_id = $1`, userID)
|
||||
if bookingID != "" {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM booking_services WHERE booking_id = $1`, bookingID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID)
|
||||
}
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE created_by = $1 OR idempotency_key LIKE 'concurrent-%'`, userID)
|
||||
for _, gcID := range gcIDs {
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, gcID)
|
||||
}
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID)
|
||||
_, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBuyGiftCard_ConcurrentSameKey_SingleRecord proves the BuyGiftCard
|
||||
// advisory lock (giftcards.go): two goroutines POSTing the same idempotency key
|
||||
// must produce exactly ONE payment record and ONE funded gift card — never
|
||||
// 2× value for 1 charge. Without the lock, both goroutines pass the
|
||||
// idempotency check, both reuse/insert pending records, and both fund the card.
|
||||
func TestBuyGiftCard_ConcurrentSameKey_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, "")
|
||||
|
||||
// Commit the setup so both goroutines operate at pool level — the advisory
|
||||
// locks only serialize across independent connections, and a per-test tx
|
||||
// would route both sides through a single shared connection.
|
||||
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)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
key := "buy-gc-concurrent-same-key"
|
||||
reqBody := map[string]interface{}{
|
||||
"amount": 2000,
|
||||
"recipient_type": "self",
|
||||
"new_card_token": "cnon:concurrent-card",
|
||||
"idempotency_key": key,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(BuyGiftCard, "POST", "/api/user/giftcards/buy", reqBody, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
// Both requests must succeed — the lock serializes them and the second
|
||||
// finds the completed record (idempotent dedup, HTTP 200), so neither
|
||||
// double-charges nor errors.
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 201 (create) or 200 (dedup), got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly one payment record for this key.
|
||||
var payCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE idempotency_key = $1`, key).Scan(&payCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount)
|
||||
}
|
||||
|
||||
// Exactly one funded gift card for this user's self-purchase.
|
||||
var gcCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM gift_cards WHERE total_funds_added = 20.00 AND created_by = $1`, userID).Scan(&gcCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count gift cards: %v", err)
|
||||
}
|
||||
if gcCount != 1 {
|
||||
t.Errorf("expected exactly 1 funded gift card, got %d (2× value!)", gcCount)
|
||||
}
|
||||
|
||||
// User balance credited exactly once.
|
||||
var balance float64
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query balance: %v", err)
|
||||
}
|
||||
if balance != 20.00 {
|
||||
t.Errorf("expected balance 20.00, got %.2f (double-credit!)", balance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTipPayment_ConcurrentSameKey_SingleRecord proves the tip advisory lock
|
||||
// (handlers.go): two goroutines POSTing the same booking with the same key must
|
||||
// produce exactly ONE tip payment record — never two charges for one booking.
|
||||
func TestTipPayment_ConcurrentSameKey_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
serviceID, err := fixtures.CreateTestService(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
start := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
|
||||
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, start)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
if _, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "online_square", "full", "completed"); err != nil {
|
||||
t.Fatalf("failed to create prior payment: %v", err)
|
||||
}
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
key := "tip-concurrent-same-key"
|
||||
cardToken := "cnon:concurrent-tip-card"
|
||||
reqBody := CreateTipPaymentRequest{
|
||||
Amount: 500,
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(CreateTipPayment, "POST", "/api/bookings/"+bookingID+"/tip", reqBody, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
var tipCount int
|
||||
err = db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_type = 'tip' AND idempotency_key = $2`, bookingID, key).Scan(&tipCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count tip payments: %v", err)
|
||||
}
|
||||
if tipCount != 1 {
|
||||
t.Errorf("expected exactly 1 tip payment record, got %d (double-charge!)", tipCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookingPayment_ConcurrentSameKey_SingleRecord proves the booking-payment
|
||||
// advisory lock (handlers.go): two goroutines paying the same booking with the
|
||||
// same key must produce exactly ONE payment record.
|
||||
func TestBookingPayment_ConcurrentSameKey_SingleRecord(t *testing.T) {
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
|
||||
token := jwt.GenerateUserToken(userID)
|
||||
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
origClient := SquareClient
|
||||
slow := &slowCreatePaymentClient{SquareClient: square.NewDevClient(), delay: 300 * time.Millisecond}
|
||||
SquareClient = slow
|
||||
defer func() { SquareClient = origClient }()
|
||||
|
||||
pool := context.Background()
|
||||
key := "booking-pay-concurrent-same-key"
|
||||
cardToken := "cnon:concurrent-booking-card"
|
||||
reqBody := CreateBookingPaymentRequest{
|
||||
Amount: 5000,
|
||||
PaymentType: "full",
|
||||
NewCardToken: &cardToken,
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
startBoth := make(chan struct{})
|
||||
recs := make([]*httptest.ResponseRecorder, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-startBoth
|
||||
recs[idx] = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", reqBody, token, pool)
|
||||
}(i)
|
||||
}
|
||||
close(startBoth)
|
||||
wg.Wait()
|
||||
|
||||
for i, rec := range recs {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d expected 200, got %d. body: %s", i, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
var payCount int
|
||||
err := db.Conn.QueryRow(pool,
|
||||
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&payCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count payments: %v", err)
|
||||
}
|
||||
if payCount != 1 {
|
||||
t.Errorf("expected exactly 1 payment record, got %d (double-charge!)", payCount)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -109,7 +110,39 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Idempotency check: if key provided, return existing sale if found
|
||||
// Serialize till-sale attempts on the idempotency key to prevent concurrent
|
||||
// same-key requests from both passing the idempotency check, both funding
|
||||
// the gift card, and one dying on the till_sales idempotency_key UNIQUE
|
||||
// constraint after the funding already committed. Mirrors the gift-card
|
||||
// advisory-lock pattern (giftcards.go). Lock is keyed on the idempotency
|
||||
// key so distinct sales are unaffected; falls back to a per-request key
|
||||
// when absent (client-supplied key is always used in practice).
|
||||
lockKey := req.IdempotencyKey
|
||||
if lockKey == "" {
|
||||
lockKey = "till-" + rand.Text()
|
||||
}
|
||||
pinConn, err := db.Conn.Acquire(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to acquire connection for till-sale lock: %v", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer pinConn.Release()
|
||||
if _, err := pinConn.Exec(ctx, `
|
||||
SELECT pg_advisory_lock(hashtext('crussell:till:' || $1))
|
||||
`, lockKey); err != nil {
|
||||
log.Printf("Failed to acquire till-sale serialization lock for %s: %v", lockKey, err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if _, err := pinConn.Exec(context.Background(), `
|
||||
SELECT pg_advisory_unlock(hashtext('crussell:till:' || $1))
|
||||
`, lockKey); err != nil {
|
||||
log.Printf("Failed to release till-sale serialization lock for %s: %v", lockKey, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Idempotency handling. A 'completed' sale is a dedup (return it). A
|
||||
// 'pending' sale means the previous Square charge failed — the gift card
|
||||
// was already funded in the committed transaction, so re-attempt the
|
||||
@@ -119,7 +152,8 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
var existingPendingGiftCard string
|
||||
if req.IdempotencyKey != "" {
|
||||
var existingID, existingStatus, existingItemID string
|
||||
err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID)
|
||||
var existingTotal float64
|
||||
err := db.Conn.QueryRow(ctx, `SELECT id, status, item_id, total_amount FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID, &existingStatus, &existingItemID, &existingTotal)
|
||||
if err == nil {
|
||||
if existingStatus == "completed" {
|
||||
if err := json.NewEncoder(w).Encode(TillSaleResponse{
|
||||
@@ -134,6 +168,15 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if existingStatus == "pending" {
|
||||
// Guard the amount: a retry with a different amount must not
|
||||
// reuse the pending sale — the gift card was already funded at
|
||||
// the old amount, so charging the new amount to Square would
|
||||
// leave the card funded at the wrong value.
|
||||
if int64(math.Round(existingTotal*100)) != int64(math.Round(req.Amount*100)) {
|
||||
log.Printf("Till-sale retry amount mismatch: pending record %s has %.2f, request has %.2f", existingID, existingTotal, req.Amount)
|
||||
http.Error(w, "Amount does not match the pending till sale", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
existingPendingID = existingID
|
||||
existingPendingGiftCard = existingItemID
|
||||
}
|
||||
@@ -314,6 +357,19 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Method-switch guard on pending-retry: if the original attempt was
|
||||
// card_machine and created a live terminal checkout, the retry MUST stay
|
||||
// card_machine and reuse that checkout. Switching to cash/on_the_house/
|
||||
// saved_card/online_square would report the sale completed while the
|
||||
// original checkout is still live — the customer could be charged at the
|
||||
// terminal AND by the new method (double charge). The terminal checkout
|
||||
// cannot be cancelled via this API, so reject the switch outright.
|
||||
if existingPendingID != "" && existingPendingCheckoutID != "" && req.PaymentMethod != "card_machine" {
|
||||
log.Printf("Till-sale retry rejected: pending sale %s has a live card-machine checkout, cannot switch method from card_machine to %s", existingPendingID, req.PaymentMethod)
|
||||
http.Error(w, "This pending sale is tied to a live card-machine checkout — retry with card machine payment", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.PaymentMethod {
|
||||
case "cash":
|
||||
saleStatus = "completed"
|
||||
@@ -540,6 +596,25 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
saleStatus = "completed"
|
||||
}
|
||||
|
||||
// Pending-reuse resolved by a non-Square method (cash / on_the_house): the
|
||||
// original sale row was inserted as 'pending' (prior Square attempt failed
|
||||
// or the method was switched after a failed charge). The reused row skips
|
||||
// the INSERT, and no Square payment runs, so the status must be flipped to
|
||||
// 'completed' explicitly — otherwise the row stays pending forever while
|
||||
// the response claims success.
|
||||
if existingPendingID != "" && !needsSquarePayment && req.PaymentMethod != "card_machine" {
|
||||
_, upErr := db.Conn.Exec(ctx,
|
||||
`UPDATE till_sales SET status = 'completed', updated_at = NOW() WHERE id = $1 AND status = 'pending'`,
|
||||
tillSaleID,
|
||||
)
|
||||
if upErr != nil {
|
||||
log.Printf("Failed to complete pending till sale %s after non-Square payment: %v", tillSaleID, upErr)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
saleStatus = "completed"
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(TillSaleResponse{
|
||||
ID: tillSaleID,
|
||||
|
||||
@@ -1332,3 +1332,230 @@ func TestCreateTillSale_PendingRetry_CardMachine_ReusesCheckout(t *testing.T) {
|
||||
t.Errorf("expected till_sales.square_checkout_id to remain %q, got %q", storedCheckoutID, rowCheckoutID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_PendingRetry_AmountMismatch_Rejected verifies that a
|
||||
// same-key retry with a different amount is rejected (400) instead of reusing
|
||||
// the pending sale — the gift card was already funded at the old amount, so
|
||||
// charging a new amount would leave the card funded at the wrong value.
|
||||
// Mirrors the tip/gift-card retry amount guard.
|
||||
func TestCreateTillSale_PendingRetry_AmountMismatch_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// Seed a PENDING till_sale funded at £50.00.
|
||||
key := "till-pending-amount-mismatch-key"
|
||||
var giftCardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
||||
RETURNING id
|
||||
`, adminID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, user_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
||||
NULL, $2, $3, NOW(), NOW())
|
||||
`, giftCardID, key, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending till sale: %v", err)
|
||||
}
|
||||
|
||||
// Retry with the same key but a different amount (£60 instead of £50).
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 60.00,
|
||||
PaymentMethod: "saved_card",
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 (amount mismatch), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The pending sale must be untouched.
|
||||
var saleStatus string
|
||||
var saleAmount float64
|
||||
var saleCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status), COALESCE(MAX(total_amount), 0) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus, &saleAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query till sale: %v", err)
|
||||
}
|
||||
if saleCount != 1 {
|
||||
t.Errorf("expected 1 till sale, got %d", saleCount)
|
||||
}
|
||||
if saleStatus != "pending" {
|
||||
t.Errorf("expected pending sale to remain pending after rejected retry, got %s", saleStatus)
|
||||
}
|
||||
if saleAmount != 50.00 {
|
||||
t.Errorf("expected sale amount to remain 50.00, got %.2f", saleAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_PendingRetry_Cash_CompletesRow verifies that a same-key
|
||||
// retry resolved by cash on a pending sale explicitly flips the row to
|
||||
// 'completed' — previously the response claimed success while the DB row stayed
|
||||
// pending forever (unreconciled).
|
||||
func TestCreateTillSale_PendingRetry_Cash_CompletesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// Seed a PENDING till_sale (prior online_square attempt failed post-commit).
|
||||
key := "till-pending-cash-retry-key"
|
||||
var giftCardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
||||
RETURNING id
|
||||
`, adminID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, user_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'online_square', 'pending',
|
||||
NULL, $2, $3, NOW(), NOW())
|
||||
`, giftCardID, key, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending till sale: %v", err)
|
||||
}
|
||||
|
||||
// Retry with the same key, same amount, cash payment.
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "cash",
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 200/201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The reused row must now be 'completed' — the cash retry explicitly flips it.
|
||||
var saleStatus string
|
||||
var saleCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query till sale: %v", err)
|
||||
}
|
||||
if saleCount != 1 {
|
||||
t.Errorf("expected 1 till sale (reuse, not duplicate), got %d", saleCount)
|
||||
}
|
||||
if saleStatus != "completed" {
|
||||
t.Errorf("expected pending sale to be completed by cash retry, got %s", saleStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected verifies that a
|
||||
// pending card_machine sale with a live checkout cannot be retried via a
|
||||
// different method — the original terminal checkout is still live and could
|
||||
// complete, causing a double charge.
|
||||
func TestCreateTillSale_PendingRetry_CardMachineSwitch_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
adminID, err := fixtures.CreateTestAdminUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin user: %v", err)
|
||||
}
|
||||
adminToken := jwt.GenerateTestToken(adminID, "admin")
|
||||
|
||||
// Seed a PENDING card_machine sale with a stored live checkout.
|
||||
key := "till-pending-method-switch-key"
|
||||
var giftCardID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase)
|
||||
VALUES (50.00, 50.00, $1, FALSE, 'SPV')
|
||||
RETURNING id
|
||||
`, adminID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create gift card: %v", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, user_id, square_checkout_id, idempotency_key, created_by, created_at, updated_at)
|
||||
VALUES ('gift_card', $1, 'Gift Card create', 1, 50.00, 50.00, 'in_person_card', 'pending',
|
||||
NULL, 'chk_live_switch', $2, $3, NOW(), NOW())
|
||||
`, giftCardID, key, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed pending card-machine till sale: %v", err)
|
||||
}
|
||||
|
||||
// Retry with the same key, same amount, but cash — must be rejected (409).
|
||||
reqBody := TillSaleRequest{
|
||||
ItemType: "gift_card",
|
||||
Action: "create",
|
||||
Amount: 50.00,
|
||||
PaymentMethod: "cash",
|
||||
IdempotencyKey: key,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/api/admin/till/sale", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx)))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := chi.NewRouter()
|
||||
r.Use(mw.RequireAuth)
|
||||
r.Post("/api/admin/till/sale", CreateTillSale)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409 (method switch on live checkout), got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// The pending sale must be untouched.
|
||||
var saleStatus string
|
||||
var saleCount int
|
||||
err = tx.QueryRow(ctx, `SELECT COUNT(*), MAX(status) FROM till_sales WHERE idempotency_key = $1`, key).Scan(&saleCount, &saleStatus)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query till sale: %v", err)
|
||||
}
|
||||
if saleCount != 1 {
|
||||
t.Errorf("expected 1 till sale, got %d", saleCount)
|
||||
}
|
||||
if saleStatus != "pending" {
|
||||
t.Errorf("expected pending sale to remain pending after rejected switch, got %s", saleStatus)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user