using Distribution.Data; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; using Microsoft.EntityFrameworkCore; namespace Distribution.Features.RecurringExpense; public class RecurringExpenseService( AppDBContext context, IRecurringExpenseRepository repo, IUnitOfWork uow, ICurrentUserService currentUser) { private static RecurringExpenseResponse Map(Models.RecurringExpenseTemplate t) => new(t.Id, t.Name, t.FK_CategoryId, t.Category?.Name ?? string.Empty, t.FK_WarehouseId, t.Warehouse?.Name, t.FK_VendorId, t.Vendor?.Name, t.Amount, t.Payee, t.Description, t.Frequency, t.DayOfMonth, t.DayOfWeek, t.StartDate, t.EndDate, t.NextRunDate, t.AutoPost, t.IsPaused, t.IsActive, t.CreatedAt); public async Task ListAsync(int page, int pageSize) { if (page < 1) page = 1; if (pageSize < 1 || pageSize > 200) pageSize = 20; var items = await repo.ListAsync(page, pageSize); var total = await repo.CountAsync(); return new PagedRecurringExpensesResponse(items.Select(Map).ToList(), page, pageSize, total); } public async Task CreateAsync(CreateRecurringExpenseRequest request, CancellationToken ct = default) { ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek); if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero"); var category = await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct) ?? throw new InvalidOperationException("Category not found"); if (category.FK_ParentCategoryId is null) throw new InvalidOperationException("Pick a specific title under a category"); var entity = new Models.RecurringExpenseTemplate { Name = request.Name.Trim(), FK_CategoryId = request.CategoryId, FK_WarehouseId = request.WarehouseId, FK_VendorId = request.VendorId, Amount = request.Amount, Payee = request.Payee, Description = request.Description, Frequency = request.Frequency, DayOfMonth = request.DayOfMonth, DayOfWeek = request.DayOfWeek, StartDate = request.StartDate.Date, EndDate = request.EndDate?.Date, NextRunDate = request.StartDate.Date, AutoPost = request.AutoPost }; await uow.BeginAsync(ct); try { await repo.AddAsync(entity); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return Map((await repo.GetByIdAsync(entity.Id))!); } public async Task UpdateAsync(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct = default) { var entity = await repo.GetByIdAsync(id); if (entity is null) return null; ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek); if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero"); entity.Name = request.Name.Trim(); entity.FK_CategoryId = request.CategoryId; entity.FK_WarehouseId = request.WarehouseId; entity.FK_VendorId = request.VendorId; entity.Amount = request.Amount; entity.Payee = request.Payee; entity.Description = request.Description; entity.Frequency = request.Frequency; entity.DayOfMonth = request.DayOfMonth; entity.DayOfWeek = request.DayOfWeek; entity.StartDate = request.StartDate.Date; entity.EndDate = request.EndDate?.Date; entity.AutoPost = request.AutoPost; await uow.BeginAsync(ct); try { repo.Update(entity); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return Map((await repo.GetByIdAsync(id))!); } public async Task ToggleAsync(Guid id, bool paused, CancellationToken ct = default) { var entity = await repo.GetByIdAsync(id); if (entity is null) return null; entity.IsPaused = paused; await uow.BeginAsync(ct); try { repo.Update(entity); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return Map(entity); } /// /// Materializes every active, non-paused template whose NextRunDate ≤ today /// into an Expense row, then advances NextRunDate by one period. Idempotent /// per call — advances only after a successful insert. /// public async Task RunDueAsync(CancellationToken ct = default) { var today = DateTime.Today; var due = await repo.ListDueAsync(today); if (due.Count == 0) return new RunDueResponse(0, []); var userId = currentUser.UserId ?? throw new InvalidOperationException("Authenticated user is required"); var createdIds = new List(); await uow.BeginAsync(ct); try { foreach (var t in due) { var expense = new Models.Expense { FK_CategoryId = t.FK_CategoryId, FK_WarehouseId = t.FK_WarehouseId, FK_VendorId = t.FK_VendorId, Amount = t.Amount, BillDate = t.NextRunDate, PeriodCovered = new DateTime(t.NextRunDate.Year, t.NextRunDate.Month, 1), Payee = t.Payee, Description = t.Description, Status = t.AutoPost ? ExpenseStatus.Posted : ExpenseStatus.Draft, RecordedByUserId = userId, PostedByUserId = t.AutoPost ? userId : null, PostedAt = t.AutoPost ? DateTime.Now : null, FK_RecurringTemplateId = t.Id }; await context.Expenses.AddAsync(expense, ct); createdIds.Add(expense.Id); t.NextRunDate = AdvanceNext(t.NextRunDate, t.Frequency); repo.Update(t); } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return new RunDueResponse(createdIds.Count, createdIds); } private static DateTime AdvanceNext(DateTime from, RecurringFrequency freq) => freq switch { RecurringFrequency.Weekly => from.AddDays(7), RecurringFrequency.Monthly => from.AddMonths(1), RecurringFrequency.Quarterly => from.AddMonths(3), RecurringFrequency.Yearly => from.AddYears(1), _ => throw new InvalidOperationException($"Unknown frequency {freq}") }; private static void ValidateSchedule(RecurringFrequency freq, int? dayOfMonth, int? dayOfWeek) { if (freq == RecurringFrequency.Weekly) { if (dayOfWeek is null or < 0 or > 6) throw new InvalidOperationException("DayOfWeek (0-6) is required for Weekly"); } else { if (dayOfMonth is null or < 1 or > 31) throw new InvalidOperationException("DayOfMonth (1-31) is required for Monthly/Quarterly/Yearly"); } } }