diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index cef705d..76b7808 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -9,15 +9,16 @@ import ( "time" "crussell/db" + "crussell/internal/validators" "crussell/mw" "log" ) // --- Types --- type DefaultHours struct { - Weekday int `json:"weekday"` - StartTime string `json:"startTime"` - EndTime string `json:"endTime"` + Weekday int `json:"weekday" validate:"gte=0,lte=6"` + StartTime string `json:"startTime" validate:"required"` + EndTime string `json:"endTime" validate:"required"` IsOpen bool `json:"isOpen"` } @@ -63,6 +64,16 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) { return } + for _, h := range hours { + if err := validators.Validate.Struct(&h); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + + // M8 + // L5 + for _, h := range hours { if !isValidTime15Min(h.StartTime) { http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest) @@ -425,6 +436,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { ) sub), 0) AS total_duration FROM bookings b WHERE b.start_time >= $1 AND b.start_time <= $2 + AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'pending_release', 'deposit_lapsed') ORDER BY b.start_time `, start, end) diff --git a/backend/handlers/scheduling/exceptional-hours.go b/backend/handlers/scheduling/exceptional-hours.go index 60ed734..a6ad04c 100644 --- a/backend/handlers/scheduling/exceptional-hours.go +++ b/backend/handlers/scheduling/exceptional-hours.go @@ -7,23 +7,24 @@ import ( "time" "crussell/db" + "crussell/internal/validators" ) type ExceptionalHours struct { ID int `json:"id"` GroupID int `json:"groupId"` - Weekday int `json:"weekday"` - StartTime string `json:"startTime"` - EndTime string `json:"endTime"` + Weekday int `json:"weekday" validate:"gte=0,lte=6"` + StartTime string `json:"startTime" validate:"required"` + EndTime string `json:"endTime" validate:"required"` IsOpen bool `json:"isOpen"` } type ExceptionalGroup struct { ID int `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Hours []ExceptionalHours `json:"hours,omitempty"` - WeekStarts []string `json:"weekStarts,omitempty"` + Name string `json:"name" validate:"required"` + Description string `json:"description" validate:"required"` + Hours []ExceptionalHours `json:"hours,omitempty" validate:"required,min=7,max=7,dive"` + WeekStarts []string `json:"weekStarts,omitempty" validate:"required,min=1,dive,required"` } // --- List Groups with Hours and Applications --- @@ -122,6 +123,14 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) { return } + if err := validators.Validate.Struct(&g); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + if len(g.Hours) != 7 { http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest) return @@ -262,8 +271,8 @@ func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) { // --- Update Applied Weeks (replaces all applications for a group) --- func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) { var req struct { - GroupID int `json:"groupId"` - WeekStarts []string `json:"weekStarts"` + GroupID int `json:"groupId" validate:"required"` + WeekStarts []string `json:"weekStarts" validate:"required,min=1,dive,required"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -271,6 +280,14 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) { return } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // M8 + // L5 + // Validate and parse weeks var parsedWeeks []time.Time ukLocation, _ := time.LoadLocation("Europe/London") diff --git a/backend/handlers/scheduling/scheduling_test.go b/backend/handlers/scheduling/scheduling_test.go index 6c0c5cc..ab08a5b 100644 --- a/backend/handlers/scheduling/scheduling_test.go +++ b/backend/handlers/scheduling/scheduling_test.go @@ -705,7 +705,6 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) { } } - // TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users // CAN see blocked time slots in the blockers field. func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) { diff --git a/backend/handlers/scheduling/testmain_test.go b/backend/handlers/scheduling/testmain_test.go index 8d0d272..5a91f05 100644 --- a/backend/handlers/scheduling/testmain_test.go +++ b/backend/handlers/scheduling/testmain_test.go @@ -8,8 +8,8 @@ import ( "testing" "crussell/db" - "crussell/testutils/testdb" "crussell/testutils/jwt" + "crussell/testutils/testdb" ) func TestMain(m *testing.M) { @@ -23,4 +23,4 @@ func TestMain(m *testing.M) { code := m.Run() pool.Close() os.Exit(code) -} \ No newline at end of file +} diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index 70b003f..917a1f8 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -358,6 +358,7 @@ func CleanupOldReservations(ctx context.Context) error { 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 } @@ -581,11 +582,14 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error { return tx.Commit(ctx) } -// CleanupExpiredDeposits cancels bookings where the deposit deadline (24h before -// start_time) has passed without payment. Confirmed bookings are set to 'no_deposit' -// with an admin notification. Pending bookings are silently cancelled. +// 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. // -// TODO: notify the user once the notification system (email/SMS) is set up. +// 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.DB.Begin(ctx) if err != nil { @@ -593,10 +597,15 @@ func CleanupExpiredDeposits(ctx context.Context) error { } defer tx.Rollback(ctx) - // Confirmed bookings past deposit deadline → no_deposit - _, err = tx.Exec(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 = 'no_deposit', updated_at = NOW() + SET status = 'pending_release', updated_at = NOW() WHERE deposit_required = true AND status = 'confirmed' AND start_time - INTERVAL '24 hours' < NOW() @@ -606,15 +615,25 @@ func CleanupExpiredDeposits(ctx context.Context) error { 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 → silent cancel - _, err = tx.Exec(ctx, ` + // Pending bookings past deposit deadline → pending_release + rows, err = tx.Query(ctx, ` UPDATE bookings - SET status = 'client_cancelled', updated_at = NOW() + SET status = 'pending_release', updated_at = NOW() WHERE deposit_required = true AND status = 'pending' AND start_time - INTERVAL '24 hours' < NOW() @@ -624,37 +643,56 @@ func CleanupExpiredDeposits(ctx context.Context) error { AND p.status = 'completed' AND p.created_at < bookings.start_time ) + RETURNING id, user_id `) if err != nil { - return fmt.Errorf("failed to cancel pending expired bookings: %w", err) + 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 } - // Create admin notifications for no_deposit bookings _, err = tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, booking_id, user_id) - SELECT 'no_deposit', id, user_id - FROM bookings - WHERE status = 'no_deposit' - AND updated_at = NOW() - AND NOT EXISTS ( - SELECT 1 FROM admin_notifications an - WHERE an.booking_id = bookings.id AND an.reason = 'no_deposit' - ) - `) + 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 no_deposit notifications: %w", err) + return fmt.Errorf("failed to create pending_release notifications: %w", err) } - // Clean up reservation time_blockers for affected bookings _, err = tx.Exec(ctx, ` DELETE FROM time_blockers tb - USING bookings b - WHERE tb.description LIKE 'RESERVATION:user:' || b.user_id || '%' - AND b.status IN ('no_deposit', 'client_cancelled') - AND b.updated_at = NOW() - `) + 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 expired deposits: %w", err) + return fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err) } return tx.Commit(ctx) @@ -714,30 +752,34 @@ func CleanupExpiredGiftCards(ctx context.Context) error { expiredCards = append(expiredCards, card) } - for _, card := range expiredCards { - _, err = tx.Exec(ctx, ` - INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) - VALUES (NULL, $1, NOW()) - `, card.balance) - if err != nil { - return fmt.Errorf("failed to insert expired balance for card %s: %w", card.id, err) + 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 } - _, err = tx.Exec(ctx, ` + 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 = $1 - `, card.id) - if err != nil { - return fmt.Errorf("failed to zero out expired card %s: %w", card.id, err) + WHERE id = ANY($1) + `, ids); err != nil { + return fmt.Errorf("failed to batch zero expired cards: %w", err) } - _, err = tx.Exec(ctx, ` - INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes) - VALUES ($1, 'expire', $2, 'system', NULL, NULL, 'card expired after 24 months unused') - `, card.id, card.balance) - if err != nil { - return fmt.Errorf("failed to record expire transaction for card %s: %w", card.id, 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) } } @@ -747,9 +789,9 @@ func CleanupExpiredGiftCards(ctx context.Context) error { // 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) +// - 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 @@ -767,9 +809,11 @@ func CleanupExpiredGiftCards(ctx context.Context) error { // - 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' +// +// 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.DB.Begin(ctx) @@ -809,27 +853,33 @@ func CleanupIdleAccounts(ctx context.Context) error { accountsWithBalance = append(accountsWithBalance, acc) } - for _, acc := range accountsWithBalance { - _, err = tx.Exec(ctx, ` - INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at) - VALUES ($1, $2, NOW()) - `, acc.id, acc.balance) - if err != nil { - return fmt.Errorf("failed to insert expired balance for account %s: %w", acc.id, err) + 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 } - _, err = tx.Exec(ctx, ` + 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 = $1 - `, acc.id) - if err != nil { - return fmt.Errorf("failed to zero balance for account %s: %w", acc.id, err) + WHERE user_id = ANY($1) + `, ids); err != nil { + return fmt.Errorf("failed to batch zero account balances: %w", err) } - _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", acc.id) - if err != nil { - return fmt.Errorf("failed to anonymize idle account %s: %w", acc.id, 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) + } } } @@ -849,7 +899,6 @@ func CleanupIdleAccounts(ctx context.Context) error { defer rowsNoBalance.Close() var accountsNoBalance []string - for rowsNoBalance.Next() { var id string if err := rowsNoBalance.Scan(&id); err != nil { @@ -857,10 +906,10 @@ func CleanupIdleAccounts(ctx context.Context) error { } accountsNoBalance = append(accountsNoBalance, id) } + rowsNoBalance.Close() for _, id := range accountsNoBalance { - _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id) - if err != nil { + if _, err = tx.Exec(ctx, "SELECT anonymize_user($1)", id); err != nil { return fmt.Errorf("failed to anonymize idle account %s: %w", id, err) } } diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index eb9890b..9d31853 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -1859,9 +1859,6 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) { // --- Tests for CleanupExpiredDeposits --- -// TestCleanupExpiredDeposits_ExpiredConfirmed verifies that a confirmed booking -// past its deposit deadline with no payment gets set to 'no_deposit', creates -// an admin notification, and cleans up the reservation time blocker. func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { resetTestData(t) @@ -1872,7 +1869,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { t.Fatalf("failed to create user: %v", err) } - // Create booking past deposit deadline (e.g. starting 12h from now, deadline was 12h ago) startTime := time.Now().Add(12 * time.Hour) var bookingID string err = db.DB.QueryRow(ctx, ` @@ -1884,7 +1880,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { t.Fatalf("failed to create booking: %v", err) } - // Create reservation time blocker _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) @@ -1893,33 +1888,29 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { t.Fatalf("failed to create reservation time blocker: %v", err) } - // Run cleanup err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } - // Verify status updated to 'no_deposit' var status string err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } - if status != "no_deposit" { - t.Errorf("expected status 'no_deposit', got '%s'", status) + if status != "pending_release" { + t.Errorf("expected status 'pending_release', got '%s'", status) } - // Verify admin notification was created var notifCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(¬ifCount) + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin notifications: %v", err) } if notifCount != 1 { - t.Errorf("expected 1 no_deposit admin notification, got %d", notifCount) + t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount) } - // Verify reservation time blocker was deleted var tbCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { @@ -1930,8 +1921,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) { } } -// TestCleanupExpiredDeposits_ExpiredPending verifies that a pending booking -// past its deposit deadline with no payment gets silently cancelled. func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { resetTestData(t) @@ -1953,7 +1942,6 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { t.Fatalf("failed to create booking: %v", err) } - // Create reservation time blocker _, err = db.DB.Exec(ctx, ` INSERT INTO time_blockers (start_time, duration_minutes, description) VALUES ($1, 60, $2) @@ -1962,33 +1950,29 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) { t.Fatalf("failed to create reservation time blocker: %v", err) } - // Run cleanup err = CleanupExpiredDeposits(ctx) if err != nil { t.Fatalf("CleanupExpiredDeposits failed: %v", err) } - // Verify status updated to 'client_cancelled' (silent cancel) var status string err = db.DB.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) if err != nil { t.Fatalf("failed to query booking: %v", err) } - if status != "client_cancelled" { - t.Errorf("expected status 'client_cancelled', got '%s'", status) + if status != "pending_release" { + t.Errorf("expected status 'pending_release', got '%s'", status) } - // Verify NO admin notification was created var notifCount int - err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'no_deposit'", bookingID).Scan(¬ifCount) + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(¬ifCount) if err != nil { t.Fatalf("failed to query admin notifications: %v", err) } - if notifCount != 0 { - t.Errorf("expected 0 no_deposit admin notifications, got %d", notifCount) + if notifCount != 1 { + t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount) } - // Verify reservation time blocker was deleted var tbCount int err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount) if err != nil { @@ -2651,3 +2635,198 @@ func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) { t.Error("expected old till_sale's idempotency_key to be cleared") } } + +// ============================================================================= +// CleanupExpiredDeposits - pending_release status +// ============================================================================= + +func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + soon := time.Now().Add(1 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to set booking: %v", err) + } + + if err := CleanupExpiredDeposits(context.Background()); err != nil { + t.Fatalf("CleanupExpiredDeposits failed: %v", err) + } + + var status string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if status != "pending_release" { + t.Errorf("expected status 'pending_release', got %q", status) + } +} + +func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) { + resetTestData(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create service: %v", err) + } + t.Cleanup(func() { fixtures.DeleteService(db.DB, serviceID) }) + + soon := time.Now().Add(1 * time.Hour) + bookingID, err := fixtures.CreateTestBookingAtTime(db.DB, userID, serviceID, soon) + if err != nil { + t.Fatalf("failed to create booking: %v", err) + } + t.Cleanup(func() { fixtures.DeleteBooking(db.DB, bookingID) }) + + _, err = db.DB.Exec(context.Background(), + "UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to set booking: %v", err) + } + + paymentID, err := fixtures.CreateTestPayment(db.DB, bookingID, 50, "online_square", "deposit", "completed") + if err != nil { + t.Fatalf("failed to create payment: %v", err) + } + t.Cleanup(func() { fixtures.DeletePayment(db.DB, paymentID) }) + + if err := CleanupExpiredDeposits(context.Background()); err != nil { + t.Fatalf("CleanupExpiredDeposits failed: %v", err) + } + + var status string + err = db.DB.QueryRow(context.Background(), + "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status) + if err != nil { + t.Fatalf("failed to query booking status: %v", err) + } + if status != "confirmed" { + t.Errorf("expected status 'confirmed' (deposit was paid), got %q", status) + } +} + +// ============================================================================= +// CleanupExpiredLoyaltyRedemptions Tests +// ============================================================================= + +func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(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) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + // Insert expired pending redemption + _, err = db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, status, expires_at) + VALUES ($1, 'pending', NOW() - INTERVAL '1 day') + `, userID) + if err != nil { + t.Fatalf("failed to insert expired redemption: %v", err) + } + + // Insert non-expired pending redemption (should be preserved) + _, err = db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, status, expires_at) + VALUES ($1, 'pending', NOW() + INTERVAL '7 days') + `, userID) + if err != nil { + t.Fatalf("failed to insert active redemption: %v", err) + } + + // Insert applied redemption (different status, should be preserved) + _, err = db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, status, expires_at) + VALUES ($1, 'applied', NOW() - INTERVAL '1 day') + `, userID) + if err != nil { + t.Fatalf("failed to insert applied redemption: %v", err) + } + + err = CleanupExpiredLoyaltyRedemptions(ctx) + if err != nil { + t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) + } + + var remaining int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&remaining) + if err != nil { + t.Fatalf("failed to count redemptions: %v", err) + } + if remaining != 2 { + t.Errorf("expected 2 remaining redemptions (active + applied), got %d", remaining) + } +} + +func TestCleanupExpiredLoyaltyRedemptions_NoExpired(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) + } + t.Cleanup(func() { fixtures.DeleteUser(db.DB, userID) }) + + // Only active redemptions + _, err = db.DB.Exec(ctx, ` + INSERT INTO loyalty_redemptions (user_id, status, expires_at) + VALUES ($1, 'pending', NOW() + INTERVAL '7 days') + `, userID) + if err != nil { + t.Fatalf("failed to insert active redemption: %v", err) + } + + err = CleanupExpiredLoyaltyRedemptions(ctx) + if err != nil { + t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) + } + + var count int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&count) + if err != nil { + t.Fatalf("failed to count: %v", err) + } + if count != 1 { + t.Errorf("expected 1 remaining (no expired), got %d", count) + } +} + +func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) { + resetTestData(t) + + err := CleanupExpiredLoyaltyRedemptions(context.Background()) + if err != nil { + t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) + } +}