using Distribution.Data; using Distribution.Features.BankAccount; using Distribution.Features.ChequeBook; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; using Microsoft.EntityFrameworkCore; namespace Distribution.Features.Expense; public class ExpenseService( AppDBContext context, IExpenseRepository repo, IBankAccountRepository bankAccountRepo, IChequeBookRepository chequeBookRepo, IUnitOfWork uow, ICurrentUserService currentUser) { private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) => new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt); private static ExpenseResponse ToResponse(Models.Expense e) => new(e.Id, e.FK_CategoryId, e.Category?.Name ?? string.Empty, e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name, e.FK_WarehouseId, e.Warehouse?.Name, e.Amount, e.BillDate, e.DueDate, e.PeriodCovered, e.FK_VendorId, e.Vendor?.Name, e.Payee, e.BillRef, e.Description, e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo, e.PaidAt, e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason, e.FK_RecurringTemplateId, e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(), e.CreatedAt); public async Task ListAsync( ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to, int page, int pageSize) { if (page < 1) page = 1; if (pageSize < 1 || pageSize > 200) pageSize = 20; var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize); var total = await repo.CountAsync(status, categoryId, warehouseId, from, to); return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total); } public async Task GetAsync(Guid id) { var e = await repo.GetByIdAsync(id); return e is null ? null : ToResponse(e); } public async Task CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default) { ValidateBase(request); var category = await context.ExpenseCategories.Include(c => c.Parent) .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, not the category itself"); var userId = currentUser.UserId ?? throw new InvalidOperationException("Authenticated user is required"); var entity = new Models.Expense { FK_CategoryId = request.CategoryId, FK_WarehouseId = request.WarehouseId, Amount = request.Amount, BillDate = request.BillDate.Date, DueDate = request.DueDate?.Date, PeriodCovered = request.PeriodCovered?.Date, FK_VendorId = request.VendorId, Payee = request.Payee, BillRef = request.BillRef, Description = request.Description, RecordedByUserId = userId, Status = ExpenseStatus.Draft }; await uow.BeginAsync(ct); try { await repo.AddAsync(entity); if (action is ExpenseAction.Post or ExpenseAction.Pay) { entity.Status = ExpenseStatus.Posted; entity.PostedByUserId = userId; entity.PostedAt = DateTime.Now; } if (action == ExpenseAction.Pay) { if (request.Payment is null) throw new InvalidOperationException("Payment details are required when action = Pay"); await ApplyPaymentAsync(entity, request.Payment, userId, ct); } await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse((await repo.GetByIdAsync(entity.Id))!); } public async Task UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default) { var entity = await repo.GetByIdAsync(id); if (entity is null) return null; if (entity.Status != ExpenseStatus.Draft) throw new InvalidOperationException("Only Draft expenses can be edited"); if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero"); entity.FK_CategoryId = request.CategoryId; entity.FK_WarehouseId = request.WarehouseId; entity.Amount = request.Amount; entity.BillDate = request.BillDate.Date; entity.DueDate = request.DueDate?.Date; entity.PeriodCovered = request.PeriodCovered?.Date; entity.FK_VendorId = request.VendorId; entity.Payee = request.Payee; entity.BillRef = request.BillRef; entity.Description = request.Description; await uow.BeginAsync(ct); try { repo.Update(entity); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse((await repo.GetByIdAsync(id))!); } public async Task PostAsync(Guid id, CancellationToken ct = default) { var entity = await repo.GetByIdAsync(id); if (entity is null) return null; if (entity.Status != ExpenseStatus.Draft) throw new InvalidOperationException("Only Draft expenses can be posted"); var userId = currentUser.UserId ?? throw new InvalidOperationException("Authenticated user is required"); entity.Status = ExpenseStatus.Posted; entity.PostedByUserId = userId; entity.PostedAt = DateTime.Now; await uow.BeginAsync(ct); try { repo.Update(entity); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse((await repo.GetByIdAsync(id))!); } public async Task PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default) { var entity = await repo.GetByIdAsync(id); if (entity is null) return null; if (entity.Status != ExpenseStatus.Posted) throw new InvalidOperationException("Only Posted expenses can be paid"); var userId = currentUser.UserId ?? throw new InvalidOperationException("Authenticated user is required"); await uow.BeginAsync(ct); try { await ApplyPaymentAsync(entity, payment, userId, ct); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse((await repo.GetByIdAsync(id))!); } public async Task VoidAsync(Guid id, string? reason, CancellationToken ct = default) { var entity = await repo.GetByIdAsync(id); if (entity is null) return null; if (entity.Status == ExpenseStatus.Void) throw new InvalidOperationException("Already voided"); if (entity.Status == ExpenseStatus.Paid) throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead"); var userId = currentUser.UserId ?? throw new InvalidOperationException("Authenticated user is required"); entity.Status = ExpenseStatus.Void; entity.VoidedByUserId = userId; entity.VoidedAt = DateTime.Now; entity.VoidReason = reason; await uow.BeginAsync(ct); try { repo.Update(entity); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToResponse((await repo.GetByIdAsync(id))!); } // ─── helpers ────────────────────────────────────────────────────────────── private static void ValidateBase(CreateExpenseRequest request) { if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero"); if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date) throw new InvalidOperationException("Due date cannot be earlier than bill date"); } /// /// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account); /// Cheque → consume an Unused ChequePage from an Active cheque book. /// private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct) { if (payment.Method == PaymentMethod.Credit) throw new InvalidOperationException("Credit is not a valid method for expense payments"); if (payment.Method == PaymentMethod.Cash) { if (payment.BankAccountId is null) throw new InvalidOperationException("BankAccountId is required when Method is Cash"); var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value) ?? throw new InvalidOperationException("BankAccountId does not reference a valid account"); if (account.IsActive is false) throw new InvalidOperationException("The selected account is inactive"); entity.FK_BankAccountId = account.Id; } else // Cheque { if (payment.ChequePageId is null) throw new InvalidOperationException("ChequePageId is required when Method is Cheque"); var page = await context.ChequePages .Include(p => p.ChequeBook) .FirstOrDefaultAsync(p => p.Id == payment.ChequePageId.Value, ct) ?? throw new InvalidOperationException("ChequePageId does not reference a valid cheque page"); if (page.IssueStatus != ChequePageIssueStatus.Unused) throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'"); if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active) throw new InvalidOperationException("The cheque book is not active"); page.IssueStatus = ChequePageIssueStatus.Issued; page.PayeeType = PayeeType.Other; page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense"; page.IssueDate = DateTime.Now; page.Amount = entity.Amount; page.CurrencyCode = "LKR"; page.PrintedAt = DateTime.Now; chequeBookRepo.UpdatePage(page); entity.FK_ChequePageId = page.Id; entity.ChequePage = page; } entity.Method = payment.Method; entity.Status = ExpenseStatus.Paid; entity.PaidByUserId = userId; entity.PaidAt = DateTime.Now; // TODO ledger: when accounting is wired in, post a journal entry here — // Debit Expense (category) // Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments) } }