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