diff --git a/Distribution/Features/PaymentReport/IPaymentReportRepository.cs b/Distribution/Features/PaymentReport/IPaymentReportRepository.cs new file mode 100644 index 0000000..964b585 --- /dev/null +++ b/Distribution/Features/PaymentReport/IPaymentReportRepository.cs @@ -0,0 +1,13 @@ +namespace Distribution.Features.PaymentReport; + +public interface IPaymentReportRepository +{ + /// Active payments collected within [From, To] (whole days). + Task> GetPaymentsAsync(PaymentReportFilter f); + + /// Finalized, non-cancelled invoices up to the end of 'To', each with cash/cheque paid as of then. + Task> GetInvoiceBalancesAsync(PaymentReportFilter f); + + /// Sum of TotalAmount of invoices finalized within [From, To]. + Task GetInvoicedAsync(PaymentReportFilter f); +} diff --git a/Distribution/Features/PaymentReport/PaymentReportController.cs b/Distribution/Features/PaymentReport/PaymentReportController.cs new file mode 100644 index 0000000..d61679f --- /dev/null +++ b/Distribution/Features/PaymentReport/PaymentReportController.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.PaymentReport; + +[ApiController] +[Route("api/reports/payments")] +public class PaymentReportController(PaymentReportService service) : ControllerBase +{ + [HttpGet("analytics")] + public async Task Analytics( + [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] Guid? regionId) + { + var f = PaymentReportService.BuildFilter(from, to, regionId); + var error = PaymentReportService.Validate(f); + return error is null ? Ok(await service.GetAnalyticsAsync(f)) : BadRequest(new { message = error }); + } +} diff --git a/Distribution/Features/PaymentReport/PaymentReportDTOs.cs b/Distribution/Features/PaymentReport/PaymentReportDTOs.cs new file mode 100644 index 0000000..d13f7f9 --- /dev/null +++ b/Distribution/Features/PaymentReport/PaymentReportDTOs.cs @@ -0,0 +1,42 @@ +using Distribution.Models; + +namespace Distribution.Features.PaymentReport; + +public record PaymentReportFilter(DateTime From, DateTime To, Guid? RegionId); + +public record PaymentSummary( + decimal Invoiced, + decimal Collected, + decimal CreditExtended, + decimal Outstanding, + decimal Overdue, + decimal? CollectionRatePct, + int PaymentCount); + +public record MethodBreakdown(string Method, int Count, decimal Amount, decimal SharePct); + +public record PaymentTrendPoint(string Period, decimal Cash, decimal Cheque, decimal Credit); + +public record AgingBucket(string Bucket, int Invoices, decimal Balance); + +public record StoreOutstanding(Guid StoreId, string StoreName, int Invoices, decimal Balance, decimal Overdue); + +public record AccountCollection(Guid? AccountId, string AccountName, int Count, decimal Amount); + +public record ChequePipeline(string Status, int Count, decimal Amount); + +public record PaymentAnalyticsResponse( + DateTime From, + DateTime To, + PaymentSummary Summary, + List ByMethod, + List Trend, + List Aging, + List TopOutstanding, + List ByAccount, + List Cheques); + +// Repository row shapes +public record PaymentRow(PaymentMethod Method, decimal Amount, DateTime CollectedAt, Guid? AccountId, string? AccountName, string? ChequeStatus); + +public record InvoiceBalanceRow(Guid StoreId, string StoreName, DateTime FinalizedAt, decimal Total, decimal Paid); diff --git a/Distribution/Features/PaymentReport/PaymentReportRepository.cs b/Distribution/Features/PaymentReport/PaymentReportRepository.cs new file mode 100644 index 0000000..99dd21b --- /dev/null +++ b/Distribution/Features/PaymentReport/PaymentReportRepository.cs @@ -0,0 +1,59 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.PaymentReport; + +public class PaymentReportRepository(AppDBContext db) : IPaymentReportRepository +{ + public async Task> GetPaymentsAsync(PaymentReportFilter f) + { + var start = f.From.Date; + var end = f.To.Date.AddDays(1); + var q = db.Payments.AsNoTracking() + .Where(p => p.IsActive && p.CollectedAt >= start && p.CollectedAt < end + && p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled); + if (f.RegionId.HasValue) + q = q.Where(p => p.Invoice.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value); + + return await q.Select(p => new PaymentRow( + p.Method, p.Amount, p.CollectedAt, + p.FK_BankAccountId, + p.BankAccount == null ? null : p.BankAccount.AccountName, + p.ReceivedCheque == null ? null : p.ReceivedCheque.Status.ToString())) + .ToListAsync(); + } + + public async Task> GetInvoiceBalancesAsync(PaymentReportFilter f) + { + var end = f.To.Date.AddDays(1); + var q = db.Invoices.AsNoTracking() + .Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized + && i.ShopPO.Status != ShopPOStatus.Cancelled && i.FinalizedAt < end); + if (f.RegionId.HasValue) + q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value); + + // Credit payments do not reduce the receivable (same rule as the dashboard). + return await q.Select(i => new InvoiceBalanceRow( + i.ShopPO.Store.Id, + i.ShopPO.Store.StoreName, + i.FinalizedAt!.Value, + i.TotalAmount, + i.Payments.Where(p => p.IsActive && p.Method != PaymentMethod.Credit && p.CollectedAt < end) + .Sum(p => (decimal?)p.Amount) ?? 0m)) + .ToListAsync(); + } + + public async Task GetInvoicedAsync(PaymentReportFilter f) + { + var start = f.From.Date; + var end = f.To.Date.AddDays(1); + var q = db.Invoices.AsNoTracking() + .Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized + && i.ShopPO.Status != ShopPOStatus.Cancelled + && i.FinalizedAt >= start && i.FinalizedAt < end); + if (f.RegionId.HasValue) + q = q.Where(i => i.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value); + return await q.SumAsync(i => (decimal?)i.TotalAmount) ?? 0m; + } +} diff --git a/Distribution/Features/PaymentReport/PaymentReportService.cs b/Distribution/Features/PaymentReport/PaymentReportService.cs new file mode 100644 index 0000000..fc614c3 --- /dev/null +++ b/Distribution/Features/PaymentReport/PaymentReportService.cs @@ -0,0 +1,105 @@ +using Distribution.Models; + +namespace Distribution.Features.PaymentReport; + +public class PaymentReportService(IPaymentReportRepository repo) +{ + public const int MaxRangeDays = 366; + private const int DailyGranularityMaxDays = 92; + private const int OverdueDays = 30; // matches the dashboard receivables definition + private const int TopStores = 10; + + public static PaymentReportFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId) + { + var end = (to ?? DateTime.Now).Date; + var start = (from ?? end.AddDays(-29)).Date; + return new PaymentReportFilter(start, end, regionId); + } + + public static string? Validate(PaymentReportFilter 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; + } + + private static decimal Pct(decimal part, decimal whole) => whole > 0 ? Math.Round(part / whole * 100, 2) : 0m; + + public async Task GetAnalyticsAsync(PaymentReportFilter f) + { + var payments = await repo.GetPaymentsAsync(f); + var balances = await repo.GetInvoiceBalancesAsync(f); + var invoiced = await repo.GetInvoicedAsync(f); + var asOf = f.To.Date.AddDays(1); + + var collected = payments.Where(p => p.Method != PaymentMethod.Credit).Sum(p => p.Amount); + var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount); + var total = payments.Sum(p => p.Amount); + + var open = balances + .Select(b => (b.StoreId, b.StoreName, Age: (asOf - b.FinalizedAt).TotalDays, Balance: Math.Max(0m, b.Total - b.Paid))) + .Where(b => b.Balance > 0) + .ToList(); + var outstanding = open.Sum(b => b.Balance); + var overdue = open.Where(b => b.Age > OverdueDays).Sum(b => b.Balance); + + var summary = new PaymentSummary( + invoiced, collected, credit, outstanding, overdue, + invoiced > 0 ? Math.Round(collected / invoiced * 100, 2) : null, + payments.Count); + + var byMethod = Enum.GetValues().Select(m => + { + var rows = payments.Where(p => p.Method == m).ToList(); + var amount = rows.Sum(p => p.Amount); + return new MethodBreakdown(m.ToString(), rows.Count, amount, Pct(amount, total)); + }).ToList(); + + var daily = (f.To - f.From).TotalDays <= DailyGranularityMaxDays; + var trend = payments + .GroupBy(p => daily ? p.CollectedAt.ToString("yyyy-MM-dd") : p.CollectedAt.ToString("yyyy-MM")) + .OrderBy(g => g.Key) + .Select(g => new PaymentTrendPoint(g.Key, + g.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount), + g.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount), + g.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount))) + .ToList(); + + var buckets = new (string Name, Func In)[] + { + ("0-30 days", a => a <= 30), + ("31-60 days", a => a > 30 && a <= 60), + ("61-90 days", a => a > 60 && a <= 90), + ("90+ days", a => a > 90), + }; + var aging = buckets.Select(b => + { + var rows = open.Where(o => b.In(o.Age)).ToList(); + return new AgingBucket(b.Name, rows.Count, rows.Sum(r => r.Balance)); + }).ToList(); + + var topOutstanding = open + .GroupBy(o => (o.StoreId, o.StoreName)) + .Select(g => new StoreOutstanding(g.Key.StoreId, g.Key.StoreName, g.Count(), + g.Sum(x => x.Balance), g.Where(x => x.Age > OverdueDays).Sum(x => x.Balance))) + .OrderByDescending(s => s.Balance) + .Take(TopStores) + .ToList(); + + var byAccount = payments + .Where(p => p.Method == PaymentMethod.Cash) + .GroupBy(p => (p.AccountId, Name: p.AccountName ?? "Unassigned")) + .Select(g => new AccountCollection(g.Key.AccountId, g.Key.Name, g.Count(), g.Sum(p => p.Amount))) + .OrderByDescending(a => a.Amount) + .ToList(); + + var cheques = payments + .Where(p => p.Method == PaymentMethod.Cheque) + .GroupBy(p => p.ChequeStatus ?? "Unknown") + .Select(g => new ChequePipeline(g.Key, g.Count(), g.Sum(p => p.Amount))) + .OrderByDescending(c => c.Amount) + .ToList(); + + return new PaymentAnalyticsResponse(f.From, f.To, summary, byMethod, trend, aging, topOutstanding, byAccount, cheques); + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 059eeb3..1afd464 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -79,6 +79,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();