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)
490 lines
24 KiB
Go
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())
|
|
}
|