feat: Implement Payment Report feature with repository, service, and controller
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
namespace Distribution.Features.PaymentReport;
|
||||
|
||||
public interface IPaymentReportRepository
|
||||
{
|
||||
/// <summary>Active payments collected within [From, To] (whole days).</summary>
|
||||
Task<List<PaymentRow>> GetPaymentsAsync(PaymentReportFilter f);
|
||||
|
||||
/// <summary>Finalized, non-cancelled invoices up to the end of 'To', each with cash/cheque paid as of then.</summary>
|
||||
Task<List<InvoiceBalanceRow>> GetInvoiceBalancesAsync(PaymentReportFilter f);
|
||||
|
||||
/// <summary>Sum of TotalAmount of invoices finalized within [From, To].</summary>
|
||||
Task<decimal> GetInvoicedAsync(PaymentReportFilter f);
|
||||
}
|
||||
@@ -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<IActionResult> 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 });
|
||||
}
|
||||
}
|
||||
@@ -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<MethodBreakdown> ByMethod,
|
||||
List<PaymentTrendPoint> Trend,
|
||||
List<AgingBucket> Aging,
|
||||
List<StoreOutstanding> TopOutstanding,
|
||||
List<AccountCollection> ByAccount,
|
||||
List<ChequePipeline> 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);
|
||||
@@ -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<List<PaymentRow>> 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<List<InvoiceBalanceRow>> 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<decimal> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,8 @@ builder.Services.AddScoped<IProductReportRepository, ProductReportRepository>();
|
||||
builder.Services.AddScoped<ProductReportService>();
|
||||
builder.Services.AddScoped<IDashboardReportRepository, DashboardReportRepository>();
|
||||
builder.Services.AddScoped<DashboardReportService>();
|
||||
builder.Services.AddScoped<Distribution.Features.PaymentReport.IPaymentReportRepository, Distribution.Features.PaymentReport.PaymentReportRepository>();
|
||||
builder.Services.AddScoped<Distribution.Features.PaymentReport.PaymentReportService>();
|
||||
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
|
||||
builder.Services.AddScoped<StockReportService>();
|
||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||
|
||||
Reference in New Issue
Block a user