Files
Crussell/backend/handlers/user/account.go
T
popertots 4e398a7a2b fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
2026-08-22 00:34:50 +01:00

589 lines
25 KiB
Go

package user
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"time"
"crussell/auth"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/adminnotify"
"crussell/internal/dav"
"crussell/internal/s3"
"crussell/internal/square"
"crussell/internal/twofa"
"crussell/mw"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
// --- Square erasure retry + alerting + durable outbox (GDPR H3 / A1) ---
//
// The deletion goroutine in DeleteAccountHandler retries transient Square
// failures and, on final failure, logs at ERROR and raises a critical admin
// notification (reason 'critical_payment_log'). A failed Square-side erasure
// must never be silently dropped: by the time the goroutine runs, the local
// anonymization has already committed and NULLed square_card_id/
// square_customer_id, so the external Square references would otherwise be
// orphaned — permanently, with no way to retrace them.
//
// Fault A1 (crash window): the erasure targets are ALSO persisted as a durable
// outbox INSIDE the anonymization transaction (persistSquareErasureOutbox),
// before it commits. The just-scrubbed, soft-deleted user_saved_cards rows keep
// their square_card_id / square_customer_id (and lose their user_id, matching
// delete_guest_user) until the Square-side erasure completes. If the process
// dies between the local commit and the async goroutine finishing, those rows
// are the only remaining record of the external Square PII; the
// retry-square-erasures job (internal/jobs/cleanup.go) finds them and retries
// the deletion. The goroutine drains the outbox (NULLing the refs on success);
// the job is the crash safety net. Either the local tx rolls back (nothing
// NULLed) or the durable queue guarantees the Square deletion is eventually
// attempted and alerted on failure.
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.
squareDeleteAttemptTimeout = 30 * time.Second
// squareDeleteBackoffBase is the delay before the first retry (doubled per
// subsequent retry).
squareDeleteBackoffBase = 500 * time.Millisecond
)
// squareErasureTarget records one saved-card row's external Square references
// (row id + square_card_id + square_customer_id) captured BEFORE anonymization
// NULLs them, so the durable outbox and the async cleanup goroutine still have
// the external refs they need to complete Square-side erasure (Fault A1).
type squareErasureTarget struct {
rowID string
cardID string // empty when the row had no Square card on file
customerID string // empty when the row had no Square customer profile
}
// 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. Exported for the retry-square-erasures job
// (internal/jobs/cleanup.go), which reuses the same retry budget as the
// account-deletion goroutine.
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
}
// squareErasureNotificationID derives a deterministic admin_notifications id
// for a failed Square-side erasure tied to key ("S" + 11 hex chars of a
// SHA-256 digest, mirroring the webhooks dispute-notification id scheme). For
// the account-deletion path key is the affected user id; for the
// retry-square-erasures job key is the user id when the outbox row still
// carries one, otherwise the outbox row id. One notification per key;
// re-delivery of the same failure is a no-op.
func squareErasureNotificationID(key string) string {
sum := sha256.Sum256([]byte("square-erasure-failure:" + key))
return "S" + hex.EncodeToString(sum[:])[:11]
}
// InsertSquareErasureCriticalNotification 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 async cleanup runs, the account
// may already be deleted (guests) or anonymized, and the deterministic id keeps
// exactly one notification per affected key. Exported for the
// retry-square-erasures job, which reuses the account-deletion alert scheme.
func InsertSquareErasureCriticalNotification(ctx context.Context, key string) {
// Fresh bounded context so a near-expiry deletion context cannot suppress
// the admin alert.
actx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Round 2 Loop B finding 1: this is a 'critical_payment_log' insert site —
// the same atomic global cap as every other. The pre-check logs the
// suppression; the fold inside the INSERT enforces it atomically.
if adminnotify.CriticalLogsCapExceeded(actx, db.Conn, "critical_payment_log") {
slog.Error("failed to insert critical notification for failed Square erasure — unacknowledged 'critical_payment_log' queue at the cap", "key", key)
return
}
tag, err := db.Conn.Exec(actx, `
INSERT INTO admin_notifications (id, reason, booking_id, user_id, created_at)
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NULL, NOW()
WHERE (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $2
ON CONFLICT (id) DO NOTHING
`, squareErasureNotificationID(key), adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
slog.Error("failed to insert critical notification for failed Square erasure", "key", key, "err", err)
return
}
if int(tag.RowsAffected()) > 0 {
slog.Error("Square-side erasure FAILED after retries — critical notification raised", "key", key)
}
}
// persistSquareErasureOutbox writes the captured Square erasure targets back
// onto the just-scrubbed, soft-deleted user_saved_cards rows INSIDE the
// anonymization transaction, BEFORE it commits (Fault A1). anonymize_user() /
// delete_guest_user() NULL square_card_id/square_customer_id for GDPR
// scrubbing; this UPDATE restores them on the soft-deleted rows so the erasure
// targets survive the commit as a durable queue entry. user_id is also NULLed
// (delete_guest_user already does this for guests): payments.EnsureSquareCustomer
// reads square_customer_id across ALL of a user's rows regardless of
// deleted_at, so an outbox row that kept its user_id would let the erased
// identity's deleted Square customer resurface on a later save-card flow. The
// rows remain in place for the 7-year financial retention; only the user link
// is dropped. If the process crashes before the async Square cleanup finishes,
// the retry-square-erasures job (internal/jobs/cleanup.go) finds these rows and
// completes the external deletion — the erasure is never permanently lost.
func persistSquareErasureOutbox(ctx context.Context, tx pgx.Tx, targets []squareErasureTarget) error {
for _, t := range targets {
var cardID, customerID any
if t.cardID != "" {
cardID = t.cardID
}
if t.customerID != "" {
customerID = t.customerID
}
if _, err := tx.Exec(ctx, `
UPDATE user_saved_cards
SET square_card_id = $2, square_customer_id = $3, user_id = NULL
WHERE id = $1 AND deleted_at IS NOT NULL
`, t.rowID, cardID, customerID); err != nil {
return err
}
}
return nil
}
// clearSquareErasureOutboxRows NULLs the durable outbox marker on the given
// soft-deleted user_saved_cards rows after their Square-side erasure is
// complete (or deliberately abandoned as still-referenced). Best-effort: the
// retry-square-erasures job re-covers any row left behind by a failure.
func clearSquareErasureOutboxRows(ctx context.Context, rowIDs []string) {
for _, rowID := range rowIDs {
if _, err := db.Conn.Exec(ctx, `
UPDATE user_saved_cards SET square_customer_id = NULL
WHERE id = $1 AND deleted_at IS NOT NULL
`, rowID); err != nil {
slog.Error("failed to clear Square erasure outbox for customer", "row", rowID, "error", err)
}
}
}
// DeleteAccountRequest carries the re-verification credentials the handler now
// requires before erasing an account (finding 3): the current password (always)
// and, in enforced environments for a user with 2FA enabled, a fresh one-time
// verification code.
type DeleteAccountRequest struct {
CurrentPassword string `json:"current_password"`
VerificationCode string `json:"verification_code"`
}
// DELETE /api/user/account
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var accountRole string
var profilePicURL sql.NullString
var passwordHash sql.NullString
var twoFactorEnabled bool
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url, password_hash, two_factor_enabled FROM users WHERE id = $1`, userID).
Scan(&accountRole, &profilePicURL, &passwordHash, &twoFactorEnabled)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch user for deletion: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
ctx := r.Context()
// Finding 3: deleting an account is irreversible, so the session token alone
// must not be enough — an attacker who lifts a token (XSS, leaked localStorage)
// must not be able to erase the account. Re-verify the current password
// (mirroring ChangePasswordHandler's bcrypt compare) and, in enforced
// environments for a user with 2FA enabled, a fresh one-time code consumed by
// the shared core (twofa.VerifyForUser with ConsumeOnVerify).
var req DeleteAccountRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if passwordHash.Valid && passwordHash.String != "" {
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash.String), []byte(req.CurrentPassword)); err != nil {
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
return
}
}
if twoFARequired() && twoFactorEnabled {
if req.VerificationCode == "" {
http.Error(w, "a two-factor verification code is required to delete the account", http.StatusBadRequest)
return
}
switch err := twofa.VerifyForUser(ctx, userID, req.VerificationCode, twofa.ConsumeOnVerify); {
case err == nil:
// Verified: the code is consumed (single-use), matching the saved-card
// gate. If the deletion below then fails the user requests a fresh code.
case errors.Is(err, twofa.ErrIncorrect):
http.Error(w, "incorrect verification code", http.StatusBadRequest)
return
case errors.Is(err, twofa.ErrLockedOut):
http.Error(w, "Too many attempts. Request a new code.", http.StatusTooManyRequests)
return
case errors.Is(err, twofa.ErrMissingOrExpired):
http.Error(w, "verification code is missing or has expired", http.StatusBadRequest)
return
default:
log.Printf("failed to check 2FA pending code for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
}
// --- External system scrubbing (BEFORE SQL anonymize) ---
// Delete profile picture from S3/R2
if profilePicURL.Valid && profilePicURL.String != "" && s3.Client != nil {
// #nosec G118 — intentional background goroutine for async profile pic cleanup
go func(picURL string) {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in S3 profile picture deletion: %v", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
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)
}
}(profilePicURL.String)
}
// Snapshot the Square card AND customer references per saved-card row
// synchronously BEFORE the SQL anonymization below NULLs
// square_card_id/square_customer_id, so both the durable outbox and the
// background cleanup still have the external Square references they need
// (previously the goroutine read the rows itself, racing the anonymize step
// which wiped them mid-flight). 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
// saved cards) are skipped.
var erasureTargets []squareErasureTarget
needsSquareErasure := false
// Capture the client synchronously so the async cleanup goroutine never
// reads the global payments.SquareClient (which tests swap per-account).
sqClient := payments.SquareClient
if sqClient != nil {
rows, err := db.Conn.Query(r.Context(),
`SELECT id, square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
if err != nil {
// Fail-closed: anonymization must never proceed without the
// external Square refs the cleanup needs — otherwise the card and
// customer would be permanently orphaned at Square with the local
// ids already NULLed (GDPR erasure completeness).
log.Printf("Failed to query saved cards for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
for rows.Next() {
var t squareErasureTarget
var cardID, customerID sql.NullString
if err := rows.Scan(&t.rowID, &cardID, &customerID); err != nil {
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
continue
}
if cardID.Valid && cardID.String != "" {
t.cardID = cardID.String
}
if customerID.Valid && customerID.String != "" {
t.customerID = customerID.String
}
if t.cardID != "" || t.customerID != "" {
needsSquareErasure = true
}
erasureTargets = append(erasureTargets, t)
}
if err := rows.Err(); err != nil {
log.Printf("Failed to iterate saved cards for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
rows.Close()
}
// --- SQL-level anonymization/deletion ---
if accountRole == "guest" {
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction for guest user deletion: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
_, err = tx.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
if err != nil {
log.Printf("Failed to delete guest user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// A1 durable outbox: persist the Square erasure targets on the
// just-scrubbed, soft-deleted rows BEFORE this tx commits — if the
// process dies after the commit, the retry-square-erasures job still
// finds them.
if sqClient != nil && needsSquareErasure {
if err := persistSquareErasureOutbox(ctx, tx, erasureTargets); err != nil {
log.Printf("Failed to persist Square erasure outbox for guest %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction for guest user deletion: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
} else {
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction for user anonymization: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// anonymize_user() is the single source of truth for erasure: it NULLs
// the 2FA columns and staff notes in the same statement that anonymizes
// the rest of the row, so every call site (this handler AND the
// idle-account batch cleanup in scheduling.CleanupIdleAccounts) is
// GDPR-clean without a separate Go-side scrub.
_, err = tx.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
log.Printf("Failed to anonymize user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// A1 durable outbox: persist the Square erasure targets on the
// just-scrubbed, soft-deleted rows BEFORE this tx commits — if the
// process dies after the commit, the retry-square-erasures job still
// finds them.
if sqClient != nil && needsSquareErasure {
if err := persistSquareErasureOutbox(ctx, tx, erasureTargets); err != nil {
log.Printf("Failed to persist Square erasure outbox for user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(ctx); err != nil {
log.Printf("Failed to commit transaction for user anonymization: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// 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)
// Finding 5: anonymize_user()/delete_guest_user() deleted every refresh
// token the user held inside the committed transaction. Drop the in-memory
// family-alive verdicts for ALL of the user's rotation families so access
// tokens minted by those families die on their next verification instead of
// riding the 30s family-alive cache TTL.
auth.InvalidateFamilyAliveByUser(userID)
// Build the context used for critical-notification inserts: route through
// the request transaction when one is active (tests) so alerts roll back
// with the fixture; otherwise fall back to the shared pool (production,
// where the request context is cancelled once this handler returns).
// Captured synchronously for the goroutine below.
notifyCtx := context.Background()
if activeTx := db.TxFromContext(r.Context()); activeTx != nil {
notifyCtx = db.ContextWithTx(notifyCtx, activeTx)
}
// external Square cleanup fires only after local anonymization/deletion
// commits, so a failed local tx leaves external state intact for retry. The
// durable outbox persisted above guarantees the targets survive even a
// process crash before this goroutine finishes — this goroutine is the
// primary drain, and the retry-square-erasures job is the safety net.
if sqClient != nil && needsSquareErasure {
// #nosec G118 — intentional background goroutine for async account deletion
go func(client square.SquareClient, targets []squareErasureTarget, notifyCtx context.Context) {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in Square cleanup: %v", r)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cardRow := map[string]string{}
for _, t := range targets {
if t.cardID != "" {
cardRow[t.cardID] = t.rowID
}
}
for cardID, rowID := range cardRow {
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. A failed erasure is logged at ERROR and
// raised as a critical admin notification — never silently
// dropped after the local refs are NULLed.
log.Printf("Error: Failed to delete Square card %s for user %s after %d attempts: %v", square.TokenPrefix(cardID), userID, squareDeleteMaxAttempts, err)
slog.Error("square card deletion failed after retries", "user_id", userID, "card", square.TokenPrefix(cardID), "error", err)
InsertSquareErasureCriticalNotification(notifyCtx, userID)
continue
}
// Drain the durable outbox so the safety-net job has nothing to
// retry. Best-effort: a failed clear leaves the entry in place
// for the job, which treats NOT_FOUND as complete.
if _, err := db.Conn.Exec(ctx, `
UPDATE user_saved_cards SET square_card_id = NULL
WHERE id = $1 AND deleted_at IS NOT NULL
`, rowID); err != nil {
slog.Error("failed to clear Square erasure outbox for card", "user_id", userID, "card", square.TokenPrefix(cardID), "error", err)
}
}
customerRows := map[string][]string{}
for _, t := range targets {
if t.customerID != "" {
customerRows[t.customerID] = append(customerRows[t.customerID], t.rowID)
}
}
for customerID, rows := range customerRows {
// Square customers are provisioned per-user from a deterministic
// email-derived key, but the UNIQUE(email) index excludes guest
// accounts — so a guest and a registered user sharing an email can
// end up on the SAME Square customer profile (Square dedups within
// its idempotency window). Deleting it would break the other
// account's saved-card charges, so skip the deletion when any OTHER
// non-deleted saved card still references the customer.
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, userID).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_id", userID, "customer", square.TokenPrefix(customerID), "error", err)
InsertSquareErasureCriticalNotification(notifyCtx, userID)
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))
// The customer is deliberately kept (still referenced by
// another account) — not a pending erasure, so drain the
// outbox rows and let the job stop retrying it.
clearSquareErasureOutboxRows(ctx, rows)
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), userID, squareDeleteMaxAttempts, err)
slog.Error("square customer deletion failed after retries", "user_id", userID, "customer", square.TokenPrefix(customerID), "error", err)
InsertSquareErasureCriticalNotification(notifyCtx, userID)
continue
}
clearSquareErasureOutboxRows(ctx, rows)
}
}(sqClient, erasureTargets, notifyCtx)
}
// Delete CardDAV contact (non-blocking, best-effort)
if dav.Service != nil {
go 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)
}
}()
}
w.WriteHeader(http.StatusNoContent)
}