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); } }