//go:build test && dev package payments // ============================================================================= // ROUND 9 — money-safety & security gaps from the adversarial test-quality review // ============================================================================= // // This file pins the behaviors the review found untested: // // 1. Sweep source-override replay: reconcileStalePaymentByKey must replay the // stored square_request_snapshot with the LIVE square_source_id column // overriding the snapshot's embedded (possibly stale) SourceID — a pending // row re-issued by a same-key retry refreshes the column while the snapshot // JSON stays stale, and replaying the stale source would hit // IDEMPOTENCY_KEY_REUSED and strand the row pending forever. A corrupt or // unusable snapshot must leave the row pending (never panic, never falsely // complete). // // 2. CancelGiftCard (C3 — the statutory 14-day cooling-off right): the money // path must issue EXACTLY ONE Square refund to the original payment // method, atomically neutralize the card (amount_remaining zeroed + // expired) with a 'cancelled' gift_card_transactions audit row and a // completed 'giftcard_cancel' refund row, reject every ineligible card // (wrong owner, till/admin purchase, redeemed/spent/top-up'd, outside the // 14-day window) with no Square call, and leave the card live + the refund // row pending when Square fails. GetMyGiftCards must surface the // cancellable cards with the right fields. // // 3. Handler-level £10,000 (1,000,000 pence) amount cap on CreateTillSale and // CreateTerminalPayment: an over-cap request must 400 with NO till_sales / // payments row and NO Square checkout created, while exactly £10,000 stays // inside the cap. // // SCOPE NOTES (items the review listed but cannot be tested from this file): // - clientIP / TRUST_PROXY_HEADERS gating (item 4): trustProxyHeaders is a // package-private var in crussell/mw; both files owned this round live in // package payments / user, so a ratelimit test cannot be placed here — // SKIPPED (noted for the mw package). // - CleanupIdleAccounts Square-deletion path (item 5): the function lives in // crussell/handlers/scheduling; the user package cannot reach it without // importing scheduling — SKIPPED (noted for the scheduling package). // - Webhook suite isolation (item 6): TestWebhook_DisputeCreated_NoLocalPayment_NoRow // (webhooks_state_test.go) ACKs ALL global unacknowledged // critical_payment_log notifications (UPDATE ... acknowledged_at = NOW() // with no scoping), making notification-count assertions order-dependent. // The required fix is a scoped reset in the webhooks TestMain that deletes // only test-created admin_notifications — NOT editable this round. import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "sync" "testing" "time" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // Round 9 helpers // ============================================================================= // round9CheckoutClient wraps a Square client and counts every CreateCheckout // call so tests can prove an over-cap request never reaches Square's terminal // checkout API. type round9CheckoutClient struct { square.SquareClient mu sync.Mutex calls int } func (c *round9CheckoutClient) CreateCheckout(ctx context.Context, req square.CreateCheckoutReq) (*square.CheckoutResult, error) { c.mu.Lock() c.calls++ c.mu.Unlock() return c.SquareClient.CreateCheckout(ctx, req) } // round9CancelGiftCard POSTs a gift-card cancellation through the real router // (mw.RequireAuth + mw.RequireNonGuest, matching main.go) with the test // transaction embedded in the request context. func round9CancelGiftCard(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, "/user/giftcards/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.RequireNonGuest).Post("/user/giftcards/cancel", CancelGiftCard) router.ServeHTTP(w, r) return w } // round9CancelGiftCardWithPaymentID POSTs a gift-card cancellation carrying an // explicit payment id (the suppliedPaymentID branch of // findGiftCardPurchasePayment), through the real router with the test // transaction embedded in the request context. func round9CancelGiftCardWithPaymentID(t *testing.T, ctx context.Context, tx pgx.Tx, token, code, paymentID string) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(CancelGiftCardRequest{Code: code, PaymentID: paymentID}) r := httptest.NewRequest(http.MethodPost, "/user/giftcards/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.RequireNonGuest).Post("/user/giftcards/cancel", CancelGiftCard) router.ServeHTTP(w, r) return w } // round9GetMyGiftCards GETs /user/giftcards through the real router with the // test transaction embedded in the request context. func round9GetMyGiftCards(t *testing.T, ctx context.Context, tx pgx.Tx, token string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest(http.MethodGet, "/user/giftcards", nil) r.Header.Set("Authorization", "Bearer "+token) r = r.WithContext(db.ContextWithTx(r.Context(), tx)) w := httptest.NewRecorder() router := chi.NewRouter() router.Use(mw.RequireAuth) router.Get("/user/giftcards", GetMyGiftCards) router.ServeHTTP(w, r) return w } // round9BuyGiftCardForFriend buys an online gift card for a friend via the // real BuyGiftCard handler (the ONLY customer-facing online purchase path that // creates a cancellable card: payments row + gift_cards row + a purchase // gift_card_transactions with reference_type='api' and user_id=the caller). // Returns the new card id and the HTTP status. func round9BuyGiftCardForFriend(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int) (cardID string, code int) { t.Helper() reqBody, _ := json.Marshal(map[string]interface{}{ "amount": amount, "recipient_type": "friend", "new_card_token": "cnon:card-nonce-ok", "idempotency_key": fmt.Sprintf("round9-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) if w.Code == http.StatusCreated { var resp map[string]interface{} _ = json.NewDecoder(w.Body).Decode(&resp) if c, ok := resp["code"].(string); ok { cardID = c } } return cardID, w.Code } // round9TerminalPayment POSTs a CreateTerminalPayment request through the real // router with the test transaction embedded in the request context. func round9TerminalPayment(t *testing.T, ctx context.Context, tx pgx.Tx, bookingID, adminToken string, body map[string]interface{}) *httptest.ResponseRecorder { t.Helper() bodyBytes, _ := json.Marshal(body) r := httptest.NewRequest(http.MethodPost, "/api/admin/bookings/"+bookingID+"/payment", bytes.NewReader(bodyBytes)) r.Header.Set("Authorization", "Bearer "+adminToken) r.Header.Set("Content-Type", "application/json") r = r.WithContext(db.ContextWithTx(r.Context(), tx)) w := httptest.NewRecorder() router := chi.NewRouter() router.Use(mw.RequireAuth) router.Post("/api/admin/bookings/{id}/payment", CreateTerminalPayment) router.ServeHTTP(w, r) return w } // round9SeedGiftCardPurchase inserts a fully-funded, unredeemed, non-inventory // online gift-card purchase for userID exactly as BuyGiftCard would: a // completed online_square payments row with square_payment_id and booking_id // NULL, a gift_cards row holding the full purchase value, and a purchase // gift_card_transactions row (reference_type='api', user_id=userID). purchaseAge // ages the whole purchase (card, transaction, and payment — the payment lands // one minute before the transaction so findGiftCardPurchasePayment's 15-minute // match window sees it). Returns the card id and payment id; pool-level // cleanup is registered. func round9SeedGiftCardPurchase(t *testing.T, ctx context.Context, q db.Querier, userID string, amountPounds float64, purchaseAge time.Duration) (cardID, paymentID string) { t.Helper() pool := context.Background() purchasedAt := clock.Now().Add(-purchaseAge) if err := q.QueryRow(ctx, ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, last_used_at, expiry_date, created_at) VALUES ($1, $1, $2, FALSE, $3::timestamptz, $3::timestamptz + INTERVAL '24 months', $3::timestamptz) RETURNING id `, amountPounds, userID, purchasedAt).Scan(&cardID); err != nil { t.Fatalf("failed to seed gift card: %v", err) } if _, err := q.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', $2, 'api', NULL, $3, 'purchased for friend', $4) `, cardID, amountPounds, userID, purchasedAt); err != nil { t.Fatalf("failed to seed gift-card purchase transaction: %v", err) } if err := q.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at) VALUES (NULL, 'full', 'online_square', 'completed', $1, $2, $3, $4, $5, $5) RETURNING id `, amountPounds, "pay_gccancel_"+cardID, "gccancel-"+cardID, userID, purchasedAt.Add(-time.Minute)).Scan(&paymentID); err != nil { t.Fatalf("failed to seed gift-card purchase payment: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID) _, _ = db.Conn.Exec(pool, `DELETE FROM refunds WHERE payment_id = $1`, paymentID) _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(pool, `DELETE FROM gift_cards WHERE id = $1`, cardID) }) return cardID, paymentID } // round9SeedStaleKeyedPending inserts a stale pending payments row (23h old — // past the 22h keyed-reconcile cutoff, still inside Square's 24h idempotency // retention window) with a stored idempotency key, a square_source_id, and a // square_request_snapshot JSON string. An empty snapshot is stored as NULL // (the legacy-row shape). Pool-level cleanup is registered. func round9SeedStaleKeyedPending(t *testing.T, ctx context.Context, q db.Querier, bookingID string, amountPounds float64, key, liveSource, snapshotJSON string) string { t.Helper() var payID string if err := q.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, square_source_id, square_request_snapshot, created_at, updated_at) VALUES ($1, 'full', 'online_square', 'pending', $2, $3, $4, NULLIF($5, ''), NOW() - INTERVAL '23 hours', NOW()) RETURNING id `, bookingID, amountPounds, key, liveSource, snapshotJSON).Scan(&payID); err != nil { t.Fatalf("failed to seed stale pending payment: %v", err) } t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, payID) }) return payID } // round9SweepCommitAndCleanup commits the caller's setup transaction (so the // sweep, which runs against the pool, sees the seeded rows) and registers // pool-level cleanup for the standard user/service/booking fixture trio. func round9SweepCommitAndCleanup(t *testing.T, ctx context.Context, staleID, bookingID, serviceID, userID string) { t.Helper() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx, "setup transaction missing from context") require.NoError(t, pgxTx.Commit(ctx), "failed to commit sweep setup tx") pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE id = $1`, staleID) _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) } // ============================================================================= // 1. Sweep source-override replay // ============================================================================= // TestRound9_SweepSourceOverride_SnapshotSourceVsLiveColumn_LiveWins locks the // sweep source-override money-safety contract: a pending row whose stored // square_request_snapshot embeds a STALE source (the JSON still carries the // original charge's source) while the live square_source_id column holds a // DIFFERENT source (refreshed by a same-key retry) must be replayed with the // LIVE column value. The dev mock holds a COMPLETED charge under the same // idempotency key whose source is the LIVE value — the exact condition where a // snapshot-only replay returns IDEMPOTENCY_KEY_REUSED and strands the row // pending forever. The sweep must rescue the row to 'completed' with the // mock's square_payment_id, proving the live-column override won. func TestRound9_SweepSourceOverride_SnapshotSourceVsLiveColumn_LiveWins(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) const key = "round9-source-override-key" const snapshotSource = "cnon:snapshot-source" const liveSource = "cnon:live-source" // The stored snapshot embeds the ORIGINAL charge's source; the live column // was refreshed by a same-key retry (the write side refreshes // square_source_id, and B6 keeps the snapshot in sync — a legacy/race row // may still diverge, which is exactly what the sweep override rescues). snapshotJSON := fmt.Sprintf(`{"Amount":200000,"Currency":"GBP","SourceID":%q,"IdempotencyKey":%q}`, snapshotSource, key) staleID := round9SeedStaleKeyedPending(t, ctx, tx, bookingID, 2000.00, key, liveSource, snapshotJSON) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) // The charge actually landed at Square under the LIVE source (the source // the retained key used). A replay WITHOUT the override would send the // snapshot's stale source and the mock would reject it with // IDEMPOTENCY_KEY_REUSED. pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 200000, Currency: "GBP", SourceID: liveSource, IdempotencyKey: key, }) require.NoError(t, err) SquareClient = mock defer func() { SquareClient = origClient }() round9SweepCommitAndCleanup(t, ctx, staleID, bookingID, serviceID, userID) if _, err := SweepStalePendingPayments(context.Background()); err != nil { t.Fatalf("sweep failed: %v", err) } var status, sqPayID string require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status, COALESCE(square_payment_id, '') FROM payments WHERE id = $1`, staleID).Scan(&status, &sqPayID)) assert.Equal(t, "completed", status, "a genuinely-charged lost-response row must be rescued to completed") assert.Equal(t, pay.SquarePayID, sqPayID, "the replay override to the LIVE square_source_id column must win over the stale snapshot source") } // TestRound9_SweepSourceOverride_CorruptSnapshot_LeavesPending locks the // corrupt-snapshot money-safety rule: a pending row whose stored // square_request_snapshot cannot be parsed (unmarshal fails when the sweep // tries to override the source) must be LEFT PENDING — never rescued to // completed and never failed, because the true charge outcome is unknowable // and a crash/panic would lose the money entirely. The mock holds a COMPLETED // charge under the key, so the ONLY thing standing between the row and a // rescue is the broken snapshot. func TestRound9_SweepSourceOverride_CorruptSnapshot_LeavesPending(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) const key = "round9-corrupt-snapshot-key" staleID := round9SeedStaleKeyedPending(t, ctx, tx, bookingID, 2000.00, key, "cnon:live-source", `not-json{{{{`) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) // The charge really completed at Square under the key — a rescue WOULD be // correct if the snapshot were usable, which makes "left pending" the only // safe outcome for the unparseable snapshot. _, err = mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 200000, Currency: "GBP", SourceID: "cnon:live-source", IdempotencyKey: key, }) require.NoError(t, err) SquareClient = mock defer func() { SquareClient = origClient }() round9SweepCommitAndCleanup(t, ctx, staleID, bookingID, serviceID, userID) if _, err := SweepStalePendingPayments(context.Background()); err != nil { t.Fatalf("sweep failed: %v", err) } var status string var sqPayID sql.NullString require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status, square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&status, &sqPayID)) assert.Equal(t, "pending", status, "a corrupt snapshot must leave the row pending (never panicked, never falsely completed)") assert.False(t, sqPayID.Valid, "no square_payment_id may be written for a row with an unparseable snapshot") } // TestRound9_SweepSourceOverride_EmptySnapshot_SourceMismatch_LeavesPending // locks the empty-snapshot (legacy NULL square_request_snapshot) money-safety // rule: the sweep rebuilds the minimal fallback replay body from the live // square_source_id column, and when that source does NOT match the source the // original charge used (the live column was refreshed by a same-key retry) the // identical-body replay returns IDEMPOTENCY_KEY_REUSED — which is NEVER proof // of no charge. The row must stay pending (CRITICAL logged), never falsely // completed and never failed. func TestRound9_SweepSourceOverride_EmptySnapshot_SourceMismatch_LeavesPending(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) const key = "round9-empty-snapshot-key" // Empty snapshot (stored NULL, the legacy-row shape) + a live source that // differs from the source the original charge actually used. The minimal // fallback replay body is built from the LIVE column, so the mock (which // compares the source) sees a different source than the retained key's // original and returns IDEMPOTENCY_KEY_REUSED. staleID := round9SeedStaleKeyedPending(t, ctx, tx, bookingID, 2000.00, key, "cnon:live-source", "") origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) // The ORIGINAL charge used a different source than the live column. _, err = mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 200000, Currency: "GBP", SourceID: "cnon:original-source", IdempotencyKey: key, }) require.NoError(t, err) SquareClient = mock defer func() { SquareClient = origClient }() round9SweepCommitAndCleanup(t, ctx, staleID, bookingID, serviceID, userID) if _, err := SweepStalePendingPayments(context.Background()); err != nil { t.Fatalf("sweep failed: %v", err) } var status string var sqPayID sql.NullString require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status, square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&status, &sqPayID)) assert.Equal(t, "pending", status, "IDEMPOTENCY_KEY_REUSED on the empty-snapshot fallback must leave the row pending (never proof of no charge)") assert.False(t, sqPayID.Valid, "no square_payment_id may be written when the empty-snapshot replay could not prove the charge") } // ============================================================================= // 2. CancelGiftCard — the 14-day cooling-off money path (C3) // ============================================================================= // TestRound9_CancelGiftCard_HappyPath_RefundAndNeutralize locks the core C3 // money path: an online gift-card purchase for a friend (<14 days, unredeemed, // full balance) is cancelled via POST /user/giftcards/cancel → 200, the Square // refund is issued EXACTLY once to the original payment, a 'giftcard_cancel' // refund row is created and completed with the Square refund id, the card is // neutralized (amount_remaining 0, expiry set to now so it can never be // spent), and a 'cancelled' gift_card_transactions audit row records the // reversal — all atomically. The originating payment is auto-matched from the // gift-card purchase transaction (no client-supplied payment id), exactly as // the frontend cancel flow does. This auto-match path is currently BLOCKED on // findGiftCardPurchasePayment (giftcards.go) accepting a timestamptz parameter // in interval arithmetic (`$3 - INTERVAL '15 minutes'` — needs `::timestamptz` // casts, otherwise the query 500s); the money path itself is verified via the // supplied-payment branch in TestRound9_CancelGiftCard_HappyPath_WithPaymentID_RefundAndNeutralize. func TestRound9_CancelGiftCard_HappyPath_RefundAndNeutralize(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 5000) require.Equal(t, http.StatusCreated, code, "buy must succeed") require.NotEmpty(t, cardID, "buy must return the card id") var remaining float64 require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining)) require.Equal(t, 50.00, remaining, "card must hold its full £50 value before cancellation") w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "success", "the cancel response must report success") // Exactly ONE Square refund to the original payment, for the full value. calls := counting.refundCalls() require.Len(t, calls, 1, "exactly one Square refund for a single cancellation") assert.Equal(t, int64(5000), calls[0].Amount, "the full purchase value must be refunded in pence") require.NotEmpty(t, calls[0].PaymentID, "the refund must target the originating Square payment") // Refunds row: origin 'giftcard_cancel', completed, with the Square id. var payID string require.NoError(t, tx.QueryRow(ctx, ` SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL `, userID).Scan(&payID)) var refundOrigin, refundStatus string var refundAmount float64 var sqRefundID sql.NullString require.NoError(t, tx.QueryRow(ctx, ` SELECT origin, status, amount, square_refund_id FROM refunds WHERE payment_id = $1 `, payID).Scan(&refundOrigin, &refundStatus, &refundAmount, &sqRefundID)) assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund row must carry the giftcard_cancel origin") assert.Equal(t, "completed", refundStatus, "a COMPLETED Square refund resolves the row to completed") assert.Equal(t, 50.00, refundAmount, "the refund row records the full purchase value") require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the Square refund id must be recorded on the refund row") // 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 zeroed after cancellation") require.True(t, expiry.Valid, "the cancelled card must have an expiry date") assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired (expiry_date set to now)") // 'cancelled' audit row written against the refund. var cancelTxCount int require.NoError(t, tx.QueryRow(ctx, ` SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'cancelled' `, cardID).Scan(&cancelTxCount)) assert.Equal(t, 1, cancelTxCount, "a 'cancelled' gift_card_transactions row must record the reversal") } // TestRound9_CancelGiftCard_HappyPath_WithPaymentID_RefundAndNeutralize locks // the C3 money path through findGiftCardPurchasePayment's supplied-payment // branch (the frontend can pass the purchase payment id to skip the amount/ // timing auto-match): the Square refund is issued exactly once for the full // purchase value, the 'giftcard_cancel' refund row is completed with the // Square refund id, the card is neutralized (zeroed + expired), and the // 'cancelled' audit row is written. This variant does NOT depend on the // auto-match SQL (`$3 - INTERVAL '15 minutes'`) whose param-type inference is // being corrected in giftcards.go, so it verifies the money path today. func TestRound9_CancelGiftCard_HappyPath_WithPaymentID_RefundAndNeutralize(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 5000) require.Equal(t, http.StatusCreated, code, "buy must succeed") require.NotEmpty(t, cardID, "buy must return the card id") var payID string require.NoError(t, tx.QueryRow(ctx, ` SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL `, userID).Scan(&payID)) w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, payID) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "success", "the cancel response must report success") calls := counting.refundCalls() require.Len(t, calls, 1, "exactly one Square refund for a single cancellation") assert.Equal(t, int64(5000), calls[0].Amount, "the full purchase value must be refunded in pence") var refundOrigin, refundStatus string var refundAmount float64 var sqRefundID sql.NullString require.NoError(t, tx.QueryRow(ctx, ` SELECT origin, status, amount, square_refund_id FROM refunds WHERE payment_id = $1 `, payID).Scan(&refundOrigin, &refundStatus, &refundAmount, &sqRefundID)) assert.Equal(t, "giftcard_cancel", refundOrigin, "the refund row must carry the giftcard_cancel origin") assert.Equal(t, "completed", refundStatus, "a COMPLETED Square refund resolves the row to completed") assert.Equal(t, 50.00, refundAmount, "the refund row records the full purchase value") require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the Square refund id must be recorded on the refund row") 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 zeroed after cancellation") require.True(t, expiry.Valid, "the cancelled card must have an expiry date") assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired (expiry_date set to now)") var cancelTxCount int require.NoError(t, tx.QueryRow(ctx, ` SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'cancelled' `, cardID).Scan(&cancelTxCount)) assert.Equal(t, 1, cancelTxCount, "a 'cancelled' gift_card_transactions row must record the reversal") } // TestRound9_CancelGiftCard_DoubleCancel_NoSecondSquareRefund locks the // double-cancel money-safety contract: after a successful first cancellation // the card holds no value, so a second POST must NOT issue a second Square // refund and must NOT create a second refunds row. Square is called exactly // ONCE total. (The implemented code rejects the repeat with 400 — "topped up, // transferred, or partially spent" — because the neutralized card no longer // holds its full purchase value; the review's expected "already refunded" // message is not emitted on this path, but the money-safety property — one // Square refund, one refund row — is what the test pins.) func TestRound9_CancelGiftCard_DoubleCancel_NoSecondSquareRefund(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 2000) require.Equal(t, http.StatusCreated, code, "buy must succeed") require.NotEmpty(t, cardID) var payID string require.NoError(t, tx.QueryRow(ctx, ` SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL `, userID).Scan(&payID)) w1 := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, payID) require.Equal(t, http.StatusOK, w1.Code, "first cancel must succeed: %s", w1.Body.String()) require.Len(t, counting.refundCalls(), 1, "first cancel issues one Square refund") w2 := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.NotEqual(t, http.StatusOK, w2.Code, "a repeat cancel must not report a fresh success, body: %s", w2.Body.String()) require.Len(t, counting.refundCalls(), 1, "Square must be called exactly once total across both cancels") var refundCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, payID).Scan(&refundCount)) assert.Equal(t, 1, refundCount, "exactly one refund row after a double-cancel attempt") } // TestRound9_CancelGiftCard_WrongUser_Rejected locks the ownership gate: a // user who is NOT the buyer of an online gift-card purchase cannot cancel it — // the purchase-transaction lookup is scoped to user_id = caller, so a // non-owner's request is rejected with NO Square refund call and NO refund row. // The implementation returns 400 (information-hiding — a non-owner cannot // distinguish an existing card from a nonexistent one); the review's expected // 404/403 is functionally equivalent. func TestRound9_CancelGiftCard_WrongUser_Rejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) buyerID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) attackerID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) attackerToken := jwt.GenerateTestToken(attackerID, "verified_email") cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, buyerID, 50.00, 0) origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting defer func() { SquareClient = origClient }() w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), attackerToken, cardID) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "not purchased online by your account") require.Empty(t, counting.refundCalls(), "no Square refund for a non-owner cancel") var refundCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) assert.Equal(t, 0, refundCount, "no refund row for a non-owner cancel") } // TestRound9_CancelGiftCard_TillOrAdminPurchase_Rejected locks the source-of- // purchase gate: a card that was NOT purchased online by the caller — sold at // the till (gift_card_transactions reference_type='till_sale') or created by // an admin (reference_type='api' but user_id = admin, not the caller) — must // be rejected with 400 and no Square call, because the statutory right applies // only to the consumer's own distance contracts. func TestRound9_CancelGiftCard_TillOrAdminPurchase_Rejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting defer func() { SquareClient = origClient }() t.Run("till_sale_card", func(t *testing.T) { cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) // A till sale writes the purchase transaction with reference_type // 'till_sale' (see till.go), never 'api'. _, err := tx.Exec(ctx, ` UPDATE gift_card_transactions SET reference_type = 'till_sale', user_id = NULL WHERE gift_card_id = $1 AND transaction_type = 'purchase' `, cardID) require.NoError(t, err) w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "not purchased online by your account") var refundCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) assert.Equal(t, 0, refundCount) }) t.Run("admin_created_card", func(t *testing.T) { cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) // CreateGiftCard (admin) writes reference_type='api' but user_id = the // ADMIN, never the calling user. _, err := tx.Exec(ctx, ` UPDATE gift_card_transactions SET user_id = $1 WHERE gift_card_id = $2 AND transaction_type = 'purchase' `, adminID, cardID) require.NoError(t, err) w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "not purchased online by your account") var refundCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) assert.Equal(t, 0, refundCount) }) require.Empty(t, counting.refundCalls(), "no Square refund for till/admin-purchased cards") } // TestRound9_CancelGiftCard_PartiallySpent_RefundsRemaining pins the CCR 2013 // reg 34(9) partial-use behaviour: a card whose balance was partially spent at // the till (shortfall verifiable via a completed payments row carrying // gift_card_id, status='completed', payment_method='giftcard') is still // cancellable within the 14-day window — Square refunds only the UNSPENT // remainder, and the card is neutralized (zeroed + expired) so the refunded // value can never be spent on top of the returned money. The already-spent // portion is consumed salon services, not refundable. func TestRound9_CancelGiftCard_PartiallySpent_RefundsRemaining(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") 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, '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 = 'spend-' || $1`, cardID) }) w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, 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 a partial 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 partial 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 deterministic key. var refundAmount float64 var refundOrigin, refundKey string require.NoError(t, tx.QueryRow(ctx, ` SELECT amount, origin, COALESCE(idempotency_key, '') FROM refunds WHERE payment_id = $1`, paymentID). Scan(&refundAmount, &refundOrigin, &refundKey)) 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") assert.Contains(t, refundKey, "-gccancel-", "the refund key must be the deterministic cancel key") } // TestRound9_CancelGiftCard_PartiallySpent_UnaccountedShortfall_Rejected pins // that a shortfall NOT attributable to a completed giftcard till-payment (e.g. // a transfer out, or a manual balance edit) is rejected: without the payments // linkage the value-state cannot be verified, and issuing a partial refund // would be unsafe. func TestRound9_CancelGiftCard_PartiallySpent_UnaccountedShortfall_Rejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting defer func() { SquareClient = origClient }() cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) // Balance reduced with NO matching giftcard payment row (unaccounted). _, err = tx.Exec(ctx, `UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID) require.NoError(t, err) w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) var refundCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) assert.Equal(t, 0, refundCount, "no refund row for an unverifiable shortfall") require.Empty(t, counting.refundCalls(), "no Square refund for an unaccounted shortfall") } // TestRound9_CancelGiftCard_RedeemedSpentTopup_Rejected locks the value-state // gates: a card that was redeemed to an account balance, partially spent, or // topped up no longer holds exactly its original purchase value, so a FULL // refund would leave money outstanding — each state must be rejected with 400 // and no Square call. func TestRound9_CancelGiftCard_RedeemedSpentTopup_Rejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") cases := []struct { name string mutate func(t *testing.T, cardID string) wantMsg string }{ { name: "redeemed", mutate: func(t *testing.T, cardID string) { _, err := tx.Exec(ctx, ` UPDATE gift_cards SET redeemed_by = $1, redeemed_at = NOW(), amount_remaining = 0 WHERE id = $2 `, userID, cardID) require.NoError(t, err) }, wantMsg: "already been redeemed", }, { name: "partially_spent", mutate: func(t *testing.T, cardID string) { _, err := tx.Exec(ctx, `UPDATE gift_cards SET amount_remaining = 30.00 WHERE id = $1`, cardID) require.NoError(t, err) }, wantMsg: "partially spent", }, { name: "topped_up", mutate: func(t *testing.T, cardID string) { _, err := tx.Exec(ctx, `UPDATE gift_cards SET total_funds_added = 100.00, amount_remaining = 100.00 WHERE id = $1`, cardID) require.NoError(t, err) }, wantMsg: "topped up", }, } origClient := SquareClient counting := &countingRefundClient{SquareClient: square.NewDevClient()} SquareClient = counting defer func() { SquareClient = origClient }() for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) tc.mutate(t, cardID) w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), tc.wantMsg) var refundCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) assert.Equal(t, 0, refundCount, "no refund row for an ineligible card") }) } require.Empty(t, counting.refundCalls(), "no Square refund for redeemed/spent/topped-up cards") } // TestRound9_CancelGiftCard_Outside14Days_Rejected locks the statutory timing // gate: the Consumer Contracts Regulations 2013 cooling-off window is 14 days, // so a purchase older than 14 days must be rejected with 400 and no Square // call. func TestRound9_CancelGiftCard_Outside14Days_Rejected(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") cardID, paymentID := 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 := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "14-day cancellation period") require.Empty(t, counting.refundCalls(), "no Square refund outside the 14-day window") var refundCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM refunds WHERE payment_id = $1`, paymentID).Scan(&refundCount)) assert.Equal(t, 0, refundCount, "no refund row outside the 14-day window") } // TestRound9_CancelGiftCard_SquareRefundFails_KeepsCardPendingRefund locks the // failure-safety contract (C2/F4): when Square's RefundPayment returns an // ambiguous error, the handler must return 500, leave the gift card LIVE // (amount_remaining untouched, expiry in the future) and leave the refunds row // 'pending' with a deterministic idempotency key so a same-key retry — or the // sweep — can still recover the refund. A refund must never be issued without // the card value being neutralized, and the card must never be cancelled while // the money is still with the salon. func TestRound9_CancelGiftCard_SquareRefundFails_KeepsCardPendingRefund(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) origClient := SquareClient // ambiguousRefundClient simulates a transport-level failure: Square may or // may not have processed the refund, so the row must stay pending. counting := &countingRefundClient{SquareClient: &ambiguousRefundClient{SquareClient: square.NewDevClient()}} SquareClient = counting defer func() { SquareClient = origClient }() w := round9CancelGiftCard(t, ctx, tx.(pgx.Tx), token, cardID) require.Equal(t, http.StatusInternalServerError, w.Code, "body: %s", w.Body.String()) require.Len(t, counting.refundCalls(), 1, "Square refund attempted exactly once") // Card NOT neutralized: full balance, expiry still in the future. var remaining float64 var expiry sql.NullTime require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &expiry)) assert.Equal(t, 50.00, remaining, "card balance must be untouched when the Square refund fails") require.True(t, expiry.Valid, "the card must still carry an expiry date") assert.True(t, expiry.Time.After(clock.Now()), "card expiry must be untouched (still in the future)") // Refund row left pending for a same-key retry / sweep reconciliation. var status, origin string var idempotencyKey sql.NullString require.NoError(t, tx.QueryRow(ctx, ` SELECT status, origin, idempotency_key FROM refunds WHERE payment_id = $1 `, paymentID).Scan(&status, &origin, &idempotencyKey)) assert.Equal(t, "pending", status, "an ambiguous Square failure leaves the refund pending") assert.Equal(t, "giftcard_cancel", origin) require.True(t, idempotencyKey.Valid && idempotencyKey.String != "", "the pending refund must carry a deterministic idempotency key") // No 'cancelled' audit row — the card was never cancelled. var cancelTxCount int require.NoError(t, tx.QueryRow(ctx, ` SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'cancelled' `, cardID).Scan(&cancelTxCount)) assert.Equal(t, 0, cancelTxCount, "no cancellation audit row when the refund failed") } // TestRound9_CancelGiftCard_SquareRefundFails_WithPaymentID_KeepsCardPendingRefund // locks the failure-safety contract through the supplied-payment branch: when // Square's RefundPayment returns an ambiguous error, the handler returns 500, // the card stays LIVE (full balance, expiry in the future), and the // 'giftcard_cancel' refund row stays 'pending' with a deterministic // idempotency key for a same-key retry / sweep reconciliation. This variant // avoids the auto-match SQL dependency and verifies the money-safety behavior // today. func TestRound9_CancelGiftCard_SquareRefundFails_WithPaymentID_KeepsCardPendingRefund(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") cardID, paymentID := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) origClient := SquareClient counting := &countingRefundClient{SquareClient: &ambiguousRefundClient{SquareClient: square.NewDevClient()}} SquareClient = counting defer func() { SquareClient = origClient }() w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID) require.Equal(t, http.StatusInternalServerError, w.Code, "body: %s", w.Body.String()) require.Len(t, counting.refundCalls(), 1, "Square refund attempted exactly once") var remaining float64 var expiry sql.NullTime require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining, &expiry)) assert.Equal(t, 50.00, remaining, "card balance must be untouched when the Square refund fails") require.True(t, expiry.Valid, "the card must still carry an expiry date") assert.True(t, expiry.Time.After(clock.Now()), "card expiry must be untouched (still in the future)") var status, origin string var idempotencyKey sql.NullString require.NoError(t, tx.QueryRow(ctx, ` SELECT status, origin, idempotency_key FROM refunds WHERE payment_id = $1 `, paymentID).Scan(&status, &origin, &idempotencyKey)) assert.Equal(t, "pending", status, "an ambiguous Square failure leaves the refund pending") assert.Equal(t, "giftcard_cancel", origin) require.True(t, idempotencyKey.Valid && idempotencyKey.String != "", "the pending refund must carry a deterministic idempotency key") } // TestRound9_GetMyGiftCards_CancellableCardFields locks the account-page // surface for the 14-day right: GET /user/giftcards lists the caller's online // purchases still held as cards with the amount, purchase time, expiry date, // and — for a card inside the window with a verifiable originating payment and // no refund — cancellable=true plus the payment id, so the UI can offer // "Cancel & refund" exactly where the consumer is legally entitled to it. A // purchase older than 14 days must be listed but marked non-cancellable with // the expiry reason. func TestRound9_GetMyGiftCards_CancellableCardFields(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") freshCardID, _ := round9SeedGiftCardPurchase(t, ctx, tx, userID, 50.00, 0) oldCardID, _ := round9SeedGiftCardPurchase(t, ctx, tx, userID, 20.00, 15*24*time.Hour) w := round9GetMyGiftCards(t, ctx, tx.(pgx.Tx), token) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) var resp MyGiftCardsResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Len(t, resp.GiftCards, 2, "both online-purchased cards must be listed") var fresh, old *MyGiftCard for i := range resp.GiftCards { gc := &resp.GiftCards[i] switch gc.Code { case freshCardID: fresh = gc case oldCardID: old = gc } } require.NotNil(t, fresh, "the fresh card must be listed") require.NotNil(t, old, "the old card must be listed") require.True(t, fresh.Cancellable, "a fresh unredeemed online purchase must be cancellable") assert.Equal(t, 50.00, fresh.Amount, "the card amount is the purchase value") assert.NotEmpty(t, fresh.PaymentID, "the originating payment id must be surfaced for a cancellable card") assert.False(t, fresh.PurchasedAt.IsZero(), "the purchase time must be surfaced") require.NotNil(t, fresh.ExpiryDate, "the rolling expiry date must be surfaced") assert.False(t, old.Cancellable, "a purchase outside the 14-day window must not be cancellable") assert.Contains(t, old.CancellationReason, "14-day cancellation period") } // ============================================================================= // 3. £10,000 amount cap at the handler level (C2) // ============================================================================= // TestRound9_CreateTillSale_OverCap_Rejected_NoRowNoCheckout locks the // handler-level £10,000 cap on CreateTillSale: a create/top-up amount above // £10,000 (1,000,000 pence — 10,000.01 pounds) must be rejected with 400 for // every payment method, with NO till_sales row, NO gift card created, and NO // Square terminal checkout created (the cap fires before any lock, // transaction, or Square call). Gift-card till funding is capped at £250 per // transaction (owner decision) — an amount above £250 must be rejected. func TestRound9_CreateTillSale_OverCap_Rejected_NoRowNoCheckout(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") origClient := SquareClient cc := &round9CheckoutClient{SquareClient: square.NewDevClient()} SquareClient = cc defer func() { SquareClient = origClient }() cases := []struct { name string req TillSaleRequest }{ {name: "cash", req: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 250.01, PaymentMethod: "cash"}}, {name: "card_machine", req: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 250.01, PaymentMethod: "card_machine"}}, {name: "online_square", req: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 250.01, PaymentMethod: "online_square", CardToken: "cnon:test-card"}}, {name: "topup_over_cap", req: TillSaleRequest{ItemType: "gift_card", Action: "topup", Amount: 250.01, GiftCardID: stringPtr("a1b2c3d4e5f6"), PaymentMethod: "cash"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := makeTillSaleRequest(t, tc.req, adminToken, ctx, tx.(pgx.Tx)) require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "£250", "the rejection must cite the gift-card amount cap") }) } require.Equal(t, 0, cc.calls, "no Square checkout may be created for an over-cap till sale") var saleCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM till_sales WHERE created_by = $1`, adminID).Scan(&saleCount)) assert.Equal(t, 0, saleCount, "no till_sales row for an over-cap till sale") var cardCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_cards WHERE created_by = $1`, adminID).Scan(&cardCount)) assert.Equal(t, 0, cardCount, "no gift card created for an over-cap till sale") } // TestRound9_CreateTerminalPayment_OverCap_Rejected_NoRow locks the // handler-level £10,000 cap on CreateTerminalPayment: an amount above // 1,000,000 pence must be rejected with 400 — for the cash path, the terminal // path (no Square checkout created), and the override_amount path (a valid // base amount must not bypass the cap) — with NO payments row. The boundary is // pinned too: exactly £10,000 (1,000,000 pence) is INSIDE the cap and records // the payment. func TestRound9_CreateTerminalPayment_OverCap_Rejected_NoRow(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking in_progress: %v", err) } adminToken := jwt.GenerateTestToken(adminID, "admin") origClient := SquareClient cc := &round9CheckoutClient{SquareClient: square.NewDevClient()} SquareClient = cc defer func() { SquareClient = origClient }() // 1,000,001 pence — one penny over the £10,000 cap. w := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, map[string]interface{}{"amount": 1000001, "payment_type": "full", "payment_method": "cash"}) require.Equal(t, http.StatusBadRequest, w.Code, "cash over-cap body: %s", w.Body.String()) // Terminal path (no payment_method → Square checkout): the cap must reject // BEFORE any checkout is created at Square. w2 := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, map[string]interface{}{"amount": 1000001, "payment_type": "full"}) require.Equal(t, http.StatusBadRequest, w2.Code, "terminal over-cap body: %s", w2.Body.String()) require.Equal(t, 0, cc.calls, "no Square checkout may be created for an over-cap terminal payment") // Override path: a valid base amount must not bypass the cap. w3 := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, map[string]interface{}{"amount": 1000, "payment_type": "full", "payment_method": "cash", "override_amount": 1000001}) require.Equal(t, http.StatusBadRequest, w3.Code, "override over-cap body: %s", w3.Body.String()) assert.Contains(t, w3.Body.String(), "Invalid override amount", "the override rejection must identify the override") // No payment row for any of the rejected requests. var payCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&payCount)) assert.Equal(t, 0, payCount, "no payment row for an over-cap terminal payment") // Boundary: exactly £10,000 (1,000,000 pence) is ALLOWED — the cap is // exclusive. B3: on a £50 booking the recorded amount is clamped to the // £50 remaining obligation (the frontend-sent amount that ignored prior // payments must never be recorded verbatim), but the request itself is // accepted. wb := round9TerminalPayment(t, ctx, tx.(pgx.Tx), bookingID, adminToken, map[string]interface{}{"amount": 1000000, "payment_type": "full", "payment_method": "cash"}) require.Equal(t, http.StatusOK, wb.Code, "boundary cash body: %s", wb.Body.String()) var boundaryCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND amount = 50.00`, bookingID).Scan(&boundaryCount)) assert.Equal(t, 1, boundaryCount, "the £10,000 boundary request must be accepted and clamped to the £50 remaining obligation") } // stringPtr is a small helper for optional string request fields. func stringPtr(s string) *string { return &s }