106 lines
4.5 KiB
C#
106 lines
4.5 KiB
C#
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<PaymentAnalyticsResponse> 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<PaymentMethod>().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<double, bool> 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);
|
|
}
|
|
}
|