feat: financial data retention & aggregation system

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
This commit is contained in:
2026-06-05 16:37:04 +01:00
parent 06efdfdac0
commit f4a6033715
8 changed files with 820 additions and 5 deletions
@@ -322,6 +322,11 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to cleanup expired loyalty redemptions: %v", err)
}
// Clean up expired financial records (aggregate + delete granular data)
if err := CleanupExpiredFinancialRecords(r.Context()); err != nil {
log.Printf("Failed to cleanup expired financial records: %v", err)
}
// Load default hours
defaultMap := map[int]DefaultHours{}
defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`)
@@ -35,6 +35,8 @@ import (
func resetTestData(t *testing.T) {
t.Helper()
testdb.TruncateTables(t, db.DB)
// Also truncate financial_aggregates which is not in the default truncation list
db.DB.Exec(context.Background(), "TRUNCATE financial_aggregates CASCADE")
seedDefaultWorkingHours(t)
}
@@ -457,3 +457,126 @@ func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error {
`)
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)
}
@@ -1122,6 +1122,616 @@ func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) {
}
}
// --- Tests for CleanupExpiredFinancialRecords ---
// TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a
// payment older than 7 years is aggregated and deleted.
func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
_, err = db.DB.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 50.00, $2)
`, bookingID, eightYearsAgo)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Errorf("expected payment to be deleted, got %d payment(s)", paymentCount)
}
// Verify financial_aggregates has 1 row
var aggCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 1 {
t.Errorf("expected 1 financial_aggregates row, got %d", aggCount)
}
// Verify aggregate has correct month and total
expectedMonth := time.Date(eightYearsAgo.Year(), eightYearsAgo.Month(), 1, 0, 0, 0, 0, time.UTC)
var aggMonth time.Time
var totalPayments float64
err = db.DB.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth, &totalPayments)
if err != nil {
t.Fatalf("failed to query aggregate: %v", err)
}
if !aggMonth.Equal(expectedMonth) {
t.Errorf("expected month %s, got %s", expectedMonth.Format("2006-01-02"), aggMonth.Format("2006-01-02"))
}
if totalPayments != 50.00 {
t.Errorf("expected total_payments 50.00, got %.2f", totalPayments)
}
}
// TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer verifies that
// an anonymized user's payment is NOT deleted when the 1-year buffer hasn't
// elapsed, even if the payment is older than 7 years.
func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) {
resetTestData(t)
ctx := context.Background()
guestID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = db.DB.Exec(ctx, `
UPDATE users
SET account_role = 'guest',
email = 'anon-' || id || '@anon.invalid',
updated_at = NOW() - INTERVAL '6 months'
WHERE id = $1
`, guestID)
if err != nil {
t.Fatalf("failed to anonymize user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, guestID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
fourYearsAgo := time.Now().AddDate(-4, 0, 0)
var paymentID string
err = db.DB.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 75.00, $2)
RETURNING id
`, bookingID, fourYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment still exists (neither 7yr nor 1yr conditions satisfied)
var paymentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 1 {
t.Error("expected payment to still exist")
}
// Verify no aggregate was created
var aggCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 0 {
t.Errorf("expected no aggregates, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years verifies that a
// payment older than 9 years is always deleted regardless of user status.
func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment created 9 years ago
nineYearsAgo := time.Now().AddDate(-9, 0, 0)
_, err = db.DB.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 60.00, $2)
`, bookingID, nineYearsAgo)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Errorf("expected payment to be deleted, got %d payment(s)", paymentCount)
}
// Verify financial_aggregates has 1 row
var aggCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 1 {
t.Errorf("expected 1 financial_aggregates row, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_AggregationCorrectTotals verifies that
// multiple payments in the same month are correctly aggregated by method and type.
func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// 3 payments 8 years ago, all in the same month
sameMonth := time.Now().AddDate(-8, 0, 0)
_ = sameMonth // used for all payments
// Payment 1: £50 cash
_, err = db.DB.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at)
VALUES ($1, 'full', 'cash', 'completed', 50.00, 0, $2)
`, bookingID, sameMonth)
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
// Payment 2: £30 online_square with £2.50 fees
_, err = db.DB.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at)
VALUES ($1, 'full', 'online_square', 'completed', 30.00, 2.50, $2)
`, bookingID, sameMonth)
if err != nil {
t.Fatalf("failed to create online payment: %v", err)
}
// Payment 3: £20 in_person_card
_, err = db.DB.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at)
VALUES ($1, 'full', 'in_person_card', 'completed', 20.00, 0, $2)
`, bookingID, sameMonth)
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify aggregate totals
var totalPayments, totalCash, totalOnline, totalInPerson, totalSquareFees float64
var bookingCount int
err = db.DB.QueryRow(ctx, `
SELECT total_payments, total_cash, total_online, total_in_person, total_square_fees, booking_count
FROM financial_aggregates
`).Scan(&totalPayments, &totalCash, &totalOnline, &totalInPerson, &totalSquareFees, &bookingCount)
if err != nil {
t.Fatalf("failed to query aggregate: %v", err)
}
if totalPayments != 100.00 {
t.Errorf("expected total_payments 100.00, got %.2f", totalPayments)
}
if totalCash != 50.00 {
t.Errorf("expected total_cash 50.00, got %.2f", totalCash)
}
if totalOnline != 30.00 {
t.Errorf("expected total_online 30.00, got %.2f", totalOnline)
}
if totalInPerson != 20.00 {
t.Errorf("expected total_in_person 20.00, got %.2f", totalInPerson)
}
if totalSquareFees != 2.50 {
t.Errorf("expected total_square_fees 2.50, got %.2f", totalSquareFees)
}
if bookingCount != 1 {
t.Errorf("expected booking_count 1, got %d", bookingCount)
}
}
// TestCleanupExpiredFinancialRecords_Idempotent verifies that running the
// cleanup function twice produces the same result (no double-counting).
func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment 8 years ago
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
_, err = db.DB.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 100.00, $2)
`, bookingID, eightYearsAgo)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// First run
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("first cleanup failed: %v", err)
}
// Capture aggregate values after first run
var aggCount1 int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount1)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
var totalPayments1 float64
var aggMonth1 time.Time
err = db.DB.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth1, &totalPayments1)
if err != nil {
t.Fatalf("failed to query aggregate after first run: %v", err)
}
// Second run
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("second cleanup failed: %v", err)
}
// Verify same aggregate count and values
var aggCount2 int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount2)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount2 != aggCount1 {
t.Errorf("expected %d aggregates after second run, got %d", aggCount1, aggCount2)
}
var totalPayments2 float64
var aggMonth2 time.Time
err = db.DB.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth2, &totalPayments2)
if err != nil {
t.Fatalf("failed to query aggregate after second run: %v", err)
}
if !aggMonth2.Equal(aggMonth1) {
t.Errorf("expected month %s, got %s", aggMonth1.Format("2006-01-02"), aggMonth2.Format("2006-01-02"))
}
if totalPayments2 != totalPayments1 {
t.Errorf("expected total_payments %.2f after second run, got %.2f", totalPayments1, totalPayments2)
}
// Verify payments are still deleted
var paymentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Errorf("expected payments to be deleted, got %d", paymentCount)
}
}
// TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years verifies that
// a payment newer than 7 years for an active user is NOT deleted.
func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment created 3 years ago (< 7 years)
threeYearsAgo := time.Now().AddDate(-3, 0, 0)
var paymentID string
err = db.DB.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 40.00, $2)
RETURNING id
`, bookingID, threeYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment still exists
var paymentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 1 {
t.Error("expected payment to still exist")
}
// Verify no aggregate was created
var aggCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 0 {
t.Errorf("expected no aggregates, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed verifies that
// an anonymized user's payment IS deleted when BOTH the 7-year rule AND the
// 1-year post-anonymization buffer have elapsed.
func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing.T) {
resetTestData(t)
ctx := context.Background()
guestID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Anonymize user 2 years ago
_, err = db.DB.Exec(ctx, `
UPDATE users
SET account_role = 'guest',
email = 'anon-' || id || '@anon.invalid',
updated_at = NOW() - INTERVAL '2 years'
WHERE id = $1
`, guestID)
if err != nil {
t.Fatalf("failed to anonymize user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, guestID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment created 8 years ago (past 7yr rule)
// User anonymized 2 years ago (past 1yr buffer)
// Both conditions met → should be deleted
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
var paymentID string
err = db.DB.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 75.00, $2)
RETURNING id
`, bookingID, eightYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Error("expected payment to be deleted (both 7yr and 1yr+ thresholds elapsed)")
}
// Verify aggregate was created
var aggCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 1 {
t.Errorf("expected 1 financial_aggregates row, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted verifies that
// refunds are aggregated and deleted alongside their parent payment when
// retention expires.
func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) {
resetTestData(t)
ctx := context.Background()
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment 8 years ago
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
var paymentID string
err = db.DB.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 100.00, $2)
RETURNING id
`, bookingID, eightYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Refund 8 years ago (same month as payment)
var refundID string
err = db.DB.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 30.00, 'completed', 'test refund', $3)
RETURNING id
`, paymentID, bookingID, eightYearsAgo).Scan(&refundID)
if err != nil {
t.Fatalf("failed to create refund: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Error("expected payment to be deleted")
}
// Verify refund was deleted
var refundCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE id = $1", refundID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to count refunds: %v", err)
}
if refundCount != 0 {
t.Error("expected refund to be deleted")
}
// Verify aggregate has both payment and refund totals
var totalPayments, totalRefunds float64
err = db.DB.QueryRow(ctx, "SELECT total_payments, total_refunds FROM financial_aggregates").Scan(&totalPayments, &totalRefunds)
if err != nil {
t.Fatalf("failed to query aggregate: %v", err)
}
if totalPayments != 100.00 {
t.Errorf("expected total_payments 100.00, got %.2f", totalPayments)
}
if totalRefunds != 30.00 {
t.Errorf("expected total_refunds 30.00, got %.2f", totalRefunds)
}
}
// Ensure bytes is used to avoid unused import error
var _ = bytes.Buffer{}
+2
View File
@@ -104,6 +104,7 @@ func Migrate(t *testing.T, pool *pgxpool.Pool) {
"user_saved_cards",
"square_deposits",
"affiliate_payouts",
"financial_aggregates",
"bookings",
"user_patch_tests",
"patch_tests",
@@ -211,6 +212,7 @@ func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
"user_saved_cards",
"square_deposits",
"affiliate_payouts",
"financial_aggregates",
"bookings",
"booking_edit_requests",
"user_patch_tests",