feat: Implement Day End Report feature logic

This commit is contained in:
2026-10-23 14:52:39 +05:30
parent c775146b64
commit 6674ba6aae
5 changed files with 224 additions and 0 deletions
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DayEndReport;
[ApiController]
[Route("api/reports/day-end")]
public class DayEndReportController(DayEndReportService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Day([FromQuery] DateTime? date) =>
Ok(await service.GetDayAsync(date ?? DateTime.Now));
[HttpGet("daily")]
public async Task<IActionResult> Daily([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var f = DayEndReportService.BuildFilter(from, to);
var error = DayEndReportService.Validate(f);
return error is null ? Ok(await service.GetDailyAsync(f)) : BadRequest(new { message = error });
}
}
@@ -0,0 +1,51 @@
using Distribution.Models;
namespace Distribution.Features.DayEndReport;
public record DayEndFilter(DateTime From, DateTime To);
public record DayEndRow(
string Date,
decimal TotalSales,
decimal Collected,
decimal Due,
decimal Cash,
decimal Cheque,
decimal Credit,
decimal Returns,
decimal Expenses,
decimal Income,
int InvoiceCount,
int PaymentCount,
decimal CashCollected,
decimal CashExpensesPaid,
decimal NetCash);
public record DayEndMethod(string Method, int Count, decimal Amount);
public record DayEndExpense(Guid Id, string Category, string? Payee, string? Description, string? Method, decimal Amount);
public record DayEndResponse(
string Date,
DayEndRow Summary,
List<DayEndMethod> ByMethod,
List<DayEndExpense> Expenses);
public record DayEndDailyResponse(DateTime From, DateTime To, List<DayEndRow> Days, DayEndRow Totals);
// Repository row shapes
public record DayEndInvoiceRow(Guid Id, DateTime FinalizedAt, decimal Total);
public record DayEndPaymentRow(Guid InvoiceId, PaymentMethod Method, decimal Amount, DateTime CollectedAt);
public record DayEndReturnRow(DateTime At, decimal Amount);
public record DayEndExpenseRow(
Guid Id, DateTime BillDate, DateTime? PaidAt, PaymentMethod? Method, decimal Amount,
string Category, string? Payee, string? Description);
public record DayEndData(
List<DayEndInvoiceRow> Invoices,
List<DayEndPaymentRow> Payments,
List<DayEndReturnRow> Returns,
List<DayEndExpenseRow> Expenses);
@@ -0,0 +1,47 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DayEndReport;
public class DayEndReportRepository(AppDBContext db) : IDayEndReportRepository
{
public async Task<DayEndData> GetDataAsync(DayEndFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var invoices = await db.Invoices.AsNoTracking()
.Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized
&& i.ShopPO.Status != ShopPOStatus.Cancelled
&& i.FinalizedAt >= start && i.FinalizedAt < end)
.Select(i => new DayEndInvoiceRow(i.Id, i.FinalizedAt!.Value, i.TotalAmount))
.ToListAsync();
var payments = await db.Payments.AsNoTracking()
.Where(p => p.IsActive && p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled
&& ((p.CollectedAt >= start && p.CollectedAt < end)
|| (p.Invoice.Status == InvoiceStatus.Finalized
&& p.Invoice.FinalizedAt >= start && p.Invoice.FinalizedAt < end)))
.Select(p => new DayEndPaymentRow(p.FK_InvoiceId, p.Method, p.Amount, p.CollectedAt))
.ToListAsync();
var returns = await db.SaleReturns.AsNoTracking()
.Where(r => r.IsActive && r.CreatedAt >= start && r.CreatedAt < end)
.Select(r => new DayEndReturnRow(r.CreatedAt, r.TotalCreditAmount))
.ToListAsync();
var expenses = await db.Expenses.AsNoTracking()
.Where(e => e.IsActive && (e.Status == ExpenseStatus.Posted || e.Status == ExpenseStatus.Paid)
&& ((e.BillDate >= start && e.BillDate < end)
|| (e.PaidAt >= start && e.PaidAt < end)))
.Select(e => new DayEndExpenseRow(
e.Id, e.BillDate, e.PaidAt, e.Method, e.Amount,
e.Category != null ? e.Category.Name : "",
e.Payee ?? (e.Vendor != null ? e.Vendor.Name : null),
e.Description))
.ToListAsync();
return new DayEndData(invoices, payments, returns, expenses);
}
}
@@ -0,0 +1,96 @@
using Distribution.Models;
namespace Distribution.Features.DayEndReport;
public class DayEndReportService(IDayEndReportRepository repo)
{
public const int MaxRangeDays = 92;
public static DayEndFilter BuildFilter(DateTime? from, DateTime? to)
{
var end = (to ?? DateTime.Now).Date;
var start = (from ?? end.AddDays(-29)).Date;
return new DayEndFilter(start, end);
}
public static string? Validate(DayEndFilter 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;
}
public async Task<DayEndResponse> GetDayAsync(DateTime date)
{
var day = date.Date;
var data = await repo.GetDataAsync(new DayEndFilter(day, day));
var row = BuildRow(day, data);
var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList();
var byMethod = Enum.GetValues<PaymentMethod>().Select(m =>
{
var rows = payments.Where(p => p.Method == m).ToList();
return new DayEndMethod(m.ToString(), rows.Count, rows.Sum(p => p.Amount));
}).ToList();
var expenses = data.Expenses
.Where(e => e.BillDate.Date == day)
.Select(e => new DayEndExpense(e.Id, e.Category, e.Payee, e.Description, e.Method?.ToString(), e.Amount))
.ToList();
return new DayEndResponse(row.Date, row, byMethod, expenses);
}
public async Task<DayEndDailyResponse> GetDailyAsync(DayEndFilter f)
{
var data = await repo.GetDataAsync(f);
var days = new List<DayEndRow>();
for (var d = f.From.Date; d <= f.To.Date; d = d.AddDays(1))
days.Add(BuildRow(d, data));
var totals = new DayEndRow(
"Total",
days.Sum(r => r.TotalSales), days.Sum(r => r.Collected), days.Sum(r => r.Due),
days.Sum(r => r.Cash), days.Sum(r => r.Cheque), days.Sum(r => r.Credit),
days.Sum(r => r.Returns), days.Sum(r => r.Expenses), days.Sum(r => r.Income),
days.Sum(r => r.InvoiceCount), days.Sum(r => r.PaymentCount),
days.Sum(r => r.CashCollected), days.Sum(r => r.CashExpensesPaid), days.Sum(r => r.NetCash));
return new DayEndDailyResponse(f.From, f.To, days, totals);
}
// Accrual basis: Income = sales (finalized invoices) - returns - expenses billed that day.
// Due is the unpaid part of that day's invoices as at the end of the day; Credit payments never count as paid.
private static DayEndRow BuildRow(DateTime day, DayEndData data)
{
var next = day.AddDays(1);
var invoices = data.Invoices.Where(i => i.FinalizedAt.Date == day).ToList();
var invoiceIds = invoices.Select(i => i.Id).ToHashSet();
var paidByInvoice = data.Payments
.Where(p => invoiceIds.Contains(p.InvoiceId) && p.Method != PaymentMethod.Credit && p.CollectedAt < next)
.GroupBy(p => p.InvoiceId)
.ToDictionary(g => g.Key, g => g.Sum(p => p.Amount));
var totalSales = invoices.Sum(i => i.Total);
var due = invoices.Sum(i => Math.Max(0m, i.Total - paidByInvoice.GetValueOrDefault(i.Id)));
var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList();
var cash = payments.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount);
var cheque = payments.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount);
var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount);
var returns = data.Returns.Where(r => r.At.Date == day).Sum(r => r.Amount);
var expenses = data.Expenses.Where(e => e.BillDate.Date == day).Sum(e => e.Amount);
var cashExpensesPaid = data.Expenses
.Where(e => e.Method == PaymentMethod.Cash && e.PaidAt.HasValue && e.PaidAt.Value.Date == day)
.Sum(e => e.Amount);
return new DayEndRow(
day.ToString("yyyy-MM-dd"),
totalSales, cash + cheque, due, cash, cheque, credit,
returns, expenses, totalSales - returns - expenses,
invoices.Count, payments.Count,
cash, cashExpensesPaid, cash - cashExpensesPaid);
}
}
@@ -0,0 +1,10 @@
namespace Distribution.Features.DayEndReport;
public interface IDayEndReportRepository
{
/// <summary>
/// Everything needed for [From, To] (whole days): invoices finalized, payments collected (plus payments on
/// those invoices, for the due amount), sale returns, and Posted/Paid expenses billed or paid in the range.
/// </summary>
Task<DayEndData> GetDataAsync(DayEndFilter f);
}