Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
1185 lines
58 KiB
Go
1185 lines
58 KiB
Go
//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 }
|