diff --git a/Distribution/Features/DayEndReport/DayEndReportController.cs b/Distribution/Features/DayEndReport/DayEndReportController.cs new file mode 100644 index 0000000..b49e2ec --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.DayEndReport; + +[ApiController] +[Route("api/reports/day-end")] +public class DayEndReportController(DayEndReportService service) : ControllerBase +{ + [HttpGet] + public async Task Day([FromQuery] DateTime? date) => + Ok(await service.GetDayAsync(date ?? DateTime.Now)); + + [HttpGet("daily")] + public async Task Daily([FromQuery] DateTime? from, [FromQuery] DateTime? to) + { + var f = DayEndReportService.BuildFilter(from, to); + var error = DayEndReportService.Validate(f); + return error is null ? Ok(await service.GetDailyAsync(f)) : BadRequest(new { message = error }); + } +} diff --git a/Distribution/Features/DayEndReport/DayEndReportDTOs.cs b/Distribution/Features/DayEndReport/DayEndReportDTOs.cs new file mode 100644 index 0000000..1094178 --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportDTOs.cs @@ -0,0 +1,51 @@ +using Distribution.Models; + +namespace Distribution.Features.DayEndReport; + +public record DayEndFilter(DateTime From, DateTime To); + +public record DayEndRow( + string Date, + decimal TotalSales, + decimal Collected, + decimal Due, + decimal Cash, + decimal Cheque, + decimal Credit, + decimal Returns, + decimal Expenses, + decimal Income, + int InvoiceCount, + int PaymentCount, + decimal CashCollected, + decimal CashExpensesPaid, + decimal NetCash); + +public record DayEndMethod(string Method, int Count, decimal Amount); + +public record DayEndExpense(Guid Id, string Category, string? Payee, string? Description, string? Method, decimal Amount); + +public record DayEndResponse( + string Date, + DayEndRow Summary, + List ByMethod, + List Expenses); + +public record DayEndDailyResponse(DateTime From, DateTime To, List Days, DayEndRow Totals); + +// Repository row shapes +public record DayEndInvoiceRow(Guid Id, DateTime FinalizedAt, decimal Total); + +public record DayEndPaymentRow(Guid InvoiceId, PaymentMethod Method, decimal Amount, DateTime CollectedAt); + +public record DayEndReturnRow(DateTime At, decimal Amount); + +public record DayEndExpenseRow( + Guid Id, DateTime BillDate, DateTime? PaidAt, PaymentMethod? Method, decimal Amount, + string Category, string? Payee, string? Description); + +public record DayEndData( + List Invoices, + List Payments, + List Returns, + List Expenses); diff --git a/Distribution/Features/DayEndReport/DayEndReportRepository.cs b/Distribution/Features/DayEndReport/DayEndReportRepository.cs new file mode 100644 index 0000000..9676093 --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportRepository.cs @@ -0,0 +1,47 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.DayEndReport; + +public class DayEndReportRepository(AppDBContext db) : IDayEndReportRepository +{ + public async Task GetDataAsync(DayEndFilter f) + { + var start = f.From.Date; + var end = f.To.Date.AddDays(1); + + var invoices = await db.Invoices.AsNoTracking() + .Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized + && i.ShopPO.Status != ShopPOStatus.Cancelled + && i.FinalizedAt >= start && i.FinalizedAt < end) + .Select(i => new DayEndInvoiceRow(i.Id, i.FinalizedAt!.Value, i.TotalAmount)) + .ToListAsync(); + + var payments = await db.Payments.AsNoTracking() + .Where(p => p.IsActive && p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled + && ((p.CollectedAt >= start && p.CollectedAt < end) + || (p.Invoice.Status == InvoiceStatus.Finalized + && p.Invoice.FinalizedAt >= start && p.Invoice.FinalizedAt < end))) + .Select(p => new DayEndPaymentRow(p.FK_InvoiceId, p.Method, p.Amount, p.CollectedAt)) + .ToListAsync(); + + var returns = await db.SaleReturns.AsNoTracking() + .Where(r => r.IsActive && r.CreatedAt >= start && r.CreatedAt < end) + .Select(r => new DayEndReturnRow(r.CreatedAt, r.TotalCreditAmount)) + .ToListAsync(); + + var expenses = await db.Expenses.AsNoTracking() + .Where(e => e.IsActive && (e.Status == ExpenseStatus.Posted || e.Status == ExpenseStatus.Paid) + && ((e.BillDate >= start && e.BillDate < end) + || (e.PaidAt >= start && e.PaidAt < end))) + .Select(e => new DayEndExpenseRow( + e.Id, e.BillDate, e.PaidAt, e.Method, e.Amount, + e.Category != null ? e.Category.Name : "", + e.Payee ?? (e.Vendor != null ? e.Vendor.Name : null), + e.Description)) + .ToListAsync(); + + return new DayEndData(invoices, payments, returns, expenses); + } +} diff --git a/Distribution/Features/DayEndReport/DayEndReportService.cs b/Distribution/Features/DayEndReport/DayEndReportService.cs new file mode 100644 index 0000000..df1e224 --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportService.cs @@ -0,0 +1,96 @@ +using Distribution.Models; + +namespace Distribution.Features.DayEndReport; + +public class DayEndReportService(IDayEndReportRepository repo) +{ + public const int MaxRangeDays = 92; + + public static DayEndFilter BuildFilter(DateTime? from, DateTime? to) + { + var end = (to ?? DateTime.Now).Date; + var start = (from ?? end.AddDays(-29)).Date; + return new DayEndFilter(start, end); + } + + public static string? Validate(DayEndFilter f) + { + if (f.To < f.From) return "'to' must not be before 'from'."; + if ((f.To - f.From).TotalDays >= MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days."; + return null; + } + + public async Task GetDayAsync(DateTime date) + { + var day = date.Date; + var data = await repo.GetDataAsync(new DayEndFilter(day, day)); + var row = BuildRow(day, data); + + var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList(); + var byMethod = Enum.GetValues().Select(m => + { + var rows = payments.Where(p => p.Method == m).ToList(); + return new DayEndMethod(m.ToString(), rows.Count, rows.Sum(p => p.Amount)); + }).ToList(); + + var expenses = data.Expenses + .Where(e => e.BillDate.Date == day) + .Select(e => new DayEndExpense(e.Id, e.Category, e.Payee, e.Description, e.Method?.ToString(), e.Amount)) + .ToList(); + + return new DayEndResponse(row.Date, row, byMethod, expenses); + } + + public async Task GetDailyAsync(DayEndFilter f) + { + var data = await repo.GetDataAsync(f); + var days = new List(); + for (var d = f.From.Date; d <= f.To.Date; d = d.AddDays(1)) + days.Add(BuildRow(d, data)); + + var totals = new DayEndRow( + "Total", + days.Sum(r => r.TotalSales), days.Sum(r => r.Collected), days.Sum(r => r.Due), + days.Sum(r => r.Cash), days.Sum(r => r.Cheque), days.Sum(r => r.Credit), + days.Sum(r => r.Returns), days.Sum(r => r.Expenses), days.Sum(r => r.Income), + days.Sum(r => r.InvoiceCount), days.Sum(r => r.PaymentCount), + days.Sum(r => r.CashCollected), days.Sum(r => r.CashExpensesPaid), days.Sum(r => r.NetCash)); + + return new DayEndDailyResponse(f.From, f.To, days, totals); + } + + // Accrual basis: Income = sales (finalized invoices) - returns - expenses billed that day. + // Due is the unpaid part of that day's invoices as at the end of the day; Credit payments never count as paid. + private static DayEndRow BuildRow(DateTime day, DayEndData data) + { + var next = day.AddDays(1); + + var invoices = data.Invoices.Where(i => i.FinalizedAt.Date == day).ToList(); + var invoiceIds = invoices.Select(i => i.Id).ToHashSet(); + var paidByInvoice = data.Payments + .Where(p => invoiceIds.Contains(p.InvoiceId) && p.Method != PaymentMethod.Credit && p.CollectedAt < next) + .GroupBy(p => p.InvoiceId) + .ToDictionary(g => g.Key, g => g.Sum(p => p.Amount)); + + var totalSales = invoices.Sum(i => i.Total); + var due = invoices.Sum(i => Math.Max(0m, i.Total - paidByInvoice.GetValueOrDefault(i.Id))); + + var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList(); + var cash = payments.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount); + var cheque = payments.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount); + var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount); + + var returns = data.Returns.Where(r => r.At.Date == day).Sum(r => r.Amount); + var expenses = data.Expenses.Where(e => e.BillDate.Date == day).Sum(e => e.Amount); + var cashExpensesPaid = data.Expenses + .Where(e => e.Method == PaymentMethod.Cash && e.PaidAt.HasValue && e.PaidAt.Value.Date == day) + .Sum(e => e.Amount); + + return new DayEndRow( + day.ToString("yyyy-MM-dd"), + totalSales, cash + cheque, due, cash, cheque, credit, + returns, expenses, totalSales - returns - expenses, + invoices.Count, payments.Count, + cash, cashExpensesPaid, cash - cashExpensesPaid); + } +} diff --git a/Distribution/Features/DayEndReport/IDayEndReportRepository.cs b/Distribution/Features/DayEndReport/IDayEndReportRepository.cs new file mode 100644 index 0000000..729ac36 --- /dev/null +++ b/Distribution/Features/DayEndReport/IDayEndReportRepository.cs @@ -0,0 +1,10 @@ +namespace Distribution.Features.DayEndReport; + +public interface IDayEndReportRepository +{ + /// + /// Everything needed for [From, To] (whole days): invoices finalized, payments collected (plus payments on + /// those invoices, for the due amount), sale returns, and Posted/Paid expenses billed or paid in the range. + /// + Task GetDataAsync(DayEndFilter f); +}