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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user