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