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/adminnotify" "crussell/internal/dav" "crussell/internal/s3" "crussell/internal/square" "crussell/internal/validators" "crussell/mw" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/robfig/cron/v3" ) // --- Types --- type TimeBlocker struct { ID string `json:"id"` StartTime time.Time `json:"start_time"` DurationMinutes int `json:"duration_minutes"` Description string `json:"description,omitempty"` CronExpression *string `json:"cron_expression,omitempty"` CreatedAt time.Time `json:"created_at"` CreatedBy *string `json:"created_by,omitempty"` } type CreateTimeBlockerRequest struct { StartTime time.Time `json:"start_time" validate:"required"` DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"` Description string `json:"description,omitempty" validate:"omitempty,max=500"` CronExpression *string `json:"cron_expression,omitempty" validate:"omitempty,max=500"` } // --- List Time Blockers --- // GET /api/admin/time-blockers // Returns: // - Future one-off blockers (cron_expression IS NULL AND start_time >= now) // - ALL recurring blockers (cron_expression IS NOT NULL) func ListTimeBlockers(w http.ResponseWriter, r *http.Request) { // Optional date range filtering startStr := r.URL.Query().Get("start") endStr := r.URL.Query().Get("end") var rows pgx.Rows var err error if startStr != "" && endStr != "" { // Filter by date range start, err1 := time.Parse("2006-01-02", startStr) end, err2 := time.Parse("2006-01-02", endStr) if err1 != nil || err2 != nil { http.Error(w, "invalid date format, expected YYYY-MM-DD", http.StatusBadRequest) return } start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation) end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation) // Get one-off blockers in range + ALL recurring blockers rows, err = db.Conn.Query(r.Context(), ` SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by FROM time_blockers WHERE (cron_expression IS NULL AND start_time >= $1 AND start_time <= $2) OR (cron_expression IS NOT NULL) ORDER BY CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END, start_time DESC `, start, end) } else { // Get future one-off blockers + ALL recurring blockers now := clock.Now() rows, err = db.Conn.Query(r.Context(), ` SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by FROM time_blockers WHERE (cron_expression IS NULL AND start_time >= $1) OR (cron_expression IS NOT NULL) ORDER BY CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END, start_time DESC LIMIT 100 `, now) } if err != nil { http.Error(w, "failed to fetch time blockers", http.StatusInternalServerError) return } defer rows.Close() var blockers []TimeBlocker for rows.Next() { var b TimeBlocker if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil { http.Error(w, "failed to scan time blocker", http.StatusInternalServerError) return } blockers = append(blockers, b) } if err := rows.Err(); err != nil { http.Error(w, "error iterating time blockers", http.StatusInternalServerError) return } if blockers == nil { blockers = []TimeBlocker{} } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(blockers); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // --- Create Time Blocker --- // POST /api/admin/time-blockers func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) { var req CreateTimeBlockerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", http.StatusBadRequest) return } // Validate required fields if req.StartTime.IsZero() { http.Error(w, "start_time is required", http.StatusBadRequest) return } if req.DurationMinutes <= 0 { http.Error(w, "duration_minutes must be greater than 0", http.StatusBadRequest) return } // Get admin user ID from context var createdBy *string if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok { createdBy = &userID } tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // Insert the time blocker var blocker TimeBlocker err = tx.QueryRow(r.Context(), ` INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) VALUES ($1, $2, $3, $4, $5) RETURNING id, start_time, duration_minutes, description, cron_expression, created_at, created_by `, req.StartTime, req.DurationMinutes, req.Description, req.CronExpression, createdBy).Scan( &blocker.ID, &blocker.StartTime, &blocker.DurationMinutes, &blocker.Description, &blocker.CronExpression, &blocker.CreatedAt, &blocker.CreatedBy, ) if err != nil { http.Error(w, "failed to create time blocker", http.StatusInternalServerError) return } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(blocker); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // --- Delete Time Blocker --- // DELETE /api/admin/time-blockers/{id} func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if id == "" || !validators.IsValidID(id) { http.Error(w, "time blocker not found", http.StatusNotFound) return } tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() result, err := tx.Exec(r.Context(), ` DELETE FROM time_blockers WHERE id = $1 `, id) if err != nil { http.Error(w, "failed to delete time blocker", http.StatusInternalServerError) return } rowsAffected := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "time blocker not found", http.StatusNotFound) return } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // --- Helper: Get Time Blockers in Range --- // --- Helper: Get Time Blockers in Range --- // Returns blockers for the given date range, expanded for recurring blockers. // If excludeUserID is non-nil, RESERVATION entries owned by that user are excluded. // Used by GetAvailableHours to subtract blocked time from available slots func GetTimeBlockersInRange(ctx context.Context, start, end time.Time, excludeUserID *string) ([]TimeBlocker, error) { rows, err := db.Conn.Query(ctx, ` SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by FROM time_blockers WHERE ((cron_expression IS NULL AND start_time >= $1 AND start_time <= $2) OR (cron_expression IS NOT NULL)) AND ($3::text IS NULL OR NOT (description LIKE 'RESERVATION:%' AND created_by = $3)) ORDER BY start_time `, start, end, excludeUserID) if err != nil { return nil, err } defer rows.Close() var blockers []TimeBlocker for rows.Next() { var b TimeBlocker if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil { return nil, err } if b.CronExpression != nil { occurrences := expandCronOccurrences(b, start, end) blockers = append(blockers, occurrences...) } else { blockers = append(blockers, b) } } if err := rows.Err(); err != nil { return nil, err } return blockers, nil } // expandCronOccurrences expands a recurring blocker to all occurrences within a date range // The cron expression defines the pattern, and the blocker's start_time provides the time-of-day func expandCronOccurrences(blocker TimeBlocker, rangeStart, rangeEnd time.Time) []TimeBlocker { if blocker.CronExpression == nil { return nil } parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) schedule, err := parser.Parse(*blocker.CronExpression) if err != nil { log.Printf("Invalid cron expression '%s': %v", *blocker.CronExpression, err) return nil } // Get the time-of-day from the blocker's start_time in London time, // so the recurrence fires at the same wall-clock time regardless of DST. blockerLondon := blocker.StartTime.In(londonLocation) blockerHour := blockerLondon.Hour() blockerMinute := blockerLondon.Minute() var occurrences []TimeBlocker // Start from the beginning of the range using London timezone, // ensuring the same wall-clock time applies year-round. current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, londonLocation) // Find the first occurrence on or after rangeStart firstNext := schedule.Next(current.Add(-time.Second)) if firstNext.Before(rangeStart) { current = schedule.Next(firstNext) } else { current = firstNext } // Collect all occurrences within the range for current.Before(rangeEnd) || current.Equal(rangeEnd) { // Create a new blocker instance for this occurrence occurrence := TimeBlocker{ ID: blocker.ID, StartTime: current, DurationMinutes: blocker.DurationMinutes, Description: blocker.Description, CronExpression: blocker.CronExpression, CreatedAt: blocker.CreatedAt, CreatedBy: blocker.CreatedBy, } occurrences = append(occurrences, occurrence) // Get next occurrence next := schedule.Next(current) if next.Equal(current) { break // Prevent infinite loop if schedule isn't advancing } current = next } return occurrences } // --- Helper: Check Time Blocker Overlap --- // Returns (hasOverlap, blockerDescription, error) // Used by booking handlers to check for blocker conflicts. // If excludeUserID is non-nil, RESERVATION entries owned by that user are skipped. func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time, excludeUserID *string) (bool, string, error) { // Get all blockers in an expanded range that could overlap // We need to look further back because recurring blockers could span multiple periods searchStart := startTime.AddDate(0, -1, 0) // Look back 1 month for recurring patterns searchEnd := endTime blockers, err := GetTimeBlockersInRange(ctx, searchStart, searchEnd, excludeUserID) if err != nil { return false, "", err } // Check each blocker (one-off or expanded recurring) for overlap for _, blocker := range blockers { blockerEnd := blocker.StartTime.Add(time.Duration(blocker.DurationMinutes) * time.Minute) // Check if the booking overlaps with the blocker // Overlap condition: booking_start < blocker_end AND booking_end > blocker_start if startTime.Before(blockerEnd) && endTime.After(blocker.StartTime) { desc := blocker.Description if desc == "" { desc = "Time blocked" } if blocker.CronExpression != nil { desc = fmt.Sprintf("%s (recurring: %s)", desc, *blocker.CronExpression) } return true, desc, nil } } return false, "", nil } // CleanupOldReservations deletes expired reservations: // - Logged-in (RESERVATION:user): older than 1 hour // - Anonymous (RESERVATION:anon): older than 10 minutes // - Admin walk-in (RESERVATION:admin:walkin:%): older than 15 minutes // - Admin call-in (RESERVATION:admin:callin:%): older than 15 minutes // - Edit request (RESERVATION:edit_request:%): older than 24 hours // - Payment in-flight (PAYMENT_IN_FLIGHT:%): TTL via duration_minutes column // (AcquirePaymentLock sets duration_minutes = PaymentLockDuration = 5min and // start_time = NOW(), so the condition evaluates to "cleanup after 5 minutes". func CleanupOldReservations(ctx context.Context) (int, error) { oneHourAgo := clock.Now().Add(-1 * time.Hour) tenMinutesAgo := clock.Now().Add(-10 * time.Minute) fifteenMinutesAgo := clock.Now().Add(-15 * time.Minute) twentyFourHoursAgo := clock.Now().Add(-24 * time.Hour) tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() tag, err := tx.Exec(ctx, ` DELETE FROM time_blockers WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1) OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2) OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3) OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3) OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4) OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW()) OR (description LIKE 'RESERVATION:placeholder:%' AND created_at < $4) OR (description LIKE 'RESERVATION:holiday_placeholder:%' AND created_at < $4) `, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo) if err != nil { return 0, err } 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() // Round 2 Loop B finding 1: this is a 'critical_payment_log' insert site — // the same atomic global flood cap as every other (webhooks, account // erasure, jwt reuse). The pre-check logs the suppression; the fold inside // the INSERT enforces it atomically (count-then-insert). The per-user // deterministic id (ON CONFLICT (id) DO NOTHING) is preserved, so each // affected user still gets exactly one notification. 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", "user", userID) 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 `, squareCleanupNotificationID(userID), adminnotify.MaxUnacknowledgedCriticalLogs) 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. // Active/pending bookings are excluded so the salon can still contact the guest. func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) { // Stale guests whose Square customers are deleted below — their // process-local cache entries must be invalidated after the anonymization. var staleGuestUserIDs []string // 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 { 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 JOIN users u ON u.id = usc.user_id WHERE u.account_role = 'guest' AND NOT EXISTS (SELECT 1 FROM bookings WHERE user_id = u.id AND status IN ('pending', 'confirmed')) AND EXISTS (SELECT 1 FROM bookings WHERE user_id = u.id GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months') AND usc.square_card_id IS NOT NULL `) if err != nil { 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. 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) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var totalRows int tag, err := tx.Exec(ctx, ` UPDATE users SET n_first_name = 'Guest', n_last_name = 'Anonymized', email = 'anon-' || id || '@anon.invalid', phone = '000000000000', date_of_birth = '1900-01-01', profile_pic_url = NULL, referral_code = NULL, notes = NULL, data_retention_consent = FALSE, updated_at = NOW() WHERE account_role = 'guest' AND NOT EXISTS (SELECT 1 FROM bookings WHERE user_id = users.id AND status IN ('pending', 'confirmed')) AND EXISTS (SELECT 1 FROM bookings WHERE user_id = users.id GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months') `) if err != nil { return 0, err } totalRows += int(tag.RowsAffected()) // Anonymize patch test records for stale guests (medical-adjacent PII) tag, err = tx.Exec(ctx, ` UPDATE user_patch_tests SET user_id = 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()) // Anonymize referral relationships for stale guests tag, err = tx.Exec(ctx, ` UPDATE user_referrals SET referrer_id = NULL WHERE referrer_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 user_referrals SET referred_id = NULL WHERE referred_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()) // Anonymize admin notification references for stale guests tag, err = tx.Exec(ctx, ` UPDATE admin_notifications SET user_id = 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()) // Scrub Square saved-card references for stale guests and soft-delete any // active cards (7-year financial retention; Square ids are external-system // identifiers and must be removed for GDPR storage limitation). // COALESCE keeps the original timestamps for cards soft-deleted by an // earlier run, so a re-run never extends the retention window. tag, err = tx.Exec(ctx, ` UPDATE user_saved_cards SET square_card_id = NULL, square_customer_id = NULL, last_4 = 'XXXX', fingerprint = NULL, deleted_at = COALESCE(deleted_at, NOW()), retained_until = COALESCE(retained_until, NOW() + INTERVAL '7 years') 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()) // 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. for _, uid := range staleGuestUserIDs { 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 } func CleanupExpiredLoyaltyRedemptions(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() tag, err := tx.Exec(ctx, ` DELETE FROM loyalty_redemptions WHERE status = 'pending' AND expires_at < NOW() `) if err != nil { return 0, err } return int(tag.RowsAffected()), tx.Commit(ctx) } // CleanupExpiredFinancialRecords aggregates granular payment/refund records // whose retention period has expired into monthly totals, then deletes them. // Runs daily at 4am. // // Retention policy — dual threshold, keeps the record until the later of: // - 7 years from payment creation (HMRC requirement) // - 1 year from account GDPR anonymisation/deletion (Limitation Act 1980 // England & Wales buffer — records kept for lawsuit defence) // // Active users are never deleted — their records stay accessible. Only records // belonging to users who have been fully GDPR-deleted or anonymised and whose // 1-year post-deletion buffer has passed are aggregated and removed. // // The function is idempotent — running it twice produces the same result. const retentionFilter = `AND ( b.user_id IS NULL OR u.id IS NULL OR ( u.account_role = 'guest' AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid') AND u.updated_at < NOW() - INTERVAL '1 year' ) )` func CleanupExpiredFinancialRecords(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var totalRows int tag, err := tx.Exec(ctx, ` INSERT INTO financial_aggregates (month, total_payments, total_square_fees, total_cash, total_online, total_in_person, total_discounts, total_giftcard, total_tips, total_deposits, total_balances, total_partials, total_vat_amount, total_net_amount, booking_count) SELECT DATE_TRUNC('month', p.created_at)::date AS month, COALESCE(SUM(p.amount), 0) AS total_payments, COALESCE(SUM(p.fees), 0) AS total_square_fees, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'cash'), 0) AS total_cash, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'online_square'), 0) AS total_online, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'in_person_card'), 0) AS total_in_person, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'discount'), 0) AS total_discounts, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'giftcard'), 0) AS total_giftcard, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'tip'), 0) AS total_tips, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'deposit'), 0) AS total_deposits, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'balance'), 0) AS total_balances, COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'partial'), 0) AS total_partials, COALESCE(SUM(p.vat_amount), 0) AS total_vat_amount, COALESCE(SUM(p.net_amount), 0) AS total_net_amount, COALESCE(COUNT(DISTINCT p.booking_id), 0) AS booking_count FROM payments p LEFT JOIN bookings b ON p.booking_id = b.id LEFT JOIN users u ON b.user_id = u.id WHERE p.created_at < NOW() - INTERVAL '7 years' `+retentionFilter+` GROUP BY DATE_TRUNC('month', p.created_at)::date ON CONFLICT (month) DO UPDATE SET total_payments = financial_aggregates.total_payments + EXCLUDED.total_payments, total_square_fees = financial_aggregates.total_square_fees + EXCLUDED.total_square_fees, total_cash = financial_aggregates.total_cash + EXCLUDED.total_cash, total_online = financial_aggregates.total_online + EXCLUDED.total_online, total_in_person = financial_aggregates.total_in_person + EXCLUDED.total_in_person, total_discounts = financial_aggregates.total_discounts + EXCLUDED.total_discounts, total_giftcard = financial_aggregates.total_giftcard + EXCLUDED.total_giftcard, total_tips = financial_aggregates.total_tips + EXCLUDED.total_tips, total_deposits = financial_aggregates.total_deposits + EXCLUDED.total_deposits, total_balances = financial_aggregates.total_balances + EXCLUDED.total_balances, total_partials = financial_aggregates.total_partials + EXCLUDED.total_partials, total_vat_amount = financial_aggregates.total_vat_amount + EXCLUDED.total_vat_amount, total_net_amount = financial_aggregates.total_net_amount + EXCLUDED.total_net_amount, booking_count = financial_aggregates.booking_count + EXCLUDED.booking_count `) if err != nil { return 0, fmt.Errorf("failed to aggregate expired payments: %w", err) } totalRows += int(tag.RowsAffected()) tag, err = tx.Exec(ctx, ` INSERT INTO financial_aggregates (month, total_refunds) SELECT DATE_TRUNC('month', r.created_at)::date AS month, COALESCE(SUM(r.amount), 0) AS total_refunds FROM refunds r JOIN payments p ON r.payment_id = p.id LEFT JOIN bookings b ON p.booking_id = b.id LEFT JOIN users u ON b.user_id = u.id WHERE p.created_at < NOW() - INTERVAL '7 years' `+retentionFilter+` GROUP BY DATE_TRUNC('month', r.created_at)::date ON CONFLICT (month) DO UPDATE SET total_refunds = financial_aggregates.total_refunds + EXCLUDED.total_refunds `) if err != nil { return 0, fmt.Errorf("failed to aggregate expired refunds: %w", err) } totalRows += int(tag.RowsAffected()) tag, err = tx.Exec(ctx, ` DELETE FROM payments p USING bookings b LEFT JOIN users u ON b.user_id = u.id WHERE p.booking_id = b.id AND p.created_at < NOW() - INTERVAL '7 years' `+retentionFilter+` `) if err != nil { return 0, fmt.Errorf("failed to delete expired payments: %w", err) } totalRows += int(tag.RowsAffected()) tag, err = tx.Exec(ctx, ` DELETE FROM refunds r USING payments p LEFT JOIN bookings b ON p.booking_id = b.id LEFT JOIN users u ON b.user_id = u.id WHERE r.payment_id = p.id AND p.created_at < NOW() - INTERVAL '7 years' `+retentionFilter+` `) if err != nil { return 0, fmt.Errorf("failed to delete expired refunds: %w", err) } totalRows += int(tag.RowsAffected()) return totalRows, tx.Commit(ctx) } // CleanupExpiredDeposits marks bookings as pending_release when the deposit // deadline (24h before start_time) has passed without payment. The booking is // kept alive in a vulnerable state — the slot becomes available for others to // book, and if another booking claims it, the original is cancelled with // deposit forfeited. // // If the deposit IS paid after the deadline but before the appointment, the // booking flips back to confirmed. func CleanupExpiredDeposits(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() // Collect all evicted booking IDs from both updates so we can notify // and clean up in one pass instead of re-scanning via updated_at = NOW(). type evictedBooking struct{ id, userID string } var evicted []evictedBooking // Confirmed bookings past deposit deadline → pending_release rows, err := tx.Query(ctx, ` UPDATE bookings SET status = 'pending_release', updated_at = NOW() WHERE deposit_required = true AND status = 'confirmed' AND start_time - INTERVAL '24 hours' < NOW() AND NOT EXISTS ( SELECT 1 FROM payments p WHERE p.booking_id = bookings.id AND p.status = 'completed' AND p.created_at < bookings.start_time ) RETURNING id, user_id `) if err != nil { return 0, fmt.Errorf("failed to expire confirmed booking deposits: %w", err) } for rows.Next() { var b evictedBooking if err := rows.Scan(&b.id, &b.userID); err != nil { rows.Close() return 0, fmt.Errorf("failed to scan evicted confirmed booking: %w", err) } evicted = append(evicted, b) } rows.Close() // Pending bookings past deposit deadline → pending_release rows, err = tx.Query(ctx, ` UPDATE bookings SET status = 'pending_release', updated_at = NOW() WHERE deposit_required = true AND status = 'pending' AND start_time - INTERVAL '24 hours' < NOW() AND NOT EXISTS ( SELECT 1 FROM payments p WHERE p.booking_id = bookings.id AND p.status = 'completed' AND p.created_at < bookings.start_time ) RETURNING id, user_id `) if err != nil { return 0, fmt.Errorf("failed to expire pending expired bookings: %w", err) } for rows.Next() { var b evictedBooking if err := rows.Scan(&b.id, &b.userID); err != nil { rows.Close() return 0, fmt.Errorf("failed to scan evicted pending booking: %w", err) } evicted = append(evicted, b) } rows.Close() if len(evicted) == 0 { return 0, tx.Commit(ctx) } n := len(evicted) // Create admin notifications and clean up time_blockers for evicted bookings. ids := make([]string, len(evicted)) userIDs := make([]string, len(evicted)) for i, b := range evicted { ids[i] = b.id userIDs[i] = b.userID } // C5: flood-cap the unacknowledged 'deposit_not_paid_by_deadline' queue // (pre-check logs the suppression; the fold inside the INSERT enforces it // atomically). The per-booking NOT EXISTS dedup is preserved. No early // return — the time_blocker cleanup below must still run. if adminnotify.CriticalLogsCapExceeded(ctx, tx, "deposit_not_paid_by_deadline") { slog.Warn("suppressed deposit_not_paid_by_deadline admin notification — unacknowledged queue at the cap") } else if _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, booking_id, user_id) SELECT 'deposit_not_paid_by_deadline', unnest($1::text[]), unnest($2::text[]) WHERE NOT EXISTS ( SELECT 1 FROM admin_notifications an WHERE an.booking_id = ANY($1) AND an.reason = 'deposit_not_paid_by_deadline' ) AND (SELECT COUNT(*) FROM admin_notifications _an WHERE _an.reason = 'deposit_not_paid_by_deadline' AND _an.acknowledged_at IS NULL) < $3 `, ids, userIDs, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil { return 0, fmt.Errorf("failed to create pending_release notifications: %w", err) } _, err = tx.Exec(ctx, ` DELETE FROM time_blockers tb USING unnest($1::text[]) AS evicted_ids(id) WHERE tb.created_by = ANY($2::text[]) OR tb.description ILIKE ANY( SELECT 'RESERVATION:user:' || u || '%' FROM unnest($2::text[]) AS u ) `, ids, userIDs) if err != nil { return 0, fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err) } return n, tx.Commit(ctx) } // CleanupExpiredGiftCards expires gift cards unused for the configured rolling // window (default 24 months) after last use. // // Legal basis: // - UK Consumer Rights Act 2015: Expiry terms must be "fair and transparent" // - CMA guidance: 24 months is industry standard (John Lewis, M&S, Sainsbury's) // - Under 12 months risks being challenged as unfair contract term // // The window is read from business_settings.gift_card_expiry_months (the SINGLE // source of truth shared with the payment handlers' expiry_date writes via // payments.GetGiftCardExpiryMonths) so the job and the refund-time check never // drift apart. // // This function: // 1. Zeroes unredeemed cards (redeemed_by IS NULL) unused for the window // 2. Inserts into gift_card_expired_balances for recovery claims // 3. Records transaction in gift_card_transactions // // Once redeemed to an account, the balance doesn't expire (but the account can // be deleted after idle time per GDPR - see CleanupIdleAccounts). // // M4 (expiry-sweep TOCTOU): the expiry decision is made ATOMICALLY with the // zeroing — the SELECT reads the rolling-expiry predicate under FOR UPDATE row // locks. A concurrent top-up either commits BEFORE the SELECT (its refreshed // last_used_at drops the card out of the predicate → the card is never // selected), or it blocks on the row lock until this sweep's transaction ends // and lands on the ALREADY-zeroed card, reviving it via its own // last_used_at/expiry refresh (→ the top-up value is preserved on the card). // Either way the top-up's value is never destroyed. The old sweep SELECTed the // expired cards with NO lock and zeroed them with an unconditional // `UPDATE ... WHERE id = ANY($1)` a moment later, so a top-up committing in // between was read as expired and then clobbered — the freshly topped-up value // was lost. The row lock also pins the read balance until commit, so the // recovery/audit rows below can never disagree with the zeroed value. // // Pre-expiry email warnings are intentionally omitted: gift cards are unowned // (bought as gifts, change hands) until redeemed to an account. After redemption, // the balance is covered by CleanupIdleAccounts warnings. func CleanupExpiredGiftCards(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() expiryMonths, monthsErr := payments.GetGiftCardExpiryMonths(ctx, tx) if monthsErr != nil { return 0, fmt.Errorf("failed to read gift card expiry months: %w", monthsErr) } // FOR UPDATE (M4): the predicate is re-evaluated against the latest // committed row state while the row lock is held, so a concurrent top-up // can neither be read as expired and clobbered, nor interleave a balance // change between this read and the zeroing UPDATE below. rows, err := tx.Query(ctx, ` SELECT id, amount_remaining FROM gift_cards WHERE redeemed_by IS NULL AND amount_remaining > 0 AND last_used_at < NOW() - ($1 * INTERVAL '1 month') FOR UPDATE `, expiryMonths) if err != nil { return 0, fmt.Errorf("failed to query expired gift cards: %w", err) } defer rows.Close() var expiredCards []struct { id string balance float64 } for rows.Next() { var card struct { id string balance float64 } if err := rows.Scan(&card.id, &card.balance); err != nil { return 0, fmt.Errorf("failed to scan expired gift card: %w", err) } expiredCards = append(expiredCards, card) } if err := rows.Err(); err != nil { return 0, fmt.Errorf("failed to iterate expired gift cards: %w", err) } if len(expiredCards) > 0 { ids := make([]string, len(expiredCards)) balances := make([]float64, len(expiredCards)) for i, c := range expiredCards { ids[i] = c.id balances[i] = c.balance } if _, err = tx.Exec(ctx, ` INSERT INTO gift_card_expired_balances (original_balance, expired_at) SELECT unnest($1::numeric[]), NOW() `, balances); err != nil { return 0, fmt.Errorf("failed to batch insert expired balances: %w", err) } if _, err = tx.Exec(ctx, ` UPDATE gift_cards SET amount_remaining = 0, last_used_at = NOW() WHERE id = ANY($1) `, ids); err != nil { return 0, fmt.Errorf("failed to batch zero expired cards: %w", err) } if _, err = tx.Exec(ctx, ` INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes) SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused' `, ids, balances); err != nil { return 0, fmt.Errorf("failed to batch insert expire transactions: %w", err) } } return len(expiredCards), tx.Commit(ctx) } // CleanupIdleAccounts deletes user accounts that have been idle for extended periods. // // Legal basis (GDPR Article 5(1)(e) - Storage Limitation): // - No money: 2 years idle (legitimate interest in customer relationship weakens) // - With money: 5 years idle (Scottish prescriptive period for contract claims, // Prescription and Limitation (Scotland) Act 1973 s.6) // // When an account with balance is deleted: // 1. Balance moves to gift_card_expired_balances for recovery claims // 2. Account is anonymized (PII removed, account ID retained) // 3. User can recover balance with account ID (no deadline imposed) // // This transforms "forfeiture" into "dormancy", reducing legal risk from 40-50% // to 10-20% per CMA unfair terms analysis. // // TODO: Email Integration // Before deleting accounts, send warning emails: // - 18 months idle (no balance): "Your account will be deleted in 6 months due to inactivity" // - 23 months idle (no balance): "Your account will be deleted in 30 days" // - 4 years idle (with balance): "Your account will be deleted in 1 year. Balance: £X" // - 59 months idle (with balance): "Your account will be deleted in 30 days. Balance: £X" // Include account ID in all emails for future recovery claims. // Query: SELECT u.id, u.email, u.last_login_at, COALESCE(b.balance, 0) as balance // // FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id // WHERE u.account_role NOT IN ('admin', 'guest') // AND u.last_login_at < NOW() - INTERVAL '18 months' // // Store sent warnings in account_email_warnings table to avoid duplicates. func CleanupIdleAccounts(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() rowsWithBalance, err := tx.Query(ctx, ` SELECT u.id, COALESCE(b.balance, 0) as balance FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id WHERE u.account_role != 'admin' AND u.account_role != 'guest' AND (u.last_login_at IS NULL OR u.last_login_at < NOW() - INTERVAL '5 years') AND (b.balance IS NULL OR b.balance > 0) AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid') `) if err != nil { return 0, fmt.Errorf("failed to query idle accounts with balance: %w", err) } defer rowsWithBalance.Close() var accountsWithBalance []struct { id string balance float64 } for rowsWithBalance.Next() { var acc struct { id string balance float64 } if err := rowsWithBalance.Scan(&acc.id, &acc.balance); err != nil { return 0, fmt.Errorf("failed to scan idle account with balance: %w", err) } 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)) for i, a := range accountsWithBalance { ids[i] = a.id balances[i] = a.balance } if _, err = tx.Exec(ctx, ` INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) SELECT unnest($1::text[]), unnest($2::numeric[]), NOW() `, ids, balances); err != nil { return 0, fmt.Errorf("failed to batch insert expired balances: %w", err) } if _, err = tx.Exec(ctx, ` UPDATE user_giftcard_balances SET balance = 0, updated_at = NOW() WHERE user_id = ANY($1) `, ids); err != nil { return 0, fmt.Errorf("failed to batch zero account balances: %w", err) } if _, err = tx.Exec(ctx, ` SELECT anonymize_user(unnest($1::text[])) `, ids); err != nil { return 0, fmt.Errorf("failed to anonymize idle accounts: %w", err) } } rowsNoBalance, err := tx.Query(ctx, ` SELECT u.id FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id WHERE u.account_role != 'admin' AND u.account_role != 'guest' AND (u.last_login_at IS NULL OR u.last_login_at < NOW() - INTERVAL '2 years') AND (b.balance IS NULL OR b.balance = 0) AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid') `) if err != nil { return 0, fmt.Errorf("failed to query idle accounts without balance: %w", err) } defer rowsNoBalance.Close() var accountsNoBalance []string for rowsNoBalance.Next() { var id string if err := rowsNoBalance.Scan(&id); err != nil { return 0, fmt.Errorf("failed to scan idle account without balance: %w", err) } accountsNoBalance = append(accountsNoBalance, id) } 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 { return 0, fmt.Errorf("failed to anonymize idle accounts: %w", err) } if err := tx.Commit(ctx); err != nil { 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. for _, acc := range accountsWithBalance { payments.InvalidateSquareCustomerCache(acc.id) } for _, id := range accountsNoBalance { 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 } // CleanupOldIdempotencyKeys clears idempotency keys from bookings, payments, and // till_sales that are older than 24 hours and no longer pending. This prevents // unbounded table growth while preserving keys for recent in-flight requests. func CleanupOldIdempotencyKeys(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() var totalRows int tag, err := tx.Exec(ctx, ` UPDATE bookings SET idempotency_key = NULL WHERE idempotency_key IS NOT NULL AND created_at < NOW() - INTERVAL '24 hours' AND status != 'pending' `) if err != nil { return 0, fmt.Errorf("failed to cleanup booking idempotency keys: %w", err) } totalRows += int(tag.RowsAffected()) tag, err = tx.Exec(ctx, ` UPDATE payments SET idempotency_key = NULL WHERE idempotency_key IS NOT NULL AND created_at < NOW() - INTERVAL '24 hours' AND status != 'pending' `) if err != nil { return 0, fmt.Errorf("failed to cleanup payment idempotency keys: %w", err) } totalRows += int(tag.RowsAffected()) tag, err = tx.Exec(ctx, ` UPDATE till_sales SET idempotency_key = NULL WHERE idempotency_key IS NOT NULL AND created_at < NOW() - INTERVAL '24 hours' `) if err != nil { return 0, fmt.Errorf("failed to cleanup till sale idempotency keys: %w", err) } totalRows += int(tag.RowsAffected()) return totalRows, tx.Commit(ctx) } // CleanupOldNameHistory removes name_history entries older than 6 months. // WHY: GDPR requires data minimization — name change history doesn't need // to be retained indefinitely. 6 months provides a reasonable window for // displaying former names on booking receipts and admin views. func CleanupOldNameHistory(ctx context.Context) (int, error) { tx, err := db.Conn.Begin(ctx) if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) } defer func() { if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() tag, err := tx.Exec(ctx, ` DELETE FROM name_history WHERE changed_at < NOW() - INTERVAL '6 months' `) if err != nil { return 0, fmt.Errorf("failed to cleanup old name history: %w", err) } return int(tag.RowsAffected()), tx.Commit(ctx) }