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