Fix review findings: aggregated-refund/saved-card/legacy-refund idempotency keys, structured Square error classification, CSP for Square SDK

Money-safety idempotency fixes (external review bugs 1-3):
- processChargeGroup: aggregated refund key now hashes the sorted pending-row
  set (chargeID-square-agg-<sha256 suffix>) so a changed group can never mark
  a new row completed against an old smaller refund; >45-char chargeIDs use a
  hashed prefix instead of verbatim truncation (which would collide charges on
  Square's global key dedup). Same-set crash-retry keeps Square's dedup.
- CreateTerminalPayment saved_card: two-tier idempotency key — client-supplied
  per-attempt UUID preferred (distinct identical charges no longer collapse),
  deterministic booking+type+amount+card fallback for no-key retry safety.
  PaymentModal sends a per-charge UUID cleared after success.
- ensureRefundKey: legacy NULL-key manual refunds persist a generated key to
  the row BEFORE the Square call (race-safe AND idempotency_key IS NULL guard),
  so a lost-response retry reuses the key and never double-refunds. Wired into
  resumeManualPendingRefund and the sweep's manual-retry loop.

Classification + money-safety hardening:
- till.go/sweep.go: structured square.ErrorCode/IsNotFound are authoritative
  when present; message-substring matching only for non-structured errors
  (dev mock, client-side status errors). Fixes fragile string-matching driving
  sweep retries and gift-card clawbacks.
- SaveCardForUser: ON CONFLICT (user_id, square_card_id) DO NOTHING + re-select
  (was a latent UNIQUE-violation 500 on save-card retry).
- CreateBookingPayment: partial payments re-validated against remaining balance
  inside the advisory lock (closes concurrent-overpayment race).
- InvalidateSquareCustomerCache on GDPR erasure paths (account.go,
  time-blockers.go stale-guest anonymization).
- GetUserGiftCardBalanceAdmin: in-handler admin check (defense-in-depth).
- getCheckoutHTTP: warn on multi-payment checkouts instead of dropping
  payments[1:].
- Cash/giftcard terminal branch: removed dead idempotency SELECT, "tip-" ->
  "till-" prefix.
- UserPaymentModal: removed vestigial polling state; proper interval cleanup.
- account/+page.svelte: gift-card redeem dialog links /terms.
- nginx CSP: allow *.squarecdn.com and js.squareup.com so the Square Web
  Payments SDK + card iframe can tokenize behind the proxy.

Tests: +8 regression tests covering changed-set refund keys, legacy NULL-key
single-refund, saved-card client-key dedup/no-dedup, concurrent partials, and
cache invalidation. Full suite + race detector clean via run-tests.sh lockfile.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 726ac8cb65
commit a8d54f1e2a
21 changed files with 1279 additions and 111 deletions
@@ -314,6 +314,138 @@ func TestBookingPayment_ConcurrentSameKey_SingleRecord(t *testing.T) {
}
}
// countingCreatePaymentClient delays the Square charge (widening the advisory
// lock race window) and counts every successful CreatePayment call so the test
// can assert exactly one charge reaches Square.
type countingCreatePaymentClient struct {
square.SquareClient
mu sync.Mutex
charges int
}
func (c *countingCreatePaymentClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) {
c.mu.Lock()
c.charges++
c.mu.Unlock()
time.Sleep(300 * time.Millisecond)
return c.SquareClient.CreatePayment(ctx, req)
}
func (c *countingCreatePaymentClient) chargeCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.charges
}
// TestBookingPayment_ConcurrentPartials_SingleCharge proves the in-lock
// remaining-balance re-check (handlers.go): two concurrent 'partial' payments
// whose combined amount exceeds the booking's remaining balance must yield ONE
// successful charge — the loser is rejected with 4xx inside the advisory lock
// BEFORE inserting a pending record or hitting Square. Without the re-check
// both pass the pre-lock ValidatePartialAmount against the same balance, both
// charge, and the overflow is silently recorded as a tip.
func TestBookingPayment_ConcurrentPartials_SingleCharge(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestDataPast(t, ctx, tx)
token := jwt.GenerateUserToken(userID)
cleanupConcurrentTestRows(t, context.Background(), userID, bookingID)
innerTx := db.TxFromContext(ctx)
if innerTx == nil {
t.Fatal("no transaction in context")
}
if err := innerTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit setup tx: %v", err)
}
// The fixture service costs £50, so the booking's remaining balance is 5000
// pence. Two £30 partials sum to £60 > £50 — only one may succeed.
var remainingCents int64
if err := db.Conn.QueryRow(context.Background(),
`SELECT ROUND(total_amount * 100)::bigint FROM bookings WHERE id = $1`, bookingID).Scan(&remainingCents); err != nil {
t.Fatalf("failed to read booking total: %v", err)
}
if remainingCents != 5000 {
t.Fatalf("expected fixture booking total of 5000 pence, got %d", remainingCents)
}
origClient := SquareClient
slow := &countingCreatePaymentClient{SquareClient: square.NewDevClient()}
SquareClient = slow
defer func() { SquareClient = origClient }()
pool := context.Background()
cardToken := "cnon:concurrent-partial-card"
reqBody := CreateBookingPaymentRequest{
Amount: 3000,
PaymentType: "partial",
NewCardToken: &cardToken,
IdempotencyKey: "partial-concurrent-" + bookingID,
}
var wg sync.WaitGroup
startBoth := make(chan struct{})
recs := make([]*httptest.ResponseRecorder, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-startBoth
recs[idx] = makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", reqBody, token, pool)
}(i)
}
close(startBoth)
wg.Wait()
// Exactly one request wins; the loser is rejected by the in-lock balance
// re-check (409) — or by the pre-lock filter if it read the reduced balance
// after the winner committed (400). Either way, never a second charge.
okCount, rejectedCount := 0, 0
for i, rec := range recs {
switch {
case rec.Code == http.StatusOK:
okCount++
case rec.Code == http.StatusBadRequest || rec.Code == http.StatusConflict:
rejectedCount++
default:
t.Errorf("request %d unexpected status %d: %s", i, rec.Code, rec.Body.String())
}
}
if okCount != 1 {
t.Errorf("expected exactly 1 successful partial payment, got %d", okCount)
}
if rejectedCount != 1 {
t.Errorf("expected exactly 1 rejected partial payment, got %d", rejectedCount)
}
// Exactly one charge reached Square.
if n := slow.chargeCount(); n != 1 {
t.Errorf("expected exactly 1 Square charge, got %d (double-charge!)", n)
}
// Exactly one completed payment for the booking, and no overpayment: the
// recorded total must not exceed the booking's remaining balance.
var payCount int
if err := db.Conn.QueryRow(pool,
`SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND status = 'completed'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 completed payment, got %d (double-charge!)", payCount)
}
var paidPence int64
if err := db.Conn.QueryRow(pool,
`SELECT ROUND(COALESCE(SUM(amount), 0) * 100)::bigint FROM payments WHERE booking_id = $1 AND status = 'completed' AND payment_type <> 'tip'`,
bookingID).Scan(&paidPence); err != nil {
t.Fatalf("failed to sum paid amount: %v", err)
}
if paidPence > remainingCents {
t.Errorf("overpayment recorded: paid %d pence exceeds remaining balance %d pence", paidPence, remainingCents)
}
}
// completedCheckoutClient forces GetCheckout to return a fixed COMPLETED
// payment for any checkout id, deterministically exercising the terminal
// completion dedup+insert path (the mock's real async goroutine would be
+5
View File
@@ -844,6 +844,11 @@ func GetGiftCardBalance(w http.ResponseWriter, r *http.Request) {
// GetUserGiftCardBalanceAdmin Handler returns any user's balance for the admin.
func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) {
if !isAdminRequest(r) {
http.Error(w, "Unauthorized", http.StatusForbidden)
return
}
ctx := r.Context()
userID := chi.URLParam(r, "id")
if userID == "" || !validators.IsValidID(userID) {
+73 -42
View File
@@ -36,6 +36,14 @@ type CreateTerminalPaymentRequest struct {
// directly, bypassing the terminal. The frontend sends this for the admin
// "Charge Saved Card" action.
UserSavedCardID *string `json:"saved_card_id,omitempty"`
// idempotency_key: optional client-generated per-attempt UUID for saved-card
// charges. The frontend generates one per distinct charge and reuses it
// across retries of the SAME charge, so two DISTINCT identical charges on
// one booking (e.g. a second £50 'full' charge for a second service) get
// different keys and never collapse on the deterministic fallback key.
// When absent, the handler falls back to the deterministic booking+type+
// amount+card key for no-client-key retry safety.
IdempotencyKey string `json:"idempotency_key,omitempty"`
}
type CreateBookingPaymentRequest struct {
@@ -287,8 +295,10 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// dedup two legitimate identical payments (e.g. two £50 cash receipts on
// one booking). Use a unique key per payment: retries of a lost response
// are handled by the Square-side key for card payments, and cash/giftcard
// are DB-committed synchronously.
idempotencyKey := uniqueChargeKey("tip-")
// are DB-committed synchronously. There is deliberately NO idempotency
// dedup check here — each request inserts its own row, and two identical
// cash receipts are legitimate distinct payments.
idempotencyKey := uniqueChargeKey("till-")
// Route based on payment method
if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") {
@@ -322,23 +332,6 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
return
}
// Check idempotency inside the transaction.
var existingID string
var existingStatus string
if err := tx.QueryRow(r.Context(), `
SELECT id, status FROM payments WHERE booking_id = $1 AND idempotency_key = $2
`, bookingID, idempotencyKey).Scan(&existingID, &existingStatus); err == nil {
if err := json.NewEncoder(w).Encode(CheckoutResponse{
CheckoutID: existingID,
Status: existingStatus,
}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
return
} else if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to check idempotency: %v", err)
}
amountPounds := float64(amount) / 100.0
var paymentID string
@@ -535,10 +528,25 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
}
defer releaseBookingPaymentLock(pinConn, "crussell:payment:"+bookingID)
// Deterministic idempotency key: booking+type+amount+card. A network
// retry with the same inputs derives the same key → dedup, never a
// second charge. ≤45 chars for Square's limit.
scKey := bookingID + "-sc-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10) + "-" + *req.UserSavedCardID
// Idempotency key — two tiers:
// 1. Client-supplied per-attempt UUID (preferred): the frontend
// generates one per DISTINCT charge and reuses it across retries of
// the same charge. Two distinct identical charges on one booking
// (e.g. a second £50 'full' charge for a second service) send
// different UUIDs → no dedup, each becomes its own payment. The
// UUID is globally unique so it is NOT namespaced with the booking
// id (the UNIQUE(idempotency_key) constraint is global); the dedup
// SELECT matches on booking_id + key, so a same-booking retry of
// the same UUID still dedups.
// 2. Deterministic booking+type+amount+card fallback when the client
// sends no key: a no-key network retry derives the same key → dedup,
// never a second charge (old-client retry safety).
// Both stay ≤45 chars for Square's limit (36-char UUID / ~38-char
// deterministic key).
scKey := req.IdempotencyKey
if scKey == "" {
scKey = bookingID + "-sc-" + req.PaymentType + "-" + strconv.FormatInt(amount, 10) + "-" + *req.UserSavedCardID
}
// Idempotency switch inside the lock: completed → dedup; pending →
// reuse (re-attempt Square with the same key, which dedups Square-side);
@@ -1299,6 +1307,28 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
return
}
// Authoritative remaining-balance re-check for 'partial' payments, inside
// the advisory lock. The cheap pre-lock ValidatePartialAmount above can
// race a concurrent partial payment on the same booking: both pass against
// the same remaining balance, then both charge at Square, and the overflow
// is silently recorded as a tip by buildSplitRecords. The lock serializes
// payment attempts, so by the time we re-read here a competing payment has
// already committed — reject before any pending record is inserted or
// Square is hit.
if req.PaymentType == "partial" {
remainingCents, err := service.GetBookingRemainingBalanceCents(r.Context(), bookingID)
if err != nil {
log.Printf("Failed to get remaining balance: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if err := ValidatePartialAmount(req.Amount, remainingCents); err != nil {
log.Printf("Payment rejected: %v", err)
http.Error(w, "Partial amount exceeds remaining balance", http.StatusConflict)
return
}
}
// Check idempotency inside the transaction.
// Only short-circuit when the existing record is 'completed'. A 'pending'
// record means the previous Square call failed — returning it as 200 would
@@ -2101,10 +2131,18 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
}
return
}
// Persist a fallback key (legacy NULL-key rows) before re-issuing so
// a lost-response retry reuses it — see ensureRefundKey.
reissueKey, keyErr := ensureRefundKey(r.Context(), existingRefundID.String, paymentID, resumeAmount, existingRefundKey.String)
if keyErr != nil {
log.Printf("Failed to ensure refund key for refund %s before re-issue: %v", existingRefundID.String, keyErr)
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
return
}
reissueReq := square.RefundPaymentReq{
PaymentID: refundSqPaymentID,
Amount: resumeAmount,
IdempotencyKey: refundResumeKey(paymentID, resumeAmount, existingRefundKey.String),
IdempotencyKey: reissueKey,
Reason: existingRefundReason.String,
}
reissueResult, reissueErr := SquareClient.RefundPayment(r.Context(), reissueReq)
@@ -2388,29 +2426,22 @@ func RefundPayment(w http.ResponseWriter, r *http.Request) {
// row. Called from RefundPayment's exact-key dedup and the (payment, amount)
// pending fallback. Using the stored key lets Square return the original
// refund if the prior attempt actually completed (response loss), so no second
// refund can ever be issued for a row whose money state is unknown.
// refundResumeKey returns the row's OWN stored idempotency key when present, or
// a fresh deterministic fallback when the row predates keyed refunds (legacy
// NULL idempotency_key). Square's RefundPayment REQUIRES a non-empty
// idempotency key — re-issuing with "" returns a 400 INVALID_REQUEST_ERROR,
// which classifies as ambiguous and leaves the refund pending forever. The
// fallback mirrors the no-client-key shape (paymentID + "-refund-" + amount +
// "-" + hex) and stays ≤45 chars (12 + 8 + up-to-9 + 1 + 12 ≈ 42), so the
// re-issue is never rejected for length either. A legit stored key is ALWAYS
// reused so Square's same-key dedup keeps returning the original refund.
func refundResumeKey(paymentID string, amount int64, storedKey string) string {
if storedKey != "" {
return storedKey
}
return paymentID + "-refund-" + strconv.FormatInt(amount, 10) + "-" + randomHexSuffix(6)
}
// refund can ever be issued for a row whose money state is unknown. A legacy
// NULL-key row gets a fallback key persisted to the row FIRST (ensureRefundKey),
// so a lost-response retry reuses it instead of double-refunding with a fresh
// random suffix.
func resumeManualPendingRefund(w http.ResponseWriter, r *http.Request, paymentID string, payment *PaymentRecord, refundID string, refundAmount float64, refundReason, refundKey string) {
resumeAmount := int64(math.Round(refundAmount * 100))
resumeKey, keyErr := ensureRefundKey(r.Context(), refundID, paymentID, resumeAmount, refundKey)
if keyErr != nil {
log.Printf("Failed to ensure refund key for refund %s before resume: %v", refundID, keyErr)
http.Error(w, "Unable to verify refund status with Square, please retry", http.StatusServiceUnavailable)
return
}
resumeReq := square.RefundPaymentReq{
PaymentID: *payment.SquarePaymentID,
Amount: resumeAmount,
IdempotencyKey: refundResumeKey(paymentID, resumeAmount, refundKey),
IdempotencyKey: resumeKey,
Reason: refundReason,
}
resumeResult, resumeErr := SquareClient.RefundPayment(r.Context(), resumeReq)
@@ -443,6 +443,82 @@ func TestCreatePaymentMethodFromToken_ProvisionsCustomerOnceAndReuses(t *testing
require.Equal(t, cid1, cid2, "both saved cards must share the user's Square customer id")
}
// alwaysNewCustomerClient counts every CreateCustomer call and always returns a
// fresh customer id (unlike recordingCustomerClient, which dedups by email and
// would mask a second mint for the same user).
type alwaysNewCustomerClient struct {
square.SquareClient
mu sync.Mutex
createCalls int
}
func (c *alwaysNewCustomerClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) {
c.mu.Lock()
c.createCalls++
id := fmt.Sprintf("cus_cache_%d", c.createCalls)
c.mu.Unlock()
return &square.CustomerResult{ID: id, Email: email}, nil
}
func (c *alwaysNewCustomerClient) customerCallCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.createCalls
}
// TestInvalidateSquareCustomerCache_DropsCachedID proves the exported cache
// invalidation: after GDPR erasure NULLs the DB square_customer_id and the
// customer is deleted at Square, the process-local cache must not keep serving
// the erased user's stale customer id. Without invalidation a later
// ensureSquareCustomer would return the cached id without re-minting; after
// invalidation it re-queries the (NULLed) DB and mints a fresh customer.
func TestInvalidateSquareCustomerCache_DropsCachedID(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// A saved-card row is the persistence point for the customer id; start
// with a NULL square_customer_id so ensureSquareCustomer must mint one.
_, err = tx.Exec(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
VALUES ($1, 'sq_card_cache_test', 'Visa', '4242', 12, 2030, 'fp_cache', true)
`, userID)
require.NoError(t, err)
origClient := SquareClient
rec := &alwaysNewCustomerClient{SquareClient: square.NewDevClient()}
SquareClient = rec
defer func() { SquareClient = origClient }()
t.Cleanup(func() { InvalidateSquareCustomerCache(userID) })
svc := NewPaymentService()
// 1. First ensure mints a customer and caches it.
c1, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.NotEmpty(t, c1)
require.Equal(t, 1, rec.customerCallCount())
// Simulate GDPR erasure NULLing the saved-card square_customer_id.
_, err = tx.Exec(ctx, `UPDATE user_saved_cards SET square_customer_id = NULL WHERE user_id = $1`, userID)
require.NoError(t, err)
// 2. WITHOUT invalidation the stale cached id is still served (no re-mint).
c2, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.Equal(t, c1, c2, "stale cached customer id must be served when the cache is NOT invalidated")
require.Equal(t, 1, rec.customerCallCount())
// 3. Invalidate, then re-ensure: the entry is gone, so the NULLed DB is
// re-queried and a fresh customer is minted — the stale identity must not
// resurface.
InvalidateSquareCustomerCache(userID)
c3, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.NotEqual(t, c1, c3, "after invalidation a fresh customer must be minted, not the stale cached id")
require.Equal(t, 2, rec.customerCallCount())
}
func TestBuyGiftCard_NoSaveCard_NoCustomerProvisioned(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
+139
View File
@@ -3953,6 +3953,145 @@ func TestSavedCardPayment_SweptFailed_Rejected(t *testing.T) {
}
}
// TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup verifies the Bug 2
// fix: the frontend sends a per-attempt idempotency UUID, so two DISTINCT
// identical saved-card charges on the same booking (same amount, same card,
// same payment type) must NOT collapse on the old deterministic
// booking+type+amount+card key. Each becomes its own payment.
func TestSavedCardPayment_ClientKey_DistinctCharges_NoDedup(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:distinct-key-card", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
handler := CreateTerminalPayment
// Two legitimately distinct £50 'full' charges on the same booking — the
// frontend sends a different per-attempt UUID for each.
for i, key := range []string{"saved-card-uuid-0001", "saved-card-uuid-0002"} {
reqBody := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: key,
}
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("saved-card charge %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
// Exactly TWO payment records — the second charge must not be swallowed by
// the dedup branch.
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 2 {
t.Errorf("expected exactly 2 payment records (no dedup), got %d", payCount)
}
}
// TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups verifies that a lost-
// response retry carrying the SAME client UUID still dedups to a single
// payment — the client key does not disable retry safety.
func TestSavedCardPayment_ClientKey_SameKeyRetry_Dedups(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:same-key-card", "VISA", "4242")
if err != nil {
t.Fatalf("failed to create saved card: %v", err)
}
handler := CreateTerminalPayment
reqBody := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("saved_card"),
UserSavedCardID: &cardID,
IdempotencyKey: "saved-card-uuid-0001",
}
// First charge.
w1 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w1.Code != http.StatusOK {
t.Fatalf("first saved-card charge: expected 200, got %d. body: %s", w1.Code, w1.Body.String())
}
// Same-input retry (lost response) — same client UUID → dedup, not a
// second charge.
w2 := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w2.Code != http.StatusOK {
t.Fatalf("retry saved-card charge: expected 200, got %d. body: %s", w2.Code, w2.Body.String())
}
// Exactly ONE payment record.
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'online_square'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 1 {
t.Errorf("expected exactly 1 payment record (same-key dedup), got %d — double-charge!", payCount)
}
}
// TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup verifies the cash/
// giftcard branch: two identical £50 cash receipts on the same booking are
// legitimate distinct payments and must each insert their own row (there is
// deliberately no idempotency dedup in this branch).
func TestTerminalPayment_TwoIdenticalCashReceipts_NoDedup(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
_, bookingID, _ := setupTestData(t, ctx, tx)
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
adminToken := jwt.GenerateTestToken(adminID, "admin")
handler := CreateTerminalPayment
reqBody := CreateTerminalPaymentRequest{
Amount: 5000,
PaymentType: "full",
PaymentMethod: strPtr("cash"),
}
for i := 0; i < 2; i++ {
w := makePaymentRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/payment", reqBody, adminToken, ctx)
if w.Code != http.StatusOK {
t.Fatalf("cash receipt %d: expected 200, got %d. body: %s", i+1, w.Code, w.Body.String())
}
}
var payCount int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND payment_method = 'cash'`, bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if payCount != 2 {
t.Errorf("expected exactly 2 cash payment rows (no dedup), got %d", payCount)
}
}
// TestSweepStalePendingPayments_CoversTillSales verifies the R3 fix: the sweep
// also marks stale pending till_sales rows (card payments) as failed, so a
// lost-response till sale can't stay pending past Square's key retention.
+108 -6
View File
@@ -2,6 +2,7 @@ package payments
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"log"
@@ -9,6 +10,7 @@ import (
"math"
"sort"
"strconv"
"strings"
"time"
"crussell/clock"
@@ -871,14 +873,29 @@ func processChargeGroup(ctx context.Context, chargeID string, rows []pendingChar
}
}
// ONE Square refund per charge with a STABLE charge-level idempotency key.
// The key is used ONLY for the Square call — never stored in
// refunds.idempotency_key (the per-row keys remain the audit trail). Square
// dedups same-key retries, so crash-retry amounts stay identical.
// ONE Square refund per charge with a SET-STABLE idempotency key. The key
// is derived from the sha256 of the SORTED ids of the pending rows being
// aggregated — computed from `pending`, the rows re-read UNDER the lock,
// NEVER the caller-supplied `rows` slice (which may be stale by the time
// the lock is held). The key is used ONLY for the Square call — never
// stored in refunds.idempotency_key (the per-row keys remain the audit
// trail).
//
// SAME-set retry (a crash/response-loss where the pending set is unchanged)
// hashes to the SAME key → Square's idempotency dedup returns the ORIGINAL
// refund, so a retry can never double-refund. A CHANGED set (a new
// cancellation refund row joined the group while the old rows still sit
// 'pending' — the CRITICAL-log path where the post-refund DB UPDATE failed)
// hashes to a NEW key → Square issues a fresh refund covering the new
// total, so the new row is NEVER marked 'completed' against an old smaller
// refund with no money actually moved (the under-refund / lost-money
// bookkeeping bug). The 23h age-guard reconcile above still protects the
// cross-sweep case where Square's finite (~24h) key retention may have
// lapsed.
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: chargeID,
Amount: totalCents,
IdempotencyKey: chargeID + "-square-agg",
IdempotencyKey: chargeAggKey(chargeID, idsOf(pending)),
Reason: reason,
})
switch {
@@ -997,6 +1014,38 @@ func idsOf(rows []pendingChargeRow) []string {
return ids
}
// aggRefundKeySuffix returns a deterministic 12-hex-char suffix identifying
// the exact set of pending refund rows being aggregated. The sorted row IDs
// (CHAR(12), so plain concatenation is unambiguous) are sha256'd and truncated:
// the SAME set always yields the SAME suffix — keeping Square's idempotency-key
// dedup for a same-set crash-retry — while a CHANGED set yields a DIFFERENT
// suffix, so a new row can never resolve against an old smaller refund.
func aggRefundKeySuffix(ids []string) string {
sorted := append([]string(nil), ids...)
sort.Strings(sorted)
h := sha256.Sum256([]byte(strings.Join(sorted, "")))
return fmt.Sprintf("%x", h)[:12]
}
// chargeAggKey builds the charge-level idempotency key for an aggregated
// refund as <chargeID>-square-agg-<set-suffix>. Square's idempotency-key limit
// is 45 chars; the verbatim form needs the chargeID ≤21 chars. square_payment_id
// is an arbitrary TEXT column holding Square's real payment ID (typically 20-28
// chars), so the verbatim form can exceed the limit — and a >45-char key is
// rejected with a 400 INVALID_REQUEST_ERROR (classified ambiguous → stuck
// pending forever). When the verbatim form does not fit, the chargeID is
// sha256'd into a fixed-width prefix instead — NEVER truncated verbatim: two
// charges sharing a truncated prefix would collide on Square's global key dedup
// and silently swallow the second charge's refund (lost money).
func chargeAggKey(chargeID string, ids []string) string {
suffix := aggRefundKeySuffix(ids)
key := chargeID + "-square-agg-" + suffix
if len(key) <= 45 {
return key
}
return aggRefundKeySuffix([]string{chargeID}) + "-square-agg-" + suffix
}
// manualPendingRow is one stale manual refund row eligible for the sweep's
// resolution. It covers BOTH pending shapes the RefundPayment handler can
// leave behind:
@@ -1085,6 +1134,44 @@ func sweepManualPendingSquareRefunds(ctx context.Context) (int, error) {
return processed, nil
}
// ensureRefundKey returns the idempotency key to use when re-issuing a manual
// refund, persisting a generated fallback to the refunds row BEFORE Square is
// called so every retry reuses the SAME key — Square dedups same-key retries,
// so a lost-response retry can never issue a SECOND refund.
//
// A legacy refund row with a NULL idempotency_key used to get a fresh random
// suffix on every resume; if the Square call succeeded but the follow-up DB
// UPDATE to 'completed' failed (the CRITICAL-log path), the row stayed
// 'pending' with its key STILL NULL and the next resume generated a NEW random
// key → a second Square refund (double refund). Persisting the key first
// closes that hole: a crash-retry re-reads the persisted key and reuses it.
//
// The `AND idempotency_key IS NULL` guard makes the persist race-safe: only
// one concurrent caller wins the UPDATE; a loser (0 rows affected) re-reads
// and returns the winner's key. The generated shape (paymentID + "-refund-" +
// amount + "-" + 12 hex chars) stays ≤45 chars: 12 + 8 + up-to-9 + 1 + 12 ≈ 42.
func ensureRefundKey(ctx context.Context, refundID, paymentID string, amount int64, storedKey string) (string, error) {
if storedKey != "" {
return storedKey, nil
}
key := paymentID + "-refund-" + strconv.FormatInt(amount, 10) + "-" + randomHexSuffix(6)
tag, err := db.Conn.Exec(ctx, `
UPDATE refunds SET idempotency_key = $1
WHERE id = $2 AND idempotency_key IS NULL
`, key, refundID)
if err != nil {
return "", fmt.Errorf("failed to persist refund idempotency key for %s: %w", refundID, err)
}
if tag.RowsAffected() == 0 {
var existing string
if err := db.Conn.QueryRow(ctx, `SELECT idempotency_key FROM refunds WHERE id = $1`, refundID).Scan(&existing); err != nil {
return "", fmt.Errorf("failed to re-read persisted refund idempotency key for %s: %w", refundID, err)
}
return existing, nil
}
return key, nil
}
// processManualPaymentGroup retries one payment's stale manual pending refunds
// under the SAME per-payment advisory lock the manual RefundPayment handler
// holds across its guard read — so a re-issued refund can never double-spend
@@ -1239,10 +1326,25 @@ func processManualPaymentGroup(ctx context.Context, paymentID string, rows []man
continue
}
// Route the stored key through ensureRefundKey: a legacy row with a
// NULL idempotency_key must NOT reach Square with "" (a 400
// INVALID_REQUEST_ERROR, classified ambiguous → stuck forever). The
// helper persists a generated fallback to the row first, so every retry
// reuses the SAME key — a lost-response retry can never issue a second
// refund (Square dedups same-key retries).
idemKey, keyErr := ensureRefundKey(ctx, pr.ID, pr.PaymentID, amountCents, pr.IdempotencyKey)
if keyErr != nil {
// The key could not be persisted — Square must not be called with an
// empty/unknown key. Leave the row pending for the next sweep (never
// mark failed on an unknown state); the 23h age guard above will
// eventually reconcile it.
log.Printf("Failed to ensure refund key for manual refund %s before re-issue: %v", pr.ID, keyErr)
continue
}
sqResult, sqErr := SquareClient.RefundPayment(ctx, square.RefundPaymentReq{
PaymentID: pr.SquarePaymentID,
Amount: amountCents,
IdempotencyKey: pr.IdempotencyKey,
IdempotencyKey: idemKey,
Reason: pr.Reason,
})
switch {
+380 -12
View File
@@ -40,6 +40,21 @@ func (c *countingRefundClient) refundCalls() []square.RefundPaymentReq {
return append([]square.RefundPaymentReq(nil), c.calls...)
}
// assertAggKey asserts the charge-level aggregated refund key carries the
// "-square-agg-" segment and stays within Square's 45-char idempotency-key
// limit. The prefix is the verbatim chargeID for short square_payment_ids and a
// hashed fixed-width form for long ones (see chargeAggKey), so only the shared
// segment + length bound are asserted here.
func assertAggKey(t *testing.T, got, chargeID string) {
t.Helper()
if !strings.Contains(got, "-square-agg-") {
t.Errorf("expected charge-level idempotency key containing \"-square-agg-\", got %q", got)
}
if len(got) > 45 {
t.Errorf("expected idempotency key within Square's 45-char limit, got %d chars: %q", len(got), got)
}
}
// ambiguousRefundClient simulates a transport-level failure (no sentinel
// error) — Square may or may not have processed the refund.
type ambiguousRefundClient struct {
@@ -1002,9 +1017,7 @@ func TestProcessCancellationRefund_SplitPayment_SingleSquareRefund(t *testing.T)
if calls[0].Amount != 5000 {
t.Errorf("expected aggregated Square refund of 5000 pence, got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected charge-level idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
assertAggKey(t, calls[0].IdempotencyKey, sameSquareID)
// Both refund records must be completed and share ONE square_refund_id —
// the old bug left one record completed with square_refund_id NULL (no
@@ -1353,9 +1366,7 @@ func TestProcessPendingSquareRefunds_SameChargePending_IssuesRefund(t *testing.T
if calls[0].Amount != 2500 {
t.Errorf("expected Square refund of 2500 pence (the residual), got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected charge-level idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
assertAggKey(t, calls[0].IdempotencyKey, sameSquareID)
// The balance pending refund must be completed WITH a square_refund_id —
// money moved, NOT left NULL.
@@ -1858,9 +1869,7 @@ func TestProcessPendingSquareRefunds_SplitCharge_OneAggregateRefund(t *testing.T
if calls[0].Amount != 5000 {
t.Errorf("expected aggregated amount 5000 pence (25+25), got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
assertAggKey(t, calls[0].IdempotencyKey, sameSquareID)
// Both rows completed with the same square_refund_id, amounts preserved.
rows, err := db.Conn.Query(freshCtx, `
@@ -2361,9 +2370,7 @@ func TestProcessPendingSquareRefunds_PartialManualThenCancel_IssuesResidual(t *t
if calls[0].Amount != 7000 {
t.Errorf("expected aggregated residual of 7000 pence (£70), got %d", calls[0].Amount)
}
if calls[0].IdempotencyKey != sameSquareID+"-square-agg" {
t.Errorf("expected idempotency key %q, got %q", sameSquareID+"-square-agg", calls[0].IdempotencyKey)
}
assertAggKey(t, calls[0].IdempotencyKey, sameSquareID)
// Two pending rows created for the residual: deposit £20 + balance £50.
rows, err := db.Conn.Query(freshCtx, `
@@ -3250,3 +3257,364 @@ func TestSweepPendingSquareRefunds_ManualWithSquareRefundID_NoMatch_Failed(t *te
t.Errorf("expected at least 1 admin_notification with reason 'refund_failed', got %d", notifCount)
}
}
// =============================================================================
// Bug fix — set-stable charge-level idempotency key (refunds.go processChargeGroup)
// =============================================================================
// TestChargeAggKey_AlwaysFitsAndDeterministic locks the charge-level idempotency
// key builder: the key must stay within Square's 45-char limit for ANY
// square_payment_id length (the verbatim form only fits chargeIDs ≤21 chars;
// longer ones fall back to a hashed fixed-width prefix — see chargeAggKey), be
// deterministic for a given (charge, set), and differ across charges/sets.
func TestChargeAggKey_AlwaysFitsAndDeterministic(t *testing.T) {
ids := []string{"id1", "id2"}
longCharge := "sqp_real_square_payment_id_that_is_quite_long"
// Short chargeID → verbatim form.
shortKey := chargeAggKey("sqp_short", ids)
if !strings.HasPrefix(shortKey, "sqp_short-square-agg-") {
t.Errorf("expected verbatim prefix for a short chargeID, got %q", shortKey)
}
if len(shortKey) > 45 {
t.Errorf("expected short-charge key within 45 chars, got %d: %q", len(shortKey), shortKey)
}
// Long chargeID → still ≤45, never the verbatim form.
longKey := chargeAggKey(longCharge, ids)
if strings.HasPrefix(longKey, longCharge+"-square-agg-") {
t.Errorf("expected long chargeID NOT embedded verbatim, got %q", longKey)
}
if len(longKey) > 45 {
t.Errorf("expected long-charge key within 45 chars, got %d: %q", len(longKey), longKey)
}
// Same (charge, set) → same key; different set → different key; different
// charge → different key (no cross-charge dedup collision).
if chargeAggKey("sqp_short", ids) != shortKey {
t.Error("expected the same (charge, set) to produce the same key")
}
if chargeAggKey("sqp_short", []string{"id1"}) == shortKey {
t.Error("expected a changed set to produce a different key")
}
if chargeAggKey(longCharge, []string{"id1"}) == longKey {
t.Error("expected a changed set to produce a different key (long charge)")
}
if chargeAggKey("sqp_other", ids) == shortKey {
t.Error("expected a different charge to produce a different key")
}
if chargeAggKey(longCharge+"x", ids) == longKey {
t.Error("expected a different long charge to produce a different key")
}
}
// TestProcessChargeGroup_ChangedPendingSet_NewKey verifies the idempotency-key
// bug fix: the charge-level aggregated refund key is derived from the SET of
// pending row ids. A SAME-set retry (the CRITICAL-log path where the post-refund
// DB UPDATE failed, leaving the rows 'pending') reuses the SAME key → Square's
// dedup returns the ORIGINAL refund. When a NEW cancellation refund row joins
// the group, the set changes → a NEW key → Square issues a fresh refund covering
// the new total, so the new row is NEVER marked 'completed' against the old
// smaller refund (the under-refund / lost-money bookkeeping bug).
func TestProcessChargeGroup_ChangedPendingSet_NewKey(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Split payment records sharing one square_payment_id (deposit + balance).
depositID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create deposit payment: %v", err)
}
balanceID, err := fixtures.CreateTestPayment(tx, bookingID, 25.00, "online_square", "balance", "completed")
if err != nil {
t.Fatalf("failed to create balance payment: %v", err)
}
chargeID := "sqp_changed_set"
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id IN ($2, $3)", chargeID, depositID, balanceID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
insertRefund := func(pid string, amount float64, key string) string {
t.Helper()
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, $3, 'pending', 'client_cancelled', $4, 'cancellation', NOW())
RETURNING id
`, pid, bookingID, amount, key).Scan(&id); err != nil {
t.Fatalf("failed to insert pending refund: %v", err)
}
return id
}
row1 := insertRefund(depositID, 25, depositID+"-square-2500")
row2 := insertRefund(balanceID, 25, balanceID+"-square-2500")
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
freshCtx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
runGroup := func() string {
t.Helper()
if _, err := processChargeGroup(freshCtx, chargeID, fetchPendingChargeRows(freshCtx, chargeID), "client_cancelled"); err != nil {
t.Fatalf("processChargeGroup failed: %v", err)
}
calls := counting.refundCalls()
return calls[len(calls)-1].IdempotencyKey
}
// Run 1: the two-row set → key K1 covering £50.
key1 := runGroup()
assertAggKey(t, key1, chargeID)
calls := counting.refundCalls()
if len(calls) != 1 || calls[0].Amount != 5000 {
t.Fatalf("expected exactly 1 Square refund of 5000 pence, got %d call(s) (amount %d)", len(calls), calls[0].Amount)
}
var oldRefundID string
if err := db.Conn.QueryRow(freshCtx, `SELECT square_refund_id FROM refunds WHERE id = $1`, row1).Scan(&oldRefundID); err != nil {
t.Fatalf("failed to read refund id: %v", err)
}
// SAME-set retry (the CRITICAL-log path: Square committed but the DB UPDATE
// failed, rows still 'pending'): reset to pending and re-run → the SAME key
// K1, and Square's dedup returns the ORIGINAL refund (no second refund).
_, err = db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', square_refund_id = NULL WHERE id = ANY($1)`, []string{row1, row2})
if err != nil {
t.Fatalf("failed to reset refund rows pending: %v", err)
}
keyRetry := runGroup()
if keyRetry != key1 {
t.Errorf("expected SAME-set retry to reuse key %q, got %q", key1, keyRetry)
}
if n := mock.RefundKeyCount(); n != 1 {
t.Errorf("expected Square to have issued exactly 1 refund after the same-set retry (dedup), got %d", n)
}
// A NEW cancellation refund row joins the group (e.g. an admin re-cancels
// the residual after run 1's DB UPDATE failed) → the set changes.
var row3 string
err = db.Conn.QueryRow(freshCtx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, origin, created_at)
VALUES ($1, $2, 25, 'pending', 'client_cancelled', $3, 'cancellation', NOW())
RETURNING id
`, balanceID, bookingID, balanceID+"-square-2500-2").Scan(&row3)
if err != nil {
t.Fatalf("failed to insert third pending refund: %v", err)
}
// Reset the original two rows to pending again (they completed in run 2) so
// the group is once more the FULL set {row1, row2, row3}.
_, err = db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', square_refund_id = NULL WHERE id = ANY($1)`, []string{row1, row2})
if err != nil {
t.Fatalf("failed to reset refund rows pending: %v", err)
}
// Run 3: the three-row set → key K2 ≠ K1 → a NEW Square refund for the full
// £75 — never deduped against the old £50 refund.
key2 := runGroup()
if key2 == key1 {
t.Errorf("expected CHANGED set to yield a DIFFERENT key, got the same %q", key2)
}
calls = counting.refundCalls()
if len(calls) != 3 {
t.Fatalf("expected 3 total Square refund calls, got %d", len(calls))
}
if calls[2].Amount != 7500 {
t.Errorf("expected the changed-set refund of 7500 pence (£25+£25+£25), got %d", calls[2].Amount)
}
if n := mock.RefundKeyCount(); n != 2 {
t.Errorf("expected Square to have issued exactly 2 distinct refunds, got %d", n)
}
// All three rows resolve to completed; the new row is completed against the
// NEW refund, never the old smaller one.
var newRefundID string
var newStatus string
if err := db.Conn.QueryRow(freshCtx, `SELECT status, square_refund_id FROM refunds WHERE id = $1`, row3).Scan(&newStatus, &newRefundID); err != nil {
t.Fatalf("failed to query third refund: %v", err)
}
if newStatus != "completed" {
t.Errorf("expected third refund status 'completed', got %q", newStatus)
}
if newRefundID == "" || newRefundID == oldRefundID {
t.Errorf("expected third refund completed against the NEW refund (not the old %q), got %q", oldRefundID, newRefundID)
}
var completedCount int
if err := db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM refunds WHERE id = ANY($1) AND status = 'completed'`, []string{row1, row2, row3}).Scan(&completedCount); err != nil {
t.Fatalf("failed to count completed refunds: %v", err)
}
if completedCount != 3 {
t.Errorf("expected all 3 refund rows completed, got %d", completedCount)
}
}
// =============================================================================
// Bug fix — NULL-key manual refund rows are keyed ONCE before re-issue
// =============================================================================
// TestSweepManualRetry_NullKey_SingleSquareRefund verifies the idempotency-key
// bug fix for the manual-refund sweep loop: a manual refund row with a NULL
// idempotency_key (legacy) gets a fallback key PERSISTED to the row before the
// Square call, so a retry after the CRITICAL-log path (Square committed, DB
// UPDATE to 'completed' failed, row still 'pending') reuses the SAME key and
// Square issues exactly ONE refund (the mock dedups same-key retries). The old
// code generated a fresh random suffix per resume → a second Square refund.
func TestSweepManualRetry_NullKey_SingleSquareRefund(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50.00, "online_square", "full", "completed")
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = 'sqp_null_key_manual' WHERE id = $1", paymentID); err != nil {
t.Fatalf("failed to set square_payment_id: %v", err)
}
// A legacy pending manual refund row with NO idempotency_key column value →
// NULL (the root source of the bug: the next sweep's refundResumeKey would
// generate a fresh random suffix per resume).
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, origin, created_at)
VALUES ($1, $2, 50, 'pending', 'customer request', 'manual', NOW())
RETURNING id
`, paymentID, bookingID).Scan(&refundID)
if err != nil {
t.Fatalf("failed to insert legacy NULL-key manual pending refund: %v", err)
}
pgxTx := db.TxFromContext(ctx)
if pgxTx == nil {
t.Fatal("no transaction in context")
}
if err := pgxTx.Commit(ctx); err != nil {
t.Fatalf("failed to commit test tx: %v", err)
}
freshCtx := context.Background()
t.Cleanup(func() {
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM refunds WHERE id = $1`, refundID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, paymentID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID)
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID)
})
origClient := SquareClient
mock := square.NewDevClient().(*square.MockClient)
counting := &countingRefundClient{SquareClient: mock}
SquareClient = counting
defer func() { SquareClient = origClient }()
runSweep := func() {
t.Helper()
n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}})
if err != nil {
t.Fatalf("processManualPaymentGroup failed: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 manual refund processed, got %d", n)
}
}
// Run 1: ensureRefundKey generates a fallback key, PERSISTS it to the row,
// and Square issues refund R1 under that key.
runSweep()
calls := counting.refundCalls()
if len(calls) != 1 {
t.Fatalf("expected exactly 1 Square refund call, got %d", len(calls))
}
key1 := calls[0].IdempotencyKey
if key1 == "" {
t.Fatal("expected a NON-EMPTY idempotency key (Square rejects empty keys with 400 INVALID_REQUEST_ERROR)")
}
if len(key1) > 45 {
t.Errorf("expected key within Square's 45-char limit, got %d chars: %q", len(key1), key1)
}
var storedKey string
if err := db.Conn.QueryRow(freshCtx, `SELECT idempotency_key FROM refunds WHERE id = $1`, refundID).Scan(&storedKey); err != nil {
t.Fatalf("failed to read stored key: %v", err)
}
if storedKey != key1 {
t.Errorf("expected the generated key %q persisted to the row, got %q", key1, storedKey)
}
// Simulate the CRITICAL-log path: the Square refund committed but the DB
// UPDATE to 'completed' failed → row back to 'pending', square_refund_id NULL.
if _, err := db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', square_refund_id = NULL WHERE id = $1`, refundID); err != nil {
t.Fatalf("failed to reset refund row pending: %v", err)
}
// Run 2: the retry reuses the PERSISTED key → Square dedups → the SAME
// single refund is returned; NO second refund is ever issued.
runSweep()
calls = counting.refundCalls()
if len(calls) != 2 {
t.Fatalf("expected 2 Square refund calls across both runs, got %d", len(calls))
}
if calls[1].IdempotencyKey != key1 {
t.Errorf("expected the retry to reuse the persisted key %q, got %q", key1, calls[1].IdempotencyKey)
}
if n := mock.RefundKeyCount(); n != 1 {
t.Errorf("expected exactly ONE distinct Square refund issued (dedup), got %d", n)
}
var keyAfter string
if err := db.Conn.QueryRow(freshCtx, `SELECT idempotency_key FROM refunds WHERE id = $1`, refundID).Scan(&keyAfter); err != nil {
t.Fatalf("failed to read stored key: %v", err)
}
if keyAfter != key1 {
t.Errorf("expected the row's key unchanged across both runs (%q), got %q", key1, keyAfter)
}
var status string
if err := db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status); err != nil {
t.Fatalf("failed to read refund status: %v", err)
}
if status != "completed" {
t.Errorf("expected refund status 'completed', got %q", status)
}
}
+28
View File
@@ -749,14 +749,31 @@ func (s *PaymentService) EnsureSquareCustomerForSavedCard(ctx context.Context, s
// the DB and, on a first-save flow, re-run CreateCustomer).
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCustomerID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
var id string
// ON CONFLICT (user_id, square_card_id) DO NOTHING: resolveChargeSource
// runs CreateCardOnFile (deterministic sha256 key) + SaveCardForUser on a
// same-key retry of a save_card=true charge. Square returns the SAME ccof:
// id on the retry, so a plain INSERT would violate the per-user UNIQUE
// constraint (N-8). Upsert instead so the retry returns the existing row;
// a distinct card id is a brand-new row, never a mutation of another
// user's card.
err := db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (
user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, NOW())
ON CONFLICT (user_id, square_card_id) DO NOTHING
RETURNING id
`, userID, squareCardID, squareCustomerID, brand, last4, expMonth, expYear, fingerprint).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
if err := db.Conn.QueryRow(ctx, `
SELECT id FROM user_saved_cards
WHERE user_id = $1 AND square_card_id = $2 AND deleted_at IS NULL
`, userID, squareCardID).Scan(&id); err != nil {
return "", err
}
return id, nil
}
return "", err
}
return id, nil
@@ -790,4 +807,15 @@ func (s *PaymentService) GetCardByIDQuerier(ctx context.Context, q db.Querier, c
// customer. The durable record remains the user_saved_cards row.
var squareCustomerCache sync.Map
// InvalidateSquareCustomerCache drops a user's cached Square customer id,
// e.g. after GDPR erasure deletes the customer at Square and NULLs the DB
// columns. Without it, the process-local cache would keep serving the erased
// user's stale customer id on a later save-card flow — reusing a deleted
// Square customer id (fail-closed at Square, but incorrect) and letting the
// erased identity resurface in memory. Called by the erasure handlers
// (handlers/user, handlers/scheduling) after the local anonymization commits.
func InvalidateSquareCustomerCache(userID string) {
squareCustomerCache.Delete(userID)
}
var SquareClient square.SquareClient
+64 -14
View File
@@ -284,20 +284,41 @@ func reconcileStalePaymentAtSquare(ctx context.Context, table, squarePaymentID s
}
// squarePaymentErrorIsNotFound reports whether a GetPayment error proves the
// payment does not exist at Square. The structured Square error code is the
// primary check (square.ErrorCode); the message fallback also covers the dev
// mock (a plain "payment not found" error) and a non-JSON 404 response.
// payment does not exist at Square. The structured not-found check is primary
// (square.IsNotFound: NOT_FOUND code / HTTP 404 status / plain "HTTP 404" body);
// the message fallback covers only the dev mock's plain "payment not found"
// error, which carries no structured code.
func squarePaymentErrorIsNotFound(err error) bool {
if err == nil {
return false
}
if square.ErrorCode(err) == "NOT_FOUND" {
if square.IsNotFound(err) {
return true
}
// Any other structured Square error code is authoritative — never
// substring-match its message.
if square.ErrorCode(err) != "" {
return false
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "NOT_FOUND") ||
strings.Contains(msg, "NOT FOUND") ||
strings.Contains(msg, "HTTP 404")
strings.Contains(msg, "NOT FOUND")
}
// squareHasCode reports whether err carries a structured Square error code
// equal to any of codes. Errors without a structured code (the dev mock's
// plain errors) match nothing.
func squareHasCode(err error, codes ...string) bool {
if err == nil {
return false
}
code := square.ErrorCode(err)
for _, c := range codes {
if code == c {
return true
}
}
return false
}
// staleTerminalCheckoutAge is how old a still-pending terminal checkout must
@@ -538,13 +559,28 @@ func markTerminalCheckoutRowFailed(ctx context.Context, r staleTerminalCheckoutR
// checkout can never complete. Square's HTTP client returns ErrCheckoutPending
// for a still-live checkout and surfaces a definitively CANCELED status as a
// "square: checkout <id> is CANCELED (not COMPLETED)" error; an expired
// checkout returns a NOT_FOUND API error (the mock uses "checkout not found").
// Any other error (timeout, 5xx) leaves the money state ambiguous, so the
// checkout must stay in flight.
// checkout returns a structured NOT_FOUND API error (the mock uses a plain
// "checkout not found"). Any other error (timeout, 5xx) leaves the money state
// ambiguous, so the checkout must stay in flight.
//
// Structured codes are authoritative when present: NOT_FOUND (via
// square.IsNotFound) and an explicit CANCELED / CANCEL_REQUESTED code classify
// as terminal. The formatted-message match applies only to errors that carry
// no structured code — the dev mock's plain errors and the real client's
// client-side "is <status> (not COMPLETED)" error, which it synthesizes
// without a squareAPIError.
func isTerminalCheckoutError(err error) bool {
if err == nil || errors.Is(err, square.ErrCheckoutPending) {
return false
}
if square.IsNotFound(err) || squareHasCode(err, "CANCELED", "CANCEL_REQUESTED") {
return true
}
// Any other structured Square error code is authoritative — never
// substring-match its message.
if square.ErrorCode(err) != "" {
return false
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "CANCELED") ||
strings.Contains(msg, "CANCEL_REQUESTED") ||
@@ -555,15 +591,29 @@ func isTerminalCheckoutError(err error) bool {
// isCheckoutDefinitivelyDead reports whether a GetCheckout error PROVES the
// checkout can never complete — the condition under which a till sale's funded
// gift card may be clawed back. It is stricter than isTerminalCheckoutError:
// a message that only reports CANCEL_REQUESTED (Square does not promise
// non-completion in that state) is NOT definitive proof, so the charge may
// still land and the funding must stay put. Only an explicit CANCELED or
// NOT_FOUND status is definitive; a CANCEL_REQUESTED message counts only when
// it ALSO carries CANCELED.
// a CANCEL_REQUESTED-only classification (Square does not promise non-completion
// in that state) is NOT definitive proof, so the charge may still land and the
// funding must stay put. Only an explicit CANCELED or NOT_FOUND status is
// definitive; a CANCEL_REQUESTED message counts only when it ALSO carries
// CANCELED.
//
// Structured codes are authoritative when present: CANCELED and NOT_FOUND are
// definitive, a bare CANCEL_REQUESTED code is not. The formatted-message match
// applies only to errors that carry no structured code (dev mock plain errors,
// and the real client's client-side "is <status> (not COMPLETED)" error).
func isCheckoutDefinitivelyDead(err error) bool {
if err == nil || errors.Is(err, square.ErrCheckoutPending) {
return false
}
if code := square.ErrorCode(err); code != "" {
return code == "CANCELED" || code == "NOT_FOUND"
}
// Non-structured errors: an HTTP 404 / plain 404 body is definitive
// (IsNotFound); the message match covers the client-side CANCELED status
// error and the mock's plain not-found errors.
if square.IsNotFound(err) {
return true
}
msg := strings.ToUpper(err.Error())
if !strings.Contains(msg, "CANCELED") && !strings.Contains(msg, "NOT_FOUND") && !strings.Contains(msg, "NOT FOUND") {
return false
+25 -3
View File
@@ -73,13 +73,24 @@ var definitivePaymentDeclineCodes = []string{
// isDefinitiveChargeFailure reports whether a Square CreatePayment error is a
// definitive business rejection (declined/expired) rather than an ambiguous
// transport/server error. The real HTTP client formats declines as
// "square: POST /v2/payments: [CATEGORY/CODE] ...", so the code is matched
// against the uppercased error message.
// transport/server error. The real HTTP client surfaces declines as a
// structured squareAPIError carrying the Square error Code (and Category), so
// the classification matches those EXACTLY against definitivePaymentDeclineCodes
// — a Square message-wording change can never silently flip the
// definitive↔retryable decision that drives the gift-card funding clawback.
// Only errors that carry NO structured code (the dev mock's plain errors, or a
// non-JSON failure body) fall back to the legacy formatted-message match
// ("square: POST /v2/payments: [CATEGORY/CODE] ..."), which is the only signal
// available for them.
func isDefinitiveChargeFailure(err error) bool {
if err == nil {
return false
}
// The formatted message carries both [CATEGORY/CODE] and the legacy check
// matched either, so compare the Code AND the Category exactly.
if code := square.ErrorCode(err); code != "" {
return declineCodeListContains(code) || declineCodeListContains(square.ErrorCategory(err))
}
msg := strings.ToUpper(err.Error())
for _, code := range definitivePaymentDeclineCodes {
if strings.Contains(msg, code) {
@@ -89,6 +100,17 @@ func isDefinitiveChargeFailure(err error) bool {
return false
}
// declineCodeListContains reports whether s is exactly one of the definitive
// payment decline codes.
func declineCodeListContains(s string) bool {
for _, code := range definitivePaymentDeclineCodes {
if s == code {
return true
}
}
return false
}
// errTillSaleNotPending: the claim-first gating UPDATE matched zero rows, so
// the sale is no longer 'pending' and the gift card must be left untouched.
var errTillSaleNotPending = errors.New("till sale is not pending")
+39 -5
View File
@@ -416,6 +416,10 @@ func CleanupOldReservations(ctx context.Context) (int, error) {
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
// Active/pending bookings are excluded so the salon can still contact the guest.
func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
// Stale guests whose Square customers are deleted below — their
// process-local cache entries must be invalidated after the anonymization.
var staleGuestUserIDs []string
// Best-effort: disable stale-guests' saved cards at Square BEFORE the SQL
// below NULLs square_card_id, so those cards can't keep accepting ccof:
// charges after anonymization (GDPR erasure completeness). A Square failure
@@ -431,7 +435,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
// local anonymization must never be blocked by Square. Rows are
// selected with the same stale-guest predicate the users UPDATE uses.
rows, err := db.Conn.Query(ctx, `
SELECT usc.square_card_id, usc.square_customer_id
SELECT usc.user_id, usc.square_card_id, usc.square_customer_id
FROM user_saved_cards usc
JOIN users u ON u.id = usc.user_id
WHERE u.account_role = 'guest'
@@ -449,12 +453,17 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
// customer) are skipped.
customerSeen := map[string]bool{}
var customerIDs []string
userSeen := map[string]bool{}
for rows.Next() {
var cardID, customerID sql.NullString
if err := rows.Scan(&cardID, &customerID); err != nil {
var userID, cardID, customerID sql.NullString
if err := rows.Scan(&userID, &cardID, &customerID); err != nil {
log.Printf("Warning: Failed to scan stale-guest saved card: %v", err)
continue
}
if userID.Valid && userID.String != "" && !userSeen[userID.String] {
userSeen[userID.String] = true
staleGuestUserIDs = append(staleGuestUserIDs, userID.String)
}
if cardID.Valid && cardID.String != "" {
cardIDs = append(cardIDs, cardID.String)
}
@@ -607,7 +616,18 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
}
totalRows += int(tag.RowsAffected())
return totalRows, tx.Commit(ctx)
if err := tx.Commit(ctx); err != nil {
return 0, err
}
// The guests' Square customer ids were deleted above and the DB columns are
// now NULLed — drop their process-local cache entries so an erased guest's
// stale Square customer id cannot resurface on a later save-card flow.
for _, uid := range staleGuestUserIDs {
payments.InvalidateSquareCustomerCache(uid)
}
return totalRows, nil
}
func CleanupExpiredLoyaltyRedemptions(ctx context.Context) (int, error) {
@@ -1111,7 +1131,21 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) {
return 0, fmt.Errorf("failed to anonymize idle accounts: %w", err)
}
return len(accountsWithBalance) + len(accountsNoBalance), tx.Commit(ctx)
if err := tx.Commit(ctx); err != nil {
return 0, err
}
// The anonymize_user(unnest(...)) calls above erased these accounts — drop
// their process-local Square customer cache entries so a stale id cannot
// resurface for an erased user.
for _, acc := range accountsWithBalance {
payments.InvalidateSquareCustomerCache(acc.id)
}
for _, id := range accountsNoBalance {
payments.InvalidateSquareCustomerCache(id)
}
return len(accountsWithBalance) + len(accountsNoBalance), nil
}
// CleanupOldIdempotencyKeys clears idempotency keys from bookings, payments, and
@@ -3797,3 +3797,66 @@ func TestTimeBlockers_List_WithDateFilter_RecurringOnly(t *testing.T) {
t.Errorf("expected 'Only Recurring', got %s", response[0].Description)
}
}
// TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache verifies the
// stale-guest anonymization drops each erased guest's process-local Square
// customer cache entry (GDPR erasure completeness): after the guest is
// anonymized (email → anon-{id}@anon.invalid, square_customer_id NULLed), a
// later save-card flow must re-mint a fresh Square customer instead of reusing
// the deleted one's stale cached id. Deliberately NOT t.Parallel: it swaps the
// package-level payments.SquareClient.
func TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache(t *testing.T) {
ctx, tx := resetTestData(t)
guestID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID); err != nil {
t.Fatalf("failed to set guest role: %v", err)
}
// Stale booking (> 6 months) so the stale-guest predicate matches.
if _, err := tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
`, guestID); err != nil {
t.Fatalf("failed to create stale booking: %v", err)
}
// A saved card so the provisioning path has a persistence point.
if _, err := tx.Exec(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
VALUES ($1, 'ccof:cache_stale_card', 'Visa', '4242', 12, 2030, 'fp1', true)
`, guestID); err != nil {
t.Fatalf("failed to insert saved card: %v", err)
}
origSquare := payments.SquareClient
payments.SquareClient = square.NewDevClient()
defer func() { payments.SquareClient = origSquare }()
t.Cleanup(func() { payments.InvalidateSquareCustomerCache(guestID) })
svc := payments.NewPaymentService()
originalID, err := svc.EnsureSquareCustomer(ctx, guestID)
if err != nil {
t.Fatalf("failed to provision Square customer: %v", err)
}
if originalID == "" {
t.Fatal("expected a provisioned Square customer id")
}
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
}
// The guest is anonymized and the cache must have been invalidated:
// re-provisioning re-queries the NULLed DB and mints a fresh customer from
// the anonymized email — a different id. Without the invalidation it would
// return the stale originalID for the erased guest.
reprovisioned, err := svc.EnsureSquareCustomer(ctx, guestID)
if err != nil {
t.Fatalf("failed to re-provision Square customer: %v", err)
}
if reprovisioned == originalID {
t.Errorf("anonymized stale guest must not reuse the deleted Square customer id %q from the cache", originalID)
}
}
+6
View File
@@ -167,6 +167,12 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
// TODO: Create 'user_anonymized' notification for admin audit trail
}
// The local erasure committed: drop the user's cached Square customer id so
// the erased identity cannot resurface from the process-local cache on a
// later save-card flow (the Square customer is deleted below and the DB
// columns are NULLed, but the cache is never touched by either).
payments.InvalidateSquareCustomerCache(userID)
// external Square cleanup fires only after local anonymization/deletion
// commits, so a failed local tx leaves external state intact for retry.
if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) {
@@ -875,3 +875,53 @@ func TestDeleteAccount_SkipsSharedSquareCustomer(t *testing.T) {
require.True(t, bCustomerID.Valid && bCustomerID.String == "cus_shared_cross_user", "user B's row must keep the shared square_customer_id")
require.False(t, bDeletedAt.Valid, "user B's row must not be soft-deleted")
}
// =============================================================================
// DeleteAccountHandler — process-local Square customer cache invalidation
// =============================================================================
// TestDeleteAccount_InvalidatesSquareCustomerCache verifies the GDPR erasure
// flow drops the user's process-local Square customer cache entry: after
// DeleteAccountHandler anonymizes the account (NULLing square_customer_id on
// saved cards and deleting the customer at Square), a later save-card flow for
// the same (anonymized) user must re-mint a fresh Square customer instead of
// reusing the deleted one's stale cached id.
func TestDeleteAccount_InvalidatesSquareCustomerCache(t *testing.T) {
savedSquareClient := payments.SquareClient
payments.SquareClient = square.NewDevClient()
t.Cleanup(func() { payments.SquareClient = savedSquareClient })
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
require.NoError(t, err)
// A saved card gives the provisioning path a persistence point.
_, err = tx.Exec(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
VALUES ($1, 'ccof:cache_erasure_card', 'Visa', '4242', 12, 2030, 'fp1', true)
`, userID)
require.NoError(t, err)
t.Cleanup(func() { payments.InvalidateSquareCustomerCache(userID) })
svc := payments.NewPaymentService()
originalID, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.NotEmpty(t, originalID, "a Square customer must be provisioned and cached for the save-card user")
handler := http.HandlerFunc(DeleteAccountHandler)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(ctx, mw.UserIDKey, userID))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
// The account is anonymized (email → anon-{id}@anon.invalid) and
// square_customer_id is NULLed; the handler must also have invalidated the
// process-local cache. A subsequent ensureSquareCustomer therefore
// re-queries the NULLed DB and mints a fresh customer from the anonymized
// email — a different id. Without the invalidation it would return the
// stale originalID, resurrecting the erased identity in memory.
reprovisioned, err := svc.EnsureSquareCustomer(ctx, userID)
require.NoError(t, err)
require.NotEqual(t, originalID, reprovisioned, "an erased user must not reuse the deleted Square customer id from the cache")
}