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) { // Stale guests whose Square customers are deleted below — their // process-local cache entries must be invalidated after the anonymization. var staleGuestUserIDs []string // Best-effort: disable stale-guests' saved cards at Square BEFORE the SQL // below NULLs square_card_id, so those cards can't keep accepting ccof: // charges after anonymization (GDPR erasure completeness). A Square failure // is logged and ignored — the local anonymization must never be blocked by // Square. Card IDs are selected with the same stale-guest predicate the // users UPDATE uses, and only when a Square client is configured. 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.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 { 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 userSeen := map[string]bool{} for rows.Next() { var userID, cardID, customerID sql.NullString if err := rows.Scan(&userID, &cardID, &customerID); err != nil { log.Printf("Warning: Failed to scan stale-guest saved card: %v", err) continue } if userID.Valid && userID.String != "" && !userSeen[userID.String] { userSeen[userID.String] = true staleGuestUserIDs = append(staleGuestUserIDs, userID.String) } if cardID.Valid && cardID.String != "" { cardIDs = append(cardIDs, cardID.String) } if customerID.Valid && customerID.String != "" && !customerSeen[customerID.String] { customerSeen[customerID.String] = true customerIDs = append(customerIDs, customerID.String) } } rows.Close() if err := rows.Err(); err != nil { log.Printf("Warning: Row iteration error querying stale-guest saved cards: %v", err) } for _, cardID := range cardIDs { if err := payments.SquareClient.DeleteCardOnFile(ctx, cardID); err != nil { // TokenPrefix redacts the ccof: card token — the full ID // must never reach logs. log.Printf("Warning: Failed to disable stale-guest Square card %s at Square: %v", square.TokenPrefix(cardID), err) } } // GDPR erasure completeness: the guest's Square customer profile // holds their real name + email PII. Disabling the saved cards and // NULLing square_customer_id locally is NOT enough — the Square // customer profile must be deleted too, or the PII persists at // Square indefinitely after anonymization. Distinct IDs only, so a // guest with multiple cards on one customer triggers one delete. for _, customerID := range customerIDs { if err := payments.SquareClient.DeleteCustomer(ctx, customerID); err != nil { // TokenPrefix redacts the customer ID — the full ID must // never reach logs. log.Printf("Warning: Failed to delete stale-guest Square customer %s at Square: %v", square.TokenPrefix(customerID), err) } } } } 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()) if err := tx.Commit(ctx); err != nil { return 0, err } // 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 } _, 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 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. Finds unredeemed cards (redeemed_by IS NULL) unused for the window // 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) } }() expiryMonths, monthsErr := payments.GetGiftCardExpiryMonths(ctx, tx) if monthsErr != nil { return 0, fmt.Errorf("failed to read gift card expiry months: %w", monthsErr) } 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') `, 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 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) } if err := tx.Commit(ctx); err != nil { return 0, err } // 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) }