Files
Crussell/backend/handlers/scheduling/time-blockers.go
T
popertots f3d50f6990 Complete Square GDPR erasure: customer deletion and stale-guest scrub
Account deletion now snapshots card and customer IDs before the local anonymize transaction and dispatches the Square cleanup goroutine only after the tx commits, deleting each distinct Square customer once and skipping any customer still referenced by another user's card. AnonymizeStaleGuestAccounts also disables cards and deletes the guest's Square customer profile (PII) before NULLing references locally. Token redaction applied to all error logs.
2026-08-22 00:34:49 +01:00

1196 lines
41 KiB
Go

package scheduling
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"time"
"crussell/clock"
"crussell/db"
"crussell/handlers/payments"
"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)
}
// 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) {
// Best-effort: disable stale-guests' saved cards at Square BEFORE the SQL
// below NULLs square_card_id, so those cards can't keep accepting ccof:
// charges after anonymization (GDPR erasure completeness). A Square failure
// is logged and ignored — the local anonymization must never be blocked by
// Square. Card IDs are selected with the same stale-guest predicate the
// users UPDATE uses, and only when a Square client is configured.
if payments.SquareClient != nil {
// Snapshot the stale-guests' saved cards AND their Square customer IDs
// BEFORE the SQL below NULLs square_card_id/square_customer_id, so the
// external Square references are still available for cleanup (GDPR
// erasure completeness: the local scrub must never strand PII at
// Square). Best-effort: a Square failure is logged and ignored — the
// local anonymization must never be blocked by Square. Rows are
// selected with the same stale-guest predicate the users UPDATE uses.
rows, err := db.Conn.Query(ctx, `
SELECT 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 {
log.Printf("Warning: Failed to query stale-guest saved cards for Square cleanup: %v", err)
} else {
var cardIDs []string
// Distinct non-null customer IDs only: a guest's saved cards share
// one provisioned Square customer, so DeleteCustomer runs once per
// customer. NULL customer IDs (guests with no provisioned Square
// customer) are skipped.
customerSeen := map[string]bool{}
var customerIDs []string
for rows.Next() {
var cardID, customerID sql.NullString
if err := rows.Scan(&cardID, &customerID); err != nil {
log.Printf("Warning: Failed to scan stale-guest saved card: %v", err)
continue
}
if cardID.Valid && cardID.String != "" {
cardIDs = append(cardIDs, cardID.String)
}
if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] {
customerSeen[customerID.String] = true
customerIDs = append(customerIDs, customerID.String)
}
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("Warning: Row iteration error querying stale-guest saved cards: %v", err)
}
for _, cardID := range cardIDs {
if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil {
// TokenPrefix redacts the ccof: card token — the full ID
// must never reach logs.
log.Printf("Warning: Failed to disable stale-guest Square card %s at Square: %v", square.TokenPrefix(cardID), err)
}
}
// GDPR erasure completeness: the guest's Square customer profile
// holds their real name + email PII. Disabling the saved cards and
// NULLing square_customer_id locally is NOT enough — the Square
// customer profile must be deleted too, or the PII persists at
// Square indefinitely after anonymization. Distinct IDs only, so a
// guest with multiple cards on one customer triggers one delete.
for _, customerID := range customerIDs {
if err := payments.SquareClient.DeleteCustomer(ctx, customerID); err != nil {
// TokenPrefix redacts the customer ID — the full ID must
// never reach logs.
log.Printf("Warning: Failed to delete stale-guest Square customer %s at Square: %v", square.TokenPrefix(customerID), err)
}
}
}
}
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())
return totalRows, tx.Commit(ctx)
}
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
}
_, 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'
)
`, ids, userIDs)
if 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 24 months (rolling expiry).
//
// 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
//
// This function:
// 1. Finds unredeemed cards (redeemed_by IS NULL) unused for 24+ months
// 2. Inserts into gift_card_expired_balances for recovery claims
// 3. Sets amount_remaining to 0
// 4. 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).
//
// 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)
}
}()
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() - INTERVAL '24 months'
`)
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 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)
}
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()
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)
}
return len(accountsWithBalance) + len(accountsNoBalance), tx.Commit(ctx)
}
// 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)
}