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
+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")
}