Files
Crussell/backend/handlers/payments/payments_round10_test.go
T
popertots 78e6d00dc5 fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking
Money-safety:
- Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation
- Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged
- CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard)
- Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse
- Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID

GDPR / security:
- Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010
- square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2
- Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel
- Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit

Frontend:
- Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen)
- Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh
- Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy

S3:
- Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific)

Tests/docs:
- 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
2026-08-22 00:34:50 +01:00

490 lines
24 KiB
Go

//go:build test && dev
package payments
// =============================================================================
// ROUND 10 — gift-card value limits & the admin cancellation surface
// =============================================================================
//
// This file pins the money-safety behaviours added in round 10:
//
// 1. Per-transaction £250 cap on the admin-funded gift-card entry points
// (CreateGiftCard, TopUpGiftCard, TransferGiftCard): an amount of £251
// (25,100 pence) is rejected with 400 before any row is written, while
// exactly £250 (25,000 pence) stays inside the cap.
//
// 2. User daily cap of £500 on online gift-card purchases (BuyGiftCard): the
// day's spend is the sum of the caller's gift_card_transactions 'purchase'
// rows (reference_type 'api' — the signal BuyGiftCard itself writes, see
// giftcard_limits.go userGiftCardSpentToday); an attempt that would cross
// £500 is rejected 400, and the cap is inclusive (exactly £500 is
// allowed). The cap is calendar-day (created_at >= CURRENT_DATE): rolling
// yesterday's signal rows forward resets it.
//
// 3. Admin daily cap of £5,000 on gift-card value created/top-up'd: the day's
// issued value is the sum of the cards the admin created today
// (total_funds_added) plus the admin's same-day 'purchase'/'topup'
// gift_card_transactions audit rows on cards created before today (see
// giftcard_limits.go adminGiftCardValueToday); an operation that would
// cross £5,000 is rejected 400, and the cap is inclusive.
//
// 4. AdminCancelGiftCard (POST /api/admin/gift-cards/cancel, body
// {code, payment_id?}) reuses the 14-day partial-spend cancellation core:
// for a card whose shortfall is verified till spend it refunds ONLY the
// unspent remainder to the original payment method, zeroes + expires the
// card, and records a 'giftcard_cancel' refunds row. Cards outside the
// 14-day window are rejected 400 with no Square call.
//
// MONEY-SAFETY CONTRACT under test: a rejected operation must never write a
// card/transaction/payment row and never call Square; an accepted cancellation
// must issue EXACTLY ONE Square refund and must never leave the card's balance
// spendable on top of the returned money (amount_remaining zeroed + expiry in
// the past, atomically with the refund row resolution).
//
// BUILD DEPENDENCY: main.go already routes POST /admin/gift-cards/cancel to
// AdminCancelGiftCard, so until that handler (and the round-10 limit checks)
// are defined in this package the package cannot compile.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"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/assert"
"github.com/stretchr/testify/require"
)
// =============================================================================
// Round 10 helpers
// =============================================================================
// round10CreateAdmin creates an admin user the way the existing admin gift-card
// tests do (CreateTestUser + account_role update) and returns the user id and a
// role-claim 'admin' token, matching main.go's admin group (mw.RequireAuth +
// mw.RequireAdmin).
func round10CreateAdmin(t *testing.T, ctx context.Context, q db.Querier) (adminID, token string) {
t.Helper()
adminID, err := fixtures.CreateTestUser(q)
require.NoError(t, err)
_, err = q.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
require.NoError(t, err)
return adminID, jwt.GenerateTestToken(adminID, "admin")
}
// round10AdminCreateGiftCard POSTs a CreateGiftCard request through the real
// router with mw.RequireAuth + mw.RequireAdmin (mirroring main.go's admin
// group) and the test transaction embedded in the request context.
func round10AdminCreateGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount float64) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(CreateGiftCardRequest{Amount: amount})
r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards", bytes.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
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.With(mw.RequireAdmin).Post("/api/admin/gift-cards", CreateGiftCard)
router.ServeHTTP(w, r)
return w
}
// round10AdminTopUpGiftCard PUTs a TopUpGiftCard request through the real
// router with the admin middleware stack and the test transaction embedded in
// the request context.
func round10AdminTopUpGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, cardID string, amount float64) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(TopUpGiftCardRequest{Amount: amount, PaymentMethod: "cash"})
r := httptest.NewRequest(http.MethodPut, "/api/admin/gift-cards/"+cardID+"/topup", bytes.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
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.With(mw.RequireAdmin).Put("/api/admin/gift-cards/{id}/topup", TopUpGiftCard)
router.ServeHTTP(w, r)
return w
}
// round10AdminTransferGiftCard POSTs a TransferGiftCard request through the
// real router with the admin middleware stack and the test transaction
// embedded in the request context.
func round10AdminTransferGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, fromCardID, toCardID string, amount float64) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(TransferGiftCardRequest{ToCardID: toCardID, Amount: amount})
r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards/"+fromCardID+"/transfer", bytes.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
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.With(mw.RequireAdmin).Post("/api/admin/gift-cards/{from}/transfer", TransferGiftCard)
router.ServeHTTP(w, r)
return w
}
// round10AdminCancelGiftCard POSTs a gift-card cancellation through the ADMIN
// endpoint (POST /api/admin/gift-cards/cancel) with the admin middleware stack
// (mw.RequireAuth + mw.RequireAdmin, matching main.go) and the test transaction
// embedded in the request context.
func round10AdminCancelGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token, code string) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(CancelGiftCardRequest{Code: code})
r := httptest.NewRequest(http.MethodPost, "/api/admin/gift-cards/cancel", bytes.NewReader(body))
r.Header.Set("Authorization", "Bearer "+token)
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.With(mw.RequireAdmin).Post("/api/admin/gift-cards/cancel", AdminCancelGiftCard)
router.ServeHTTP(w, r)
return w
}
// round10BuyGiftCard POSTs an online gift-card purchase for a friend through
// the real BuyGiftCard handler and returns the full response recorder so the
// daily-limit message can be asserted. Mirrors round9BuyGiftCardForFriend but
// keeps the body (that helper returns only the card id and status).
func round10BuyGiftCard(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int) *httptest.ResponseRecorder {
t.Helper()
reqBody, _ := json.Marshal(map[string]interface{}{
"amount": amount,
"recipient_type": "friend",
"new_card_token": "cnon:card-nonce-ok",
"idempotency_key": fmt.Sprintf("round10-buy-%d-%d", amount, time.Now().UnixNano()),
})
r := httptest.NewRequest(http.MethodPost, "/user/giftcards/buy", bytes.NewBuffer(reqBody))
r.Header.Set("Authorization", "Bearer "+token)
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.With(mw.RequireNonGuest).Post("/user/giftcards/buy", BuyGiftCard)
router.ServeHTTP(w, r)
return w
}
// =============================================================================
// 1. £250 per-transaction cap on admin gift-card value entry points
// =============================================================================
// TestRound10_AdminGiftCardTransaction_250Cap_Rejected pins the per-transaction
// £250 cap on the three admin-funded gift-card entry points. For each of
// CreateGiftCard, TopUpGiftCard and TransferGiftCard an amount of £251
// (25,100 pence) must be rejected 400 with a message citing the cap BEFORE any
// value moves (no card created, no top-up applied, no transfer executed), while
// exactly £250 (25,000 pence) stays INSIDE the cap and succeeds. The cap is the
// money-safety ceiling for a single admin-funded gift-card operation; without
// it a mis-keyed admin entry could fund a card beyond the value the salon can
// justify, so the boundary is pinned exactly.
func TestRound10_AdminGiftCardTransaction_250Cap_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, adminToken := round10CreateAdmin(t, ctx, tx)
// Source card funds the top-up and transfer cases; destination receives
// the transfer. Both are plain unredeemed non-inventory cards.
var sourceID, destID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (300.00, 300.00, $1) RETURNING id`, adminID).Scan(&sourceID))
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (0, 0, $1) RETURNING id`, adminID).Scan(&destID))
cases := []struct {
name string
at func(t *testing.T, amount float64) *httptest.ResponseRecorder
wantSuccess int
}{
{
name: "CreateGiftCard",
at: func(t *testing.T, amount float64) *httptest.ResponseRecorder {
return round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, amount)
},
wantSuccess: http.StatusCreated,
},
{
name: "TopUpGiftCard",
at: func(t *testing.T, amount float64) *httptest.ResponseRecorder {
return round10AdminTopUpGiftCard(t, ctx, tx.(pgx.Tx), adminToken, sourceID, amount)
},
wantSuccess: http.StatusOK,
},
{
name: "TransferGiftCard",
at: func(t *testing.T, amount float64) *httptest.ResponseRecorder {
return round10AdminTransferGiftCard(t, ctx, tx.(pgx.Tx), adminToken, sourceID, destID, amount)
},
wantSuccess: http.StatusOK,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// £251 (25,100 pence) — one penny over the £250 per-transaction cap.
w := tc.at(t, 251.00)
require.Equal(t, http.StatusBadRequest, w.Code, "over-cap body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "£250", "the rejection must cite the £250 per-transaction cap")
// Boundary: exactly £250 (25,000 pence) is INSIDE the cap.
wb := tc.at(t, 250.00)
require.Equal(t, tc.wantSuccess, wb.Code, "boundary body: %s", wb.Body.String())
})
}
}
// =============================================================================
// 2. User daily cap of £500 on online gift-card purchases (BuyGiftCard)
// =============================================================================
// TestRound10_UserGiftCardDailyLimit_500 pins the user-facing daily cap: a
// user who has already purchased £500 of online gift cards today cannot buy any
// more — a £50 purchase that would land the day on £550 is rejected 400 with
// the daily-limit message ("You have reached your £500 daily gift-card purchase
// limit"). A user at £450 today can still buy £50, landing the day on EXACTLY
// £500 — pinning the cap as inclusive. (Per-purchase amounts are fixed at
// £10/£20/£50, and the daily gate sits after that amount validation, so the
// over-cap purchase is exercised at the maximum valid amount rather than a
// £100 request, which the amount validation rejects first.) The day's spend
// signal is the caller's gift_card_transactions 'purchase' rows written by
// BuyGiftCard (reference_type 'api'), seeded here via round9SeedGiftCardPurchase.
func TestRound10_UserGiftCardDailyLimit_500(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
// --- Over-cap rejection: £500 already purchased today ---
overUserID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
overToken := jwt.GenerateTestToken(overUserID, "verified_email")
for i := 0; i < 10; i++ {
round9SeedGiftCardPurchase(t, ctx, tx, overUserID, 50.00, 0)
}
// A £50 purchase would take the day to £550 — over the £500 cap.
w := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), overToken, 5000)
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "£500", "the rejection must cite the £500 daily cap")
assert.Contains(t, w.Body.String(), "daily", "the rejection must be the daily-limit message")
// --- Inclusive boundary: £450 purchased today, £50 still allowed ---
boundaryUserID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
boundaryToken := jwt.GenerateTestToken(boundaryUserID, "verified_email")
for i := 0; i < 9; i++ {
round9SeedGiftCardPurchase(t, ctx, tx, boundaryUserID, 50.00, 0)
}
// A £50 purchase takes the day to exactly £500 — inside the cap.
wb := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), boundaryToken, 5000)
require.Equal(t, http.StatusCreated, wb.Code, "boundary body: %s", wb.Body.String())
}
// =============================================================================
// 3. Admin daily cap of £5,000 on gift-card value created/top-up'd
// =============================================================================
// TestRound10_AdminGiftCardDailyLimit_5000 pins the admin daily cap: an admin
// who has issued £4,900 of gift-card value today (CreateGiftCard/TopUpGiftCard
// audit rows — reference_type 'api', user_id = the admin) cannot issue another
// £200 (that would land the day on £5,100 — over the £5,000 cap) and is
// rejected 400 with a message citing the cap, while a £100 issue that lands the
// day on EXACTLY £5,000 is accepted, pinning the cap as inclusive. The day's
// issued-value signal is seeded as both the gift-card row and its 'purchase'/
// 'topup' gift_card_transactions rows so whichever query the limit code uses
// sees £4,900.
func TestRound10_AdminGiftCardDailyLimit_5000(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
adminID, adminToken := round10CreateAdmin(t, ctx, tx)
// £4,900 of admin-issued gift-card value today: one card plus the audit
// rows CreateGiftCard/TopUpGiftCard write (transaction_type 'purchase'/
// 'topup', reference_type 'api', user_id = the admin), both created today.
var cardID string
require.NoError(t, tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
VALUES (4900.00, 4900.00, $1) RETURNING id`, adminID).Scan(&cardID))
_, err := tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes, created_at)
VALUES ($1, 'purchase', 2400.00, 'api', NULL, $2, 'seeded daily signal', NOW())`, cardID, adminID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes, created_at)
VALUES ($1, 'topup', 2500.00, 'api', NULL, $2, 'seeded daily signal', NOW())`, cardID, adminID)
require.NoError(t, err)
// A £200 creation would take the day to £5,100 — over the £5,000 cap.
// (£200 is also inside the £250 per-transaction cap, isolating the daily gate.)
w := round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, 200.00)
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "£5,000", "the rejection must cite the £5,000 daily cap")
// A £100 creation takes the day to exactly £5,000 — inside the cap.
wb := round10AdminCreateGiftCard(t, ctx, tx.(pgx.Tx), adminToken, 100.00)
require.Equal(t, http.StatusCreated, wb.Code, "boundary body: %s", wb.Body.String())
}
// =============================================================================
// 4. Admin cancellation reuses the 14-day partial-spend core
// =============================================================================
// TestRound10_AdminCancelGiftCard_PartiallySpent_RefundsRemaining pins the
// admin cancellation surface's handling of partial spend (CCR 2013 reg 34(9)):
// a £50 online purchase whose balance was genuinely spent down to £30 at the
// till (a completed giftcard payment row carrying the card id) is cancelled via
// POST /api/admin/gift-cards/cancel as an admin → 200, Square refunds EXACTLY
// once for the unspent remainder (3,000 pence), the card is neutralized (zeroed
// + expired so the refunded value can never be spent on top of the returned
// money), and the refunds row carries the 'giftcard_cancel' origin at the
// unspent amount. This proves the admin surface exercises the same
// partial-spend money path as the customer-facing flow.
func TestRound10_AdminCancelGiftCard_PartiallySpent_RefundsRemaining(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, adminToken := round10CreateAdmin(t, ctx, tx)
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
// £50 online purchase, £20 genuinely spent at the till (a completed
// giftcard payment row carrying the card id), £30 remaining.
cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0)
_, err = tx.Exec(ctx, `
UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at, gift_card_id)
VALUES (NULL, 'full', 'giftcard', 'completed', 20.00, 'r10-spend-' || $1::text, $2, NOW(), NOW(), $1)`, cardID, userID)
require.NoError(t, err)
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE idempotency_key = 'r10-spend-' || $1`, cardID)
})
w := round10AdminCancelGiftCard(t, ctx, tx.(pgx.Tx), adminToken, cardID)
require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "£30.00", "the message must state the refunded unspent portion")
assert.Contains(t, w.Body.String(), "£20.00", "the message must state the non-refundable spent portion")
// Exactly ONE Square refund, for the UNSPENT remainder (3000 pence).
calls := counting.refundCalls()
require.Len(t, calls, 1, "exactly one Square refund for the admin cancellation")
assert.Equal(t, int64(3000), calls[0].Amount, "the unspent remainder must be refunded in pence")
// Card neutralized: zero balance, expired (cannot be spent).
var rem float64
var expiry sql.NullTime
require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry))
assert.Equal(t, 0.00, rem, "card balance must be zero after the admin cancellation")
require.True(t, expiry.Valid, "the card must still carry an expiry date")
assert.False(t, expiry.Time.After(clock.Now()), "card expiry must be in the past (neutralized)")
// Refund row recorded at the partial amount with the giftcard_cancel origin.
var refundAmount float64
var refundOrigin string
require.NoError(t, tx.QueryRow(ctx, `
SELECT amount, origin FROM refunds WHERE payment_id = $1`, paymentID).
Scan(&refundAmount, &refundOrigin))
assert.Equal(t, 30.00, refundAmount, "the refunds row must record the unspent remainder")
assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund must carry the gift-card-cancel origin")
}
// TestRound10_AdminCancelGiftCard_NotCancellable_Rejected pins the statutory
// timing gate on the ADMIN cancellation surface: a card purchased outside the
// 14-day cooling-off window (seeded 15 days ago) is rejected 400 with the
// 14-day message and NO Square refund is issued — the cooling-off right is
// time-limited regardless of who invokes it.
func TestRound10_AdminCancelGiftCard_NotCancellable_Rejected(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
_, adminToken := round10CreateAdmin(t, ctx, tx)
cardID, _ := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 15*24*time.Hour)
origClient := SquareClient
counting := &countingRefundClient{SquareClient: square.NewDevClient()}
SquareClient = counting
defer func() { SquareClient = origClient }()
w := round10AdminCancelGiftCard(t, ctx, tx.(pgx.Tx), adminToken, cardID)
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "14-day", "the rejection must cite the 14-day cooling-off window")
require.Empty(t, counting.refundCalls(), "no Square refund for a card outside the 14-day window")
}
// =============================================================================
// 5. The user daily cap resets on the next calendar day
// =============================================================================
// TestRound10_UserDailyLimit_ClearsNextDay pins the daily boundary of the user
// purchase cap: after a user has purchased £500 today (exactly at the cap) a
// further £50 purchase is rejected, but once the seeded purchases' timestamps
// are rolled back to YESTERDAY the same £50 purchase succeeds — proving the cap
// is calendar-day scoped and never counts spend from a previous day. Without
// this, a single heavy day would permanently suppress future purchases (or, if
// the boundary were a rolling window, a purchase at 23:59 would bleed into the
// next day's allowance).
func TestRound10_UserDailyLimit_ClearsNextDay(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
token := jwt.GenerateTestToken(userID, "verified_email")
// £500 of purchases today — exactly at the cap.
for i := 0; i < 10; i++ {
round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0)
}
// Any further purchase today is over the cap.
w := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), token, 5000)
require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "£500", "the rejection must cite the £500 daily cap")
// Roll the seeded purchases back to yesterday across every table that
// could carry the daily-spend signal (payments, gift_card_transactions,
// gift_cards) so the day boundary resets regardless of which signal the
// limit code queries.
_, err = tx.Exec(ctx, `
UPDATE payments SET created_at = created_at - INTERVAL '1 day'
WHERE created_by = $1 AND booking_id IS NULL AND payment_method = 'online_square'`, userID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
UPDATE gift_card_transactions SET created_at = created_at - INTERVAL '1 day'
WHERE user_id = $1 AND reference_type = 'api' AND transaction_type = 'purchase'`, userID)
require.NoError(t, err)
_, err = tx.Exec(ctx, `
UPDATE gift_cards SET created_at = created_at - INTERVAL '1 day'
WHERE created_by = $1`, userID)
require.NoError(t, err)
// The same £50 purchase now succeeds — yesterday's spend does not count
// toward today's cap.
wb := round10BuyGiftCard(t, ctx, tx.(pgx.Tx), token, 5000)
require.Equal(t, http.StatusCreated, wb.Code, "next-day body: %s", wb.Body.String())
}