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:
@@ -2,12 +2,15 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +24,183 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// --- 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()
|
||||
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
|
||||
`, squareErasureNotificationID(key))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/user/account
|
||||
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
@@ -70,45 +250,56 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}(profilePicURL.String)
|
||||
}
|
||||
|
||||
// Snapshot the Square card IDs AND customer IDs synchronously BEFORE the
|
||||
// SQL anonymization below NULLs square_card_id/square_customer_id, so the
|
||||
// background cleanup still has the external Square references it needs
|
||||
// (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 cardIDs []string
|
||||
customerSeen := map[string]bool{}
|
||||
var customerIDs []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 square_card_id, square_customer_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
|
||||
`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 {
|
||||
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
|
||||
} else {
|
||||
for rows.Next() {
|
||||
var cardID, customerID sql.NullString
|
||||
if err := rows.Scan(&cardID, &customerID); err != nil {
|
||||
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Warning: Row iteration error for user %s: %v", userID, err)
|
||||
}
|
||||
rows.Close()
|
||||
// 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 ---
|
||||
@@ -133,6 +324,18 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -163,6 +366,18 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -178,11 +393,24 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// columns are NULLed, but the cache is never touched by either).
|
||||
payments.InvalidateSquareCustomerCache(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.
|
||||
if sqClient != nil && (len(cardIDs) > 0 || len(customerIDs) > 0) {
|
||||
// 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, cards, customers []string) {
|
||||
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)
|
||||
@@ -190,14 +418,44 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, cardID := range cards {
|
||||
if err := client.DeleteCardOnFile(ctx, cardID); err != nil {
|
||||
// TokenPrefix redacts the ccof: card token — the full ID must
|
||||
// never reach logs.
|
||||
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", square.TokenPrefix(cardID), userID, err)
|
||||
|
||||
cardRow := map[string]string{}
|
||||
for _, t := range targets {
|
||||
if t.cardID != "" {
|
||||
cardRow[t.cardID] = t.rowID
|
||||
}
|
||||
}
|
||||
for _, customerID := range customers {
|
||||
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
|
||||
@@ -209,19 +467,31 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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("Warning: Failed to check Square customer %s references before deletion: %v", square.TokenPrefix(customerID), err)
|
||||
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 := client.DeleteCustomer(ctx, customerID); err != nil {
|
||||
log.Printf("Warning: Failed to delete Square customer %s for user %s: %v", square.TokenPrefix(customerID), userID, err)
|
||||
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, cardIDs, customerIDs)
|
||||
}(sqClient, erasureTargets, notifyCtx)
|
||||
}
|
||||
|
||||
// Delete CardDAV contact (non-blocking, best-effort)
|
||||
|
||||
Reference in New Issue
Block a user