Add CleanupExpiredFinancialRecords to enforce HMRC + Limitation Act compliance (7-year retention, 1-year post-anonymization buffer). - financial_aggregates table: monthly totals by payment method/type (no PII) - CleanupExpiredFinancialRecords(): aggregates expired payments/refunds, deletes granular records, idempotent via ON CONFLICT DO UPDATE - Wired into GET /api/availability alongside existing cleanup functions - 8 tests: 7yr expiry, 1yr buffer, 9yr override, aggregation totals, idempotency, active user protection, both-thresholds elapsed, refunds - testdb.go: financial_aggregates in drop-order and truncate lists - README + Technical Manual updated
583 lines
20 KiB
Go
583 lines
20 KiB
Go
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.DB.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.DB.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.DB.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.DB.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.DB.Query(ctx, `
|
|
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
|
|
FROM time_blockers
|
|
WHERE cron_expression IS NULL
|
|
AND description NOT LIKE 'RESERVATION:%'
|
|
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.DB.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.DB.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)
|
|
`, 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.DB.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.DB.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.DB.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.DB.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.DB.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.DB.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.DB.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)
|
|
}
|