package scheduling import ( "context" "encoding/json" "fmt" "log" "net/http" "time" "crussell/db" "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 ukLocation, _ := time.LoadLocation("Europe/London") start, err1 := time.ParseInLocation("2006-01-02", startStr, ukLocation) end, err2 := time.ParseInLocation("2006-01-02", endStr, ukLocation) 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, ukLocation) end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation) // 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 := time.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") json.NewEncoder(w).Encode(blockers) } // --- 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 JSON: "+err.Error(), http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { http.Error(w, err.Error(), 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 } // Insert the time blocker var blocker TimeBlocker err := db.Conn.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 } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(blocker) } // --- 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 } result, err := db.Conn.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 } 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 // Used by GetAvailableHours to subtract blocked time from available slots func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBlocker, error) { // Get one-off blockers in range 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 ORDER BY start_time `, start, end) 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 } blockers = append(blockers, b) } rows.Close() // Get ALL recurring blockers and expand them recurringRows, 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 NOT NULL `) if err != nil { return nil, err } defer recurringRows.Close() for recurringRows.Next() { var b TimeBlocker if err := recurringRows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil { return nil, err } // Expand recurring blocker to occurrences within range occurrences := expandCronOccurrences(b, start, end) blockers = append(blockers, occurrences...) } 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 blockerHour := blocker.StartTime.Hour() blockerMinute := blocker.StartTime.Minute() // Use UK timezone for expansion ukLocation, _ := time.LoadLocation("Europe/London") var occurrences []TimeBlocker // Start from the beginning of the range current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, ukLocation) // 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 func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time) (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) 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 func CleanupOldReservations(ctx context.Context) error { oneHourAgo := time.Now().Add(-1 * time.Hour) tenMinutesAgo := time.Now().Add(-10 * time.Minute) fifteenMinutesAgo := time.Now().Add(-15 * time.Minute) twentyFourHoursAgo := time.Now().Add(-24 * time.Hour) _, err := db.Conn.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()) `, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo) return err } // AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts // whose last booking was more than 6 months ago (UK GDPR storage limitation). // Financial records (bookings, payments) remain intact — only PII is scrubbed. // Active/pending bookings are excluded so the salon can still contact the guest. func AnonymizeStaleGuestAccounts(ctx context.Context) error { _, err := db.Conn.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 id NOT IN ( SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed') ) AND id IN ( SELECT user_id FROM bookings WHERE user_id IS NOT NULL GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months' ) `) if err != nil { return err } // Anonymize patch test records for stale guests (medical-adjacent PII) _, err = db.Conn.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 err } // Anonymize referral relationships for stale guests _, err = db.Conn.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 err } _, err = db.Conn.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 err } // Anonymize admin notification references for stale guests _, err = db.Conn.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' ) `) return err } func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error { _, err := db.Conn.Exec(ctx, ` DELETE FROM loyalty_redemptions WHERE status = 'pending' AND expires_at < NOW() `) return err } // CleanupExpiredFinancialRecords deletes granular payment/refund records whose // retention period has expired and replaces them with monthly aggregates. // // Retention: MAX(p.created_at + 7 years, user_anonymized_at + 1 year) // - Walk-in records (no user): 7 years from payment creation // - Active users: 7 years from payment creation // - Anonymized users: 7 years from payment creation AND 1 year since anonymization // // The function is idempotent — running it twice produces the same result. func CleanupExpiredFinancialRecords(ctx context.Context) error { tx, err := db.Conn.Begin(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) _, 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, 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(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' AND ( b.user_id IS NULL OR u.id IS NULL OR NOT (u.account_role = 'guest' AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid')) OR u.updated_at < NOW() - INTERVAL '1 year' ) 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, booking_count = financial_aggregates.booking_count + EXCLUDED.booking_count `) if err != nil { return fmt.Errorf("failed to aggregate expired payments: %w", err) } _, 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' AND ( b.user_id IS NULL OR u.id IS NULL OR NOT (u.account_role = 'guest' AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid')) OR u.updated_at < NOW() - INTERVAL '1 year' ) 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 fmt.Errorf("failed to aggregate expired refunds: %w", err) } _, 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' AND ( b.user_id IS NULL OR u.id IS NULL OR NOT (u.account_role = 'guest' AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid')) OR u.updated_at < NOW() - INTERVAL '1 year' ) `) if err != nil { return fmt.Errorf("failed to delete expired payments: %w", err) } _, 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' AND ( b.user_id IS NULL OR u.id IS NULL OR NOT (u.account_role = 'guest' AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid')) OR u.updated_at < NOW() - INTERVAL '1 year' ) `) if err != nil { return fmt.Errorf("failed to delete expired refunds: %w", err) } return 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) error { tx, err := db.Conn.Begin(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) // 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 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 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 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 fmt.Errorf("failed to scan evicted pending booking: %w", err) } evicted = append(evicted, b) } rows.Close() if len(evicted) == 0 { return tx.Commit(ctx) } // 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 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 fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err) } return 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) error { tx, err := db.Conn.Begin(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) 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 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 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 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 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 fmt.Errorf("failed to batch insert expire transactions: %w", err) } } return 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) error { tx, err := db.Conn.Begin(ctx) if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback(ctx) 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 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 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 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 fmt.Errorf("failed to batch zero account balances: %w", err) } for _, id := range ids { if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil { return fmt.Errorf("failed to anonymize idle account %s: %w", id, 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 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 fmt.Errorf("failed to scan idle account without balance: %w", err) } accountsNoBalance = append(accountsNoBalance, id) } rowsNoBalance.Close() for _, id := range accountsNoBalance { if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil { return fmt.Errorf("failed to anonymize idle account %s: %w", id, err) } } return 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) error { _, err := db.Conn.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 fmt.Errorf("failed to cleanup booking idempotency keys: %w", err) } _, err = db.Conn.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 fmt.Errorf("failed to cleanup payment idempotency keys: %w", err) } _, err = db.Conn.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 fmt.Errorf("failed to cleanup till sale idempotency keys: %w", err) } return nil } // 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) error { _, err := db.Conn.Exec(ctx, ` DELETE FROM name_history WHERE changed_at < NOW() - INTERVAL '6 months' `) if err != nil { return fmt.Errorf("failed to cleanup old name history: %w", err) } return nil }