Files
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

1863 lines
70 KiB
Go

package scheduling
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"net/url"
"os"
"sync"
"time"
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/adminnotify"
"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
}
// s3ProfilePicsBucketUnsetWarningOnce throttles the missing
// S3_PROFILE_PICS_BUCKET CRITICAL log to one line per process (FIX 2): batch
// erasure skips profile-picture deletion fail-closed when the bucket is unset,
// and the single loud warning makes the misconfiguration impossible to miss. A
// proper startup check in main.go is tracked for a later round.
var s3ProfilePicsBucketUnsetWarningOnce sync.Once
func warnS3ProfilePicsBucketUnset() {
s3ProfilePicsBucketUnsetWarningOnce.Do(func() {
log.Printf("CRITICAL: S3_PROFILE_PICS_BUCKET is not set — batch erasure skipping profile-picture deletion fail-closed; profile-pic objects may remain until the bucket is configured (main.go startup check pending in a later round)")
})
}
// persistExternalArtifactErasures writes the batch erasure's external-artifact
// cleanup INSIDE the anonymization transaction, before it commits — mirroring
// DeleteAccountHandler's FIX 1a/FIX 1b pattern:
//
// - CardDAV: the erased users' dav_cards rows (full name/email/phone/DOB/
// photo URL PII, keyed by uri "{userID}.vcf" under addressbook 1) are
// deleted in the same Postgres, so the delete is atomic with the erasure.
// - S3/R2: when bucket is non-empty a pending_s3_deletions outbox row is
// written for each user who HAD a profile picture (profilePicUserIDs must
// be snapshotted BEFORE anonymization NULLs profile_pic_url), so the
// retry-s3-deletions job completes the object deletion after a crash.
// When bucket is empty the outbox is skipped fail-closed — never the
// guessed dev bucket "crussell-profile-pics".
func persistExternalArtifactErasures(ctx context.Context, tx pgx.Tx, userIDs, profilePicUserIDs []string, bucket string) error {
if len(userIDs) > 0 {
uris := make([]string, len(userIDs))
for i, uid := range userIDs {
uris[i] = fmt.Sprintf("%s.vcf", uid)
}
if _, err := tx.Exec(ctx, `
DELETE FROM dav_cards WHERE addressbookid = 1 AND uri = ANY($1)
`, uris); err != nil {
return fmt.Errorf("failed to delete CardDAV vCards: %w", err)
}
}
if bucket != "" {
for _, uid := range profilePicUserIDs {
if _, err := tx.Exec(ctx, `
INSERT INTO pending_s3_deletions (user_id, bucket, object_key)
VALUES ($1, $2, $3)
`, uid, bucket, fmt.Sprintf("profiles/%s.jpg", uid)); err != nil {
return fmt.Errorf("failed to persist S3 profile-picture deletion outbox for user %s: %w", uid, err)
}
}
}
return nil
}
// 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
// FIX 1: durable Square erasure outbox — capture row IDs BEFORE the UPDATE
// NULLs square_card_id/square_customer_id, so we can restore them on the
// soft-deleted rows inside the tx (crash-safe via retry-square-erasures job).
type squareErasureRow struct {
rowID string
cardID string
customerID string
}
var erasureRows []squareErasureRow
if payments.SquareClient != nil {
cardsByUser = map[string][]string{}
customers = map[string]string{}
rows, err := db.Conn.Query(ctx, `
SELECT usc.id, 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 rowID, userID, cardID, customerID sql.NullString
if err := rows.Scan(&rowID, &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
}
}
// FIX 1: capture row-level data for the durable outbox restore.
if rowID.Valid && rowID.String != "" {
erasureRows = append(erasureRows, squareErasureRow{
rowID: rowID.String,
cardID: cardID.String,
customerID: customerID.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
// FIX 1b: snapshot the profile-pic-owning stale guests BEFORE the users
// UPDATE NULLs profile_pic_url, so the S3 deletion outbox rows can be
// written inside the tx. Only relevant when an object store is configured.
var profilePicGuestIDs []string
if s3.Client != nil {
profilePicBucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
if profilePicBucket == "" {
warnS3ProfilePicsBucketUnset()
} else {
rows, err := tx.Query(ctx, `
SELECT id FROM users
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')
AND profile_pic_url IS NOT NULL
`)
if err != nil {
return 0, fmt.Errorf("failed to snapshot stale-guest profile pictures for S3 erasure: %w", err)
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, fmt.Errorf("failed to scan stale-guest profile picture id: %w", err)
}
profilePicGuestIDs = append(profilePicGuestIDs, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("row iteration error snapshotting stale-guest profile pictures: %w", err)
}
}
}
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())
// FIX 1: durable Square erasure outbox — restore the Square references on the
// just-scrubbed, soft-deleted rows INSIDE the tx, BEFORE the commit. If the
// process crashes after the commit but before the post-commit Square deletion,
// the retry-square-erasures job finds these rows (deleted_at IS NOT NULL AND
// last_4 = 'XXXX' AND square_card_id IS NOT NULL) and completes the erasure.
if len(erasureRows) > 0 {
for _, er := range erasureRows {
var cardID, customerID any
if er.cardID != "" {
cardID = er.cardID
}
if er.customerID != "" {
customerID = er.customerID
}
if _, err := tx.Exec(ctx, `
UPDATE user_saved_cards
SET square_card_id = $2, square_customer_id = $3, user_id = NULL
WHERE id = $1 AND deleted_at IS NOT NULL
`, er.rowID, cardID, customerID); err != nil {
return 0, fmt.Errorf("failed to persist Square erasure outbox for row %s: %w", er.rowID, err)
}
}
}
// 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 in-tx CardDAV /
// S3-outbox erasures cover exactly the erased guests. Already-anonymized
// guests from a re-run may re-appear here — their 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)
}
// FIX 1b: delete the erased guests' CardDAV vCards and write the S3/R2
// profile-picture deletion outbox INSIDE the erasure tx — atomic with the
// anonymization, and crash-safe via the retry-s3-deletions job.
if len(anonymizedGuestIDs) > 0 {
if err := persistExternalArtifactErasures(ctx, tx, anonymizedGuestIDs, profilePicGuestIDs, os.Getenv("S3_PROFILE_PICS_BUCKET")); err != nil {
return 0, fmt.Errorf("failed to persist external artifact erasures for 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)
}
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
}
}
// FIX 1b: snapshot the profile-pic-owning erased accounts BEFORE
// anonymize_user(unnest(...)) NULLs profile_pic_url, so the S3 deletion
// outbox rows can be written inside the tx (only when an object store is
// configured; fail-closed otherwise).
var profilePicUserIDs []string
snapshotProfilePicUsers := func(ids []string) error {
if s3.Client == nil || len(ids) == 0 {
return nil
}
if os.Getenv("S3_PROFILE_PICS_BUCKET") == "" {
warnS3ProfilePicsBucketUnset()
return nil
}
rows, err := tx.Query(ctx, `
SELECT id FROM users
WHERE id = ANY($1) AND profile_pic_url IS NOT NULL
`, ids)
if err != nil {
return fmt.Errorf("failed to snapshot idle-account profile pictures for S3 erasure: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return fmt.Errorf("failed to scan idle-account profile picture id: %w", err)
}
profilePicUserIDs = append(profilePicUserIDs, id)
}
return rows.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 := snapshotProfilePicUsers(ids); err != nil {
return 0, err
}
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 := snapshotProfilePicUsers(accountsNoBalance); err != nil {
return 0, err
}
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)
}
// FIX 1b: delete the erased accounts' CardDAV vCards and write the S3/R2
// profile-picture deletion outbox INSIDE the erasure tx — atomic with the
// anonymization, and crash-safe via the retry-s3-deletions job.
erasedIDs := make([]string, 0, len(accountsWithBalance)+len(accountsNoBalance))
for _, acc := range accountsWithBalance {
erasedIDs = append(erasedIDs, acc.id)
}
erasedIDs = append(erasedIDs, accountsNoBalance...)
if len(erasedIDs) > 0 {
if err := persistExternalArtifactErasures(ctx, tx, erasedIDs, profilePicUserIDs, os.Getenv("S3_PROFILE_PICS_BUCKET")); err != nil {
return 0, fmt.Errorf("failed to persist external artifact erasures for 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)
}
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)
}