//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()) }