fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking

Money-safety:
- Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation
- Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged
- CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard)
- Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse
- Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID

GDPR / security:
- Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010
- square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2
- Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel
- Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit

Frontend:
- Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen)
- Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh
- Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy

S3:
- Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific)

Tests/docs:
- 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 9111258461
commit 78e6d00dc5
89 changed files with 7702 additions and 853 deletions
+518 -59
View File
@@ -2,18 +2,24 @@ package scheduling
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"time"
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/dav"
"crussell/internal/s3"
"crussell/internal/square"
"crussell/internal/validators"
"crussell/mw"
@@ -411,6 +417,315 @@ func CleanupOldReservations(ctx context.Context) (int, error) {
return int(tag.RowsAffected()), tx.Commit(ctx)
}
// --- Square erasure helpers (GDPR) ---
//
// The guest/idle batch cleanups snapshot the users' Square card and customer
// ids BEFORE anonymize_user NULLs square_card_id/square_customer_id, then
// delete the external Square resources AFTER the local erasure commits.
// Deletions are retried on transient failures (transport errors / Square 5xx)
// and, when they still fail, surfaced as a CRITICAL admin notification
// (reason 'critical_payment_log') plus an ERROR log — never silently dropped.
// A customer profile still referenced by another active user is skipped (see
// the guard in deleteSquareCustomers): Square dedups customers provisioned
// from the same email within its idempotency window, so two accounts can share
// one Square customer and deleting it would break the other's saved-card
// charges.
const (
// squareDeleteMaxAttempts is the number of times a Square erasure call is
// attempted before it is treated as failed (1 initial + 2 retries).
squareDeleteMaxAttempts = 3
// squareDeleteAttemptTimeout bounds a single Square erasure attempt so a
// hung call cannot stall the whole batch job.
squareDeleteAttemptTimeout = 30 * time.Second
// squareDeleteBackoffBase is the delay before the first retry (doubled per
// subsequent retry).
squareDeleteBackoffBase = 500 * time.Millisecond
)
// squareDeletionRetryable reports whether a Square erasure error is transient
// and worth retrying: transport errors (wrapped *url.Error) and Square 5xx
// responses. Definitive 4xx rejections and not-found no-ops are NOT retried.
func squareDeletionRetryable(err error) bool {
if square.IsNotFound(err) {
return false
}
if sc := square.ErrorStatusCode(err); sc != 0 {
return sc >= http.StatusInternalServerError
}
var urlErr *url.Error
return errors.As(err, &urlErr)
}
// retrySquareDeletion runs fn (a DeleteCardOnFile/DeleteCustomer call) up to
// squareDeleteMaxAttempts times, backing off between retries, retrying only
// transient failures (see squareDeletionRetryable). Returns the final error,
// nil on success.
func retrySquareDeletion(parent context.Context, fn func(context.Context) error) error {
var lastErr error
backoff := squareDeleteBackoffBase
for attempt := 1; attempt <= squareDeleteMaxAttempts; attempt++ {
if attempt > 1 {
select {
case <-time.After(backoff):
case <-parent.Done():
return lastErr
}
backoff *= 2
}
attemptCtx, cancel := context.WithTimeout(parent, squareDeleteAttemptTimeout)
err := fn(attemptCtx)
cancel()
if err == nil {
return nil
}
lastErr = err
if !squareDeletionRetryable(err) {
return err
}
}
return lastErr
}
// squareCleanupNotificationID derives a deterministic admin_notifications id
// for a failed Square-side erasure tied to userID ("S" + 11 hex chars of a
// SHA-256 digest, mirroring the webhooks dispute-notification id scheme). One
// notification per affected user; re-delivery of the same failure is a no-op.
func squareCleanupNotificationID(userID string) string {
sum := sha256.Sum256([]byte("square-erasure-failure:" + userID))
return "S" + hex.EncodeToString(sum[:])[:11]
}
// insertSquareCleanupCriticalNotification surfaces a failed Square-side
// erasure in the admin notification centre (reason 'critical_payment_log' —
// the DB-backed stand-in for CRITICAL logs that the payments sweep uses).
// user_id is deliberately NULL: by the time the deletion runs, the account may
// already be anonymized or deleted, and the deterministic id keeps exactly one
// notification per affected user. A fresh bounded context is used so a
// near-expiry job context cannot suppress the admin alert.
func insertSquareCleanupCriticalNotification(ctx context.Context, userID string) {
actx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
tag, err := db.Conn.Exec(actx, `
INSERT INTO admin_notifications (id, reason, booking_id, user_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NULL, NOW())
ON CONFLICT (id) DO NOTHING
`, squareCleanupNotificationID(userID))
if err != nil {
slog.Error("failed to insert critical notification for failed Square erasure", "user", userID, "err", err)
return
}
if int(tag.RowsAffected()) > 0 {
slog.Error("Square-side erasure FAILED after retries — critical notification raised", "user", userID)
}
}
// deleteSquareCards deletes each card at Square, retrying transient failures.
// A card that fails after all attempts is logged at ERROR and surfaced via a
// critical admin notification for its owning user.
func deleteSquareCards(ctx context.Context, client square.SquareClient, cardsByUser map[string][]string) {
for userID, cardIDs := range cardsByUser {
for _, cardID := range cardIDs {
if err := retrySquareDeletion(ctx, func(actx context.Context) error {
return client.DeleteCardOnFile(actx, cardID)
}); err != nil {
// TokenPrefix redacts the ccof: card token — the full ID must
// never reach logs.
log.Printf("Error: Failed to delete Square card %s for user %s after %d attempts: %v", square.TokenPrefix(cardID), userID, squareDeleteMaxAttempts, err)
slog.Error("failed to delete Square card after retries", "user", userID, "card", square.TokenPrefix(cardID), "err", err)
insertSquareCleanupCriticalNotification(ctx, userID)
}
}
}
}
// deleteSquareCustomers deletes each distinct Square customer profile once,
// guarded by the shared-reference check: a customer still referenced by another
// active user's saved card is skipped, never deleted. Customers are retried on
// transient failures; a failure after all attempts is logged at ERROR and
// surfaced via a critical admin notification for the owning user.
func deleteSquareCustomers(ctx context.Context, client square.SquareClient, customers map[string]string) {
// customers maps square_customer_id -> owning user_id (first owner).
for customerID, owner := range customers {
var stillReferenced bool
if err := db.Conn.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM user_saved_cards WHERE square_customer_id = $1 AND user_id <> $2 AND deleted_at IS NULL)
`, customerID, owner).Scan(&stillReferenced); err != nil {
log.Printf("Error: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err)
slog.Error("failed to check Square customer references before deletion", "user", owner, "customer", square.TokenPrefix(customerID), "err", err)
insertSquareCleanupCriticalNotification(ctx, owner)
continue
}
if stillReferenced {
// PII-redacted customer id — the full id never reaches logs.
log.Printf("Warning: skipping Square customer deletion — customer %s still referenced by another account", square.TokenPrefix(customerID))
continue
}
if err := retrySquareDeletion(ctx, func(actx context.Context) error {
return client.DeleteCustomer(actx, customerID)
}); err != nil {
log.Printf("Error: Failed to delete Square customer %s for user %s after %d attempts: %v", square.TokenPrefix(customerID), owner, squareDeleteMaxAttempts, err)
slog.Error("failed to delete Square customer after retries", "user", owner, "customer", square.TokenPrefix(customerID), "err", err)
insertSquareCleanupCriticalNotification(ctx, owner)
}
}
}
// snapshotSquareErasureTargets captures the Square card and customer ids for
// the given users BEFORE anonymize_user NULLs them, so the post-commit Square
// cleanup still has the external references it needs (GDPR erasure
// completeness). Fail-closed: an error aborts the cleanup so the local erasure
// never proceeds without the external refs it requires.
func snapshotSquareErasureTargets(ctx context.Context, q db.Querier, userIDs []string) (cardsByUser map[string][]string, customers map[string]string, err error) {
cardsByUser = map[string][]string{}
customers = map[string]string{}
if len(userIDs) == 0 {
return cardsByUser, customers, nil
}
rows, err := q.Query(ctx, `
SELECT user_id, square_card_id, square_customer_id
FROM user_saved_cards
WHERE user_id = ANY($1)
AND deleted_at IS NULL
`, userIDs)
if err != nil {
return nil, nil, fmt.Errorf("failed to snapshot Square card ids for %d users: %w", len(userIDs), err)
}
defer rows.Close()
for rows.Next() {
var userID, cardID, customerID sql.NullString
if err := rows.Scan(&userID, &cardID, &customerID); err != nil {
return nil, nil, fmt.Errorf("failed to scan Square card id snapshot: %w", err)
}
if !userID.Valid || userID.String == "" {
continue
}
if cardID.Valid && cardID.String != "" {
cardsByUser[userID.String] = append(cardsByUser[userID.String], cardID.String)
}
// Distinct non-null customer IDs only: a user's saved cards share one
// provisioned Square customer, so DeleteCustomer runs once per customer.
// NULL customer IDs (users with no provisioned customer) are skipped.
if customerID.Valid && customerID.String != "" {
if _, seen := customers[customerID.String]; !seen {
customers[customerID.String] = userID.String
}
}
}
if err := rows.Err(); err != nil {
return nil, nil, fmt.Errorf("row iteration error snapshotting Square card ids: %w", err)
}
return cardsByUser, customers, nil
}
// stillStaleGuest reports whether userID is STILL a stale guest eligible for
// Square-side erasure: no active (pending/confirmed) booking. It closes the
// ToCTOU window in AnonymizeStaleGuestAccounts where a guest could book again
// between the Square-id snapshot (taken before the anonymize tx) and the
// post-commit Square deletion — the users UPDATE predicate correctly skips such
// a guest's local erasure, so their Square references must be spared too.
// checked memoizes per-user results across the batch (a guest's saved cards
// share one Square customer, so the same owner is re-queried at most once).
func stillStaleGuest(ctx context.Context, userID string, checked map[string]bool) bool {
if stale, seen := checked[userID]; seen {
return stale
}
var active bool
if err := db.Conn.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM bookings
WHERE user_id = $1 AND status IN ('pending', 'confirmed')
)
`, userID).Scan(&active); err != nil {
// Fail toward retention: an unverifiable user's Square refs are never
// deleted — the local erasure predicate already skipped them if they
// re-booked, so deleting Square-side would strand the guest's payment
// methods. Raise the same critical notification the deletion path uses.
log.Printf("Error: failed to re-check stale-guest eligibility for user %s before Square deletion: %v", userID, err)
slog.Error("failed to re-check stale-guest eligibility before Square deletion — skipping", "user", userID, "err", err)
insertSquareCleanupCriticalNotification(ctx, userID)
checked[userID] = false
return false
}
if active {
log.Printf("Warning: skipping Square erasure for user %s — guest re-booked after anonymization snapshot", userID)
}
checked[userID] = !active
return !active
}
// recheckStaleGuestSquareTargets filters the post-commit Square deletion maps
// down to owners verified to STILL be stale guests, closing the snapshot→delete
// ToCTOU in AnonymizeStaleGuestAccounts: a guest who books/pays after the
// Square-id snapshot but before the anonymize tx is excluded by the users
// UPDATE predicate (they keep their active booking) but would otherwise still
// have their cards/customer deleted from the stale snapshot. Owners whose
// re-check errors are dropped too (see stillStaleGuest). The downstream
// shared-reference guard in deleteSquareCustomers still runs, so a customer
// referenced by any other active account is never deleted.
func recheckStaleGuestSquareTargets(ctx context.Context, cardsByUser map[string][]string, customers map[string]string) (map[string][]string, map[string]string) {
stillStaleCards := map[string][]string{}
stillStaleCustomers := map[string]string{}
checked := map[string]bool{}
for userID, cardIDs := range cardsByUser {
if stillStaleGuest(ctx, userID, checked) {
stillStaleCards[userID] = cardIDs
}
}
for customerID, owner := range customers {
if stillStaleGuest(ctx, owner, checked) {
stillStaleCustomers[customerID] = owner
}
}
return stillStaleCards, stillStaleCustomers
}
// deleteExternalUserArtifacts best-effort deletes the erased user's CardDAV
// vCard and R2/S3 profile photo — the external PII artifacts
// DeleteAccountHandler scrubs on interactive account deletion. Both are
// personal data the SQL erasure does not reach: the vCard lives in the dav
// service's own dav_cards table and the photo is an object in object storage,
// so batch erasure must delete them explicitly (GDPR Art 17). Mirrors
// account.go's call pattern and nil-guards: both services may be nil in dev,
// and each call is wrapped in panic recovery so a nil-pool dev service cannot
// crash the cleanup job.
func deleteExternalUserArtifacts(ctx context.Context, userID string) {
// Delete profile picture from S3/R2
if s3.Client != nil {
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in S3 profile picture deletion: %v", r)
}
}()
bucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
if bucket == "" {
bucket = "crussell-profile-pics"
}
// profiles/{userID}.jpg — matches UploadProfilePictureHandler key format
key := fmt.Sprintf("profiles/%s.jpg", userID)
if err := s3.Client.Delete(ctx, bucket, key); err != nil {
log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err)
}
}()
}
// Delete CardDAV contact (non-blocking, best-effort)
if dav.Service != nil {
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in CardDAV contact deletion: %v", r)
}
}()
uri := fmt.Sprintf("%s.vcf", userID)
if err := dav.Service.DeleteContact(1, uri); err != nil {
log.Printf("Warning: Failed to delete CardDAV contact for user %s: %v", userID, err)
}
}()
}
}
// AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts
// whose last booking was more than 6 months ago (UK GDPR storage limitation).
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
@@ -420,20 +735,20 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
// 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
// is logged and ignored — the local anonymization must never be blocked by
// Square. Card IDs are selected with the same stale-guest predicate the
// users UPDATE uses, and only when a Square client is configured.
// Snapshot the stale-guests' saved cards AND their Square customer IDs
// BEFORE the SQL below NULLs square_card_id/square_customer_id, so the
// post-commit Square cleanup still has the external references (GDPR
// erasure completeness: the local scrub must never strand PII at Square).
// Rows are selected with the same stale-guest predicate the users UPDATE
// uses, and only when a Square client is configured. The snapshot is a
// local SELECT — fail-closed: if it fails the cleanup aborts so the local
// erasure never proceeds without the external refs it needs. (The Square
// deletion calls themselves never block the local erasure.)
var cardsByUser map[string][]string
var customers map[string]string
if payments.SquareClient != nil {
// Snapshot the stale-guests' saved cards AND their Square customer IDs
// BEFORE the SQL below NULLs square_card_id/square_customer_id, so the
// external Square references are still available for cleanup (GDPR
// erasure completeness: the local scrub must never strand PII at
// Square). Best-effort: a Square failure is logged and ignored — the
// local anonymization must never be blocked by Square. Rows are
// selected with the same stale-guest predicate the users UPDATE uses.
cardsByUser = map[string][]string{}
customers = map[string]string{}
rows, err := db.Conn.Query(ctx, `
SELECT usc.user_id, usc.square_card_id, usc.square_customer_id
FROM user_saved_cards usc
@@ -444,59 +759,36 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
AND usc.square_card_id IS NOT NULL
`)
if err != nil {
log.Printf("Warning: Failed to query stale-guest saved cards for Square cleanup: %v", err)
} else {
var cardIDs []string
return 0, fmt.Errorf("failed to snapshot stale-guest saved cards for Square cleanup: %w", err)
}
userSeen := map[string]bool{}
for rows.Next() {
var userID, cardID, customerID sql.NullString
if err := rows.Scan(&userID, &cardID, &customerID); err != nil {
rows.Close()
return 0, fmt.Errorf("failed to scan stale-guest saved card: %w", err)
}
if userID.Valid && userID.String != "" && !userSeen[userID.String] {
userSeen[userID.String] = true
staleGuestUserIDs = append(staleGuestUserIDs, userID.String)
}
if userID.Valid && cardID.Valid && cardID.String != "" {
cardsByUser[userID.String] = append(cardsByUser[userID.String], cardID.String)
}
// Distinct non-null customer IDs only: a guest's saved cards share
// one provisioned Square customer, so DeleteCustomer runs once per
// customer. NULL customer IDs (guests with no provisioned Square
// customer) are skipped.
customerSeen := map[string]bool{}
var customerIDs []string
userSeen := map[string]bool{}
for rows.Next() {
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)
}
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
customerSeen[customerID.String] = true
customerIDs = append(customerIDs, customerID.String)
}
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("Warning: Row iteration error querying stale-guest saved cards: %v", err)
}
for _, cardID := range cardIDs {
if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil {
// TokenPrefix redacts the ccof: card token — the full ID
// must never reach logs.
log.Printf("Warning: Failed to disable stale-guest Square card %s at Square: %v", square.TokenPrefix(cardID), err)
}
}
// GDPR erasure completeness: the guest's Square customer profile
// holds their real name + email PII. Disabling the saved cards and
// NULLing square_customer_id locally is NOT enough — the Square
// customer profile must be deleted too, or the PII persists at
// Square indefinitely after anonymization. Distinct IDs only, so a
// guest with multiple cards on one customer triggers one delete.
for _, customerID := range customerIDs {
if err := payments.SquareClient.DeleteCustomer(ctx, customerID); err != nil {
// TokenPrefix redacts the customer ID — the full ID must
// never reach logs.
log.Printf("Warning: Failed to delete stale-guest Square customer %s at Square: %v", square.TokenPrefix(customerID), err)
if customerID.Valid && customerID.String != "" {
if _, seen := customers[customerID.String]; !seen {
customers[customerID.String] = userID.String
}
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("row iteration error snapshotting stale-guest saved cards: %w", err)
}
}
tx, err := db.Conn.Begin(ctx)
@@ -616,10 +908,105 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
}
totalRows += int(tag.RowsAffected())
// Scrub Square CreatePayment request snapshots (payments / till_sales):
// the stored replay JSON embeds the guest's email as BuyerEmail (PII, GDPR
// Art 17 / Art 5(1)(e)). The financial rows MUST survive the 7-year
// retention period, so only the snapshot is NULLed — the sweep rebuilds a
// minimal replay body when the snapshot is missing, keeping reconciliation
// money-safe. Scope mirrors delete_guest_user(): payments the guest
// initiated (created_by) or charged against the guest's bookings, and
// till_sales where the guest is the customer (user_id), narrowed to the
// guests this run just anonymized.
tag, err = tx.Exec(ctx, `
UPDATE payments
SET square_request_snapshot = NULL
WHERE created_by IN (
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
)
OR booking_id IN (
SELECT id FROM bookings
WHERE user_id IN (
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
)
)
`)
if err != nil {
return 0, err
}
totalRows += int(tag.RowsAffected())
tag, err = tx.Exec(ctx, `
UPDATE till_sales
SET square_request_snapshot = NULL
WHERE user_id IN (
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
)
`)
if err != nil {
return 0, err
}
totalRows += int(tag.RowsAffected())
// Capture the ids of the stale guests this run erased (the 'Anonymized'
// marker is set only by the users UPDATE above) so the post-commit CardDAV
// vCard / S3 profile-photo scrubs cover exactly the erased guests.
// Already-anonymized guests from a re-run may re-appear here — their
// external-artifact deletes are no-ops.
rows, err := tx.Query(ctx, `
SELECT id FROM users
WHERE account_role = 'guest'
AND n_first_name = 'Guest'
AND n_last_name = 'Anonymized'
`)
if err != nil {
return 0, fmt.Errorf("failed to query anonymized stale guests: %w", err)
}
var anonymizedGuestIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, fmt.Errorf("failed to scan anonymized stale guest id: %w", err)
}
anonymizedGuestIDs = append(anonymizedGuestIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("row iteration error querying anonymized stale guests: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
// After the local erasure commits: delete the guests' cards and Square
// customer profiles (real name + email PII) at Square. Retried on transient
// failures; a failure after all attempts raises a critical admin
// notification + ERROR log — never silently dropped. Distinct customer IDs
// only, so a guest with multiple cards on one customer triggers one delete,
// and the shared-reference guard skips a customer still referenced by
// another (active) account.
if payments.SquareClient != nil {
// ToCTOU guard: the Square-id snapshot above was taken BEFORE the
// anonymize tx, so a guest who re-booked in that window was excluded by
// the users UPDATE predicate (they keep their active booking) but would
// otherwise still be erased at Square from the stale snapshot. Re-verify
// each snapshot owner is still stale (no active/pending booking) and
// drop owners who are not — their local erasure never ran either.
stillStaleCards, stillStaleCustomers := recheckStaleGuestSquareTargets(ctx, cardsByUser, customers)
deleteSquareCards(ctx, payments.SquareClient, stillStaleCards)
deleteSquareCustomers(ctx, payments.SquareClient, stillStaleCustomers)
}
// 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.
@@ -627,6 +1014,14 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
payments.InvalidateSquareCustomerCache(uid)
}
// Delete the erased guests' CardDAV vCards and R2/S3 profile photos — the
// same external PII artifacts DeleteAccountHandler scrubs. These live
// outside the SQL rows the tx above anonymized, so batch erasure must
// delete them explicitly (best-effort; both services may be nil in dev).
for _, uid := range anonymizedGuestIDs {
deleteExternalUserArtifacts(ctx, uid)
}
return totalRows, nil
}
@@ -1080,6 +1475,24 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) {
accountsWithBalance = append(accountsWithBalance, acc)
}
// Snapshot the with-balance accounts' Square card/customer ids BEFORE
// anonymize_user(unnest(...)) below NULLs them, so the post-commit Square
// cleanup still has the external references (GDPR erasure completeness).
// Fail-closed: the whole batch aborts if the snapshot fails, so the local
// erasure never proceeds without the external refs it needs.
var cardsByUser map[string][]string
var customers map[string]string
if payments.SquareClient != nil {
ids := make([]string, len(accountsWithBalance))
for i, a := range accountsWithBalance {
ids[i] = a.id
}
cardsByUser, customers, err = snapshotSquareErasureTargets(ctx, tx, ids)
if err != nil {
return 0, err
}
}
if len(accountsWithBalance) > 0 {
ids := make([]string, len(accountsWithBalance))
balances := make([]float64, len(accountsWithBalance))
@@ -1135,6 +1548,32 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) {
}
rowsNoBalance.Close()
// Snapshot the no-balance accounts' Square card/customer ids BEFORE their
// anonymize_user(unnest(...)) below NULLs them, merging into the same maps.
// Fail-closed: the whole batch aborts if the snapshot fails.
if payments.SquareClient != nil {
var cardsNoBalance map[string][]string
var customersNoBalance map[string]string
cardsNoBalance, customersNoBalance, err = snapshotSquareErasureTargets(ctx, tx, accountsNoBalance)
if err != nil {
return 0, err
}
if cardsByUser == nil {
cardsByUser = map[string][]string{}
}
if customers == nil {
customers = map[string]string{}
}
for u, cs := range cardsNoBalance {
cardsByUser[u] = append(cardsByUser[u], cs...)
}
for c, owner := range customersNoBalance {
if _, seen := customers[c]; !seen {
customers[c] = owner
}
}
}
if _, err = tx.Exec(ctx, `
SELECT anonymize_user(unnest($1::text[]))
`, accountsNoBalance); err != nil {
@@ -1145,6 +1584,16 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) {
return 0, err
}
// After the local erasure commits: delete the erased accounts' cards and
// Square customer profiles (real name + email PII) at Square. Retried on
// transient failures; a failure after all attempts raises a critical admin
// notification + ERROR log — never silently dropped. The shared-reference
// guard skips a customer still referenced by another (active) account.
if payments.SquareClient != nil {
deleteSquareCards(ctx, payments.SquareClient, cardsByUser)
deleteSquareCustomers(ctx, payments.SquareClient, customers)
}
// 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.
@@ -1155,6 +1604,16 @@ func CleanupIdleAccounts(ctx context.Context) (int, error) {
payments.InvalidateSquareCustomerCache(id)
}
// Delete the erased accounts' CardDAV vCards and R2/S3 profile photos —
// the same external PII artifacts DeleteAccountHandler scrubs (best-effort;
// both services may be nil in dev).
for _, acc := range accountsWithBalance {
deleteExternalUserArtifacts(ctx, acc.id)
}
for _, id := range accountsNoBalance {
deleteExternalUserArtifacts(ctx, id)
}
return len(accountsWithBalance) + len(accountsNoBalance), nil
}