Dev #89
@@ -0,0 +1,208 @@
|
||||
using Distribution.Features.DayEndReport;
|
||||
using Distribution.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Distribution.Tests.Services;
|
||||
|
||||
public class DayEndReportServiceTests
|
||||
{
|
||||
private static readonly DateTime Day = new(2026, 10, 20);
|
||||
|
||||
private readonly Mock<IDayEndReportRepository> _repo = new();
|
||||
private readonly DayEndReportService _sut;
|
||||
|
||||
public DayEndReportServiceTests()
|
||||
{
|
||||
_sut = new DayEndReportService(_repo.Object);
|
||||
}
|
||||
|
||||
private void Returns(DayEndData data) =>
|
||||
_repo.Setup(r => r.GetDataAsync(It.IsAny<DayEndFilter>())).ReturnsAsync(data);
|
||||
|
||||
private static DayEndData Data(
|
||||
List<DayEndInvoiceRow>? invoices = null,
|
||||
List<DayEndPaymentRow>? payments = null,
|
||||
List<DayEndReturnRow>? returns = null,
|
||||
List<DayEndExpenseRow>? expenses = null) =>
|
||||
new(invoices ?? [], payments ?? [], returns ?? [], expenses ?? []);
|
||||
|
||||
private static DayEndExpenseRow Expense(DateTime billDate, decimal amount, PaymentMethod? method = null, DateTime? paidAt = null) =>
|
||||
new(Guid.NewGuid(), billDate, paidAt, method, amount, "Utilities", "Payee", null);
|
||||
|
||||
// Validate / BuildFilter
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReturnsError_WhenToBeforeFrom()
|
||||
{
|
||||
var f = new DayEndFilter(Day, Day.AddDays(-1));
|
||||
Assert.NotNull(DayEndReportService.Validate(f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReturnsError_WhenRangeTooLong()
|
||||
{
|
||||
var f = new DayEndFilter(Day, Day.AddDays(DayEndReportService.MaxRangeDays));
|
||||
Assert.NotNull(DayEndReportService.Validate(f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReturnsNull_ForMaxAllowedRange()
|
||||
{
|
||||
var f = new DayEndFilter(Day, Day.AddDays(DayEndReportService.MaxRangeDays - 1));
|
||||
Assert.Null(DayEndReportService.Validate(f));
|
||||
}
|
||||
|
||||
// GetDayAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_ComputesSalesCollectionsAndIncome()
|
||||
{
|
||||
var inv1 = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 1000m);
|
||||
var inv2 = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(15), 500m);
|
||||
Returns(Data(
|
||||
invoices: [inv1, inv2],
|
||||
payments:
|
||||
[
|
||||
new(inv1.Id, PaymentMethod.Cash, 600m, Day.AddHours(11)),
|
||||
new(inv1.Id, PaymentMethod.Cheque, 400m, Day.AddHours(12)),
|
||||
new(inv2.Id, PaymentMethod.Credit, 500m, Day.AddHours(16)),
|
||||
],
|
||||
returns: [new(Day.AddHours(9), 100m)],
|
||||
expenses: [Expense(Day, 250m)]));
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
var s = result.Summary;
|
||||
Assert.Equal(1500m, s.TotalSales);
|
||||
Assert.Equal(1000m, s.Collected);
|
||||
Assert.Equal(500m, s.Due);
|
||||
Assert.Equal(600m, s.Cash);
|
||||
Assert.Equal(400m, s.Cheque);
|
||||
Assert.Equal(500m, s.Credit);
|
||||
Assert.Equal(100m, s.Returns);
|
||||
Assert.Equal(250m, s.Expenses);
|
||||
Assert.Equal(1150m, s.Income);
|
||||
Assert.Equal(2, s.InvoiceCount);
|
||||
Assert.Equal(3, s.PaymentCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_CreditPayment_DoesNotReduceDue()
|
||||
{
|
||||
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 800m);
|
||||
Returns(Data(invoices: [inv], payments: [new(inv.Id, PaymentMethod.Credit, 800m, Day.AddHours(10))]));
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
Assert.Equal(800m, result.Summary.Due);
|
||||
Assert.Equal(0m, result.Summary.Collected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_PaymentAfterDayEnd_DoesNotReduceThatDaysDue()
|
||||
{
|
||||
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 800m);
|
||||
Returns(Data(invoices: [inv], payments: [new(inv.Id, PaymentMethod.Cash, 800m, Day.AddDays(1).AddHours(1))]));
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
Assert.Equal(800m, result.Summary.Due);
|
||||
Assert.Equal(0m, result.Summary.Collected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_InstalmentOnOlderInvoice_CountsAsCollectedNotSales()
|
||||
{
|
||||
var older = Guid.NewGuid();
|
||||
Returns(Data(payments: [new(older, PaymentMethod.Cash, 300m, Day.AddHours(9))]));
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
Assert.Equal(0m, result.Summary.TotalSales);
|
||||
Assert.Equal(300m, result.Summary.Collected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_Overpayment_DueIsNeverNegative()
|
||||
{
|
||||
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 100m);
|
||||
Returns(Data(invoices: [inv], payments: [new(inv.Id, PaymentMethod.Cash, 150m, Day.AddHours(11))]));
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
Assert.Equal(0m, result.Summary.Due);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_OnlyCountsExpensesBilledThatDay()
|
||||
{
|
||||
Returns(Data(expenses:
|
||||
[
|
||||
Expense(Day, 100m),
|
||||
Expense(Day.AddDays(-1), 999m, PaymentMethod.Cash, paidAt: Day.AddHours(8)),
|
||||
]));
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
Assert.Equal(100m, result.Summary.Expenses);
|
||||
Assert.Single(result.Expenses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_NetCash_IsCashCollectedMinusCashExpensesPaid()
|
||||
{
|
||||
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 1000m);
|
||||
Returns(Data(
|
||||
invoices: [inv],
|
||||
payments:
|
||||
[
|
||||
new(inv.Id, PaymentMethod.Cash, 700m, Day.AddHours(11)),
|
||||
new(inv.Id, PaymentMethod.Cheque, 300m, Day.AddHours(11)),
|
||||
],
|
||||
expenses:
|
||||
[
|
||||
Expense(Day, 200m, PaymentMethod.Cash, paidAt: Day.AddHours(14)),
|
||||
Expense(Day, 50m, PaymentMethod.Cheque, paidAt: Day.AddHours(14)),
|
||||
]));
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
Assert.Equal(700m, result.Summary.CashCollected);
|
||||
Assert.Equal(200m, result.Summary.CashExpensesPaid);
|
||||
Assert.Equal(500m, result.Summary.NetCash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDayAsync_NoActivity_ReturnsZeros()
|
||||
{
|
||||
Returns(Data());
|
||||
|
||||
var result = await _sut.GetDayAsync(Day);
|
||||
|
||||
Assert.Equal(0m, result.Summary.Income);
|
||||
Assert.Equal(3, result.ByMethod.Count);
|
||||
Assert.Empty(result.Expenses);
|
||||
}
|
||||
|
||||
// GetDailyAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyAsync_FillsEmptyDays_AndTotalsMatchSumOfDays()
|
||||
{
|
||||
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 1000m);
|
||||
Returns(Data(
|
||||
invoices: [inv],
|
||||
payments: [new(inv.Id, PaymentMethod.Cash, 1000m, Day.AddHours(11))],
|
||||
expenses: [Expense(Day.AddDays(2), 300m)]));
|
||||
|
||||
var result = await _sut.GetDailyAsync(new DayEndFilter(Day, Day.AddDays(2)));
|
||||
|
||||
Assert.Equal(3, result.Days.Count);
|
||||
Assert.Equal(0m, result.Days[1].TotalSales);
|
||||
Assert.Equal(1000m, result.Totals.TotalSales);
|
||||
Assert.Equal(300m, result.Totals.Expenses);
|
||||
Assert.Equal(700m, result.Totals.Income);
|
||||
Assert.Equal(result.Days.Sum(d => d.Income), result.Totals.Income);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -85,6 +85,8 @@ 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<Distribution.Features.DayEndReport.IDayEndReportRepository, Distribution.Features.DayEndReport.DayEndReportRepository>();
|
||||
builder.Services.AddScoped<Distribution.Features.DayEndReport.DayEndReportService>();
|
||||
builder.Services.AddScoped<IStockReportRepository, StockReportRepository>();
|
||||
builder.Services.AddScoped<StockReportService>();
|
||||
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Day-End Sales Report — Plan (Distribution ERP)
|
||||
|
||||
A per-day view of sales, collections by method, returns, expenses and the resulting income, with a
|
||||
day-wise chart and a PDF export. Read-only: it adds no tables and changes no existing models.
|
||||
|
||||
> Modelled on the Day-End report in the other HexDrive ERP, but adapted to this project's data.
|
||||
> The big difference: **there is no cash/card split here.** `PaymentMethod` is `Cash | Cheque | Credit`,
|
||||
> so the report splits collections by those three.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the report shows (one selected day)
|
||||
|
||||
| Group | Figures |
|
||||
|---|---|
|
||||
| Sales | Invoiced total (finalized invoices that day), Collected, Due |
|
||||
| Collections by method | Cash, Cheque, Credit (credit = extended to the store, not money received) |
|
||||
| Counts | Invoices count, payments count, count by method |
|
||||
| Returns | Total credit amount of sale returns that day (`SaleReturn.TotalCreditAmount`) |
|
||||
| Expenses | Total + list of that day's expenses |
|
||||
| Result | **Income of the day** |
|
||||
| Secondary | Cash in hand movement: cash collected − cash expenses paid |
|
||||
|
||||
Plus:
|
||||
- Day-wise income **chart** over a date range (Sales / Expenses / Income).
|
||||
- Day-wise table with a totals row.
|
||||
- **PDF export** (single day, and day-wise range).
|
||||
|
||||
---
|
||||
|
||||
## 2. What already exists (reused, nothing new in the DB)
|
||||
|
||||
| Need | Source |
|
||||
|---|---|
|
||||
| Sales | `Invoice` — `Status = Finalized`, `FinalizedAt`, `TotalAmount`, `IsActive`, excluding `ShopPO.Status = Cancelled` |
|
||||
| Collections | `Payment` — `Amount`, `Method`, `CollectedAt`, `FK_BankAccountId` |
|
||||
| Returns | `SaleReturn` — `TotalCreditAmount` (ItemExchange is 0), `CreatedAt` |
|
||||
| Expenses | `Expense` — `Amount`, `BillDate`, `PaidAt`, `Status` (`Draft/Posted/Paid/Void`), `Method` |
|
||||
| Region scope | `Invoice → ShopPO → Store → Route.FK_RegionId` (same filter `PaymentReport` uses) |
|
||||
| Backend pattern | `Features/PaymentReport/*` (controller + service + repository + DTOs) |
|
||||
| Frontend | `lib/api/reports.ts`, `config/nav.ts`, `recharts`, `jspdf` + `jspdf-autotable` (all already installed) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend — new `Features/DayEndReport/`
|
||||
|
||||
Same shape as `PaymentReport`:
|
||||
|
||||
- `DayEndReportController.cs`
|
||||
- `DayEndReportService.cs`
|
||||
- `IDayEndReportRepository.cs` / `DayEndReportRepository.cs`
|
||||
- `DayEndReportDTOs.cs`
|
||||
- Register repo + service in `Program.cs` next to the `PaymentReport` lines.
|
||||
|
||||
### 3.1 `GET /api/reports/day-end?date=YYYY-MM-DD®ionId=`
|
||||
|
||||
Response (one day):
|
||||
|
||||
| Section | Fields |
|
||||
|---|---|
|
||||
| Sales | `TotalSales`, `Collected`, `Due` |
|
||||
| By method | `Cash`, `Cheque`, `Credit` (amount + count each) |
|
||||
| Counts | `InvoiceCount`, `PaymentCount` |
|
||||
| Returns | `SalesReturns` |
|
||||
| Expenses | `ExpenseTotal`, `Expenses[]` (doc no, category, title, payee, method, amount) |
|
||||
| Income | `Income = TotalSales − SalesReturns − ExpenseTotal` |
|
||||
| Cash in hand | `CashCollected`, `CashExpensesPaid`, `NetCash` |
|
||||
|
||||
### 3.2 `GET /api/reports/day-end/daily?from=&to=®ionId=`
|
||||
|
||||
One row per day: `Date, TotalSales, Collected, Due, Cash, Cheque, Credit, Returns, Expenses, Income`
|
||||
plus a totals row. Feeds the chart, the table and the PDF.
|
||||
|
||||
- Days with no activity are returned as zeros.
|
||||
- Range capped at 92 days (matches `PaymentReport`'s daily granularity; error message in the same style).
|
||||
|
||||
### 3.3 Rules (need confirming — see §6)
|
||||
|
||||
- **Sales** = finalized invoices by `FinalizedAt`, active, order not cancelled.
|
||||
- **Collected** = payments by `CollectedAt` (includes instalments against older invoices).
|
||||
- **Due** = that day's invoiced total − payments received **on the same invoices** that day? or running balance? → decision #2.
|
||||
- **Expenses** = `Posted` and `Paid` expenses, `Void` and `Draft` excluded. Dated by `BillDate`.
|
||||
- **Region filter** optional; unset = all regions.
|
||||
- **Day boundaries**: use the same local-date handling `PaymentReport` uses (`DateTime.Now.Date`, `[day, day+1)`), so numbers agree with the Payment Analytics page.
|
||||
- Permission: a `reports.day-end` view permission, seeded like the other permissions (to confirm the existing seeding pattern when implementing).
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend — `/reports/day-end`
|
||||
|
||||
- API: add `dayEnd` calls + types to `lib/api/reports.ts`.
|
||||
- Page `app/(dashboard)/reports/day-end/page.tsx`:
|
||||
- Toolbar: date picker (default today), region select, **Download PDF**.
|
||||
- Summary cards: Total Sales · Collected · Due · Cash · Cheque · Credit · Returns · Expenses · **Income** (green/red by sign).
|
||||
- Expenses table for the selected day.
|
||||
- Chart: from/to (default last 30 days), Sales / Expenses / Income lines (`recharts`), daily table below.
|
||||
- Nav: add "Day-End Sales" entry in `config/nav.ts` (Reports group), gated by the permission.
|
||||
|
||||
## 5. PDF export
|
||||
|
||||
- `lib/pdf/day-end-report-pdf.ts` using `jspdf` + `jspdf-autotable`.
|
||||
- Page 1: header (company, region, range, generated at), period totals, chart image.
|
||||
- Following pages: day-wise table with totals row.
|
||||
- Single-day variant: summary figures + expense list.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions I need from you
|
||||
|
||||
| # | Question | My default |
|
||||
|---|---|---|
|
||||
| 1 | **Income basis.** Accrual (sales − returns − expenses dated that day) or cash basis (money collected − expenses paid that day)? | Accrual; cash shown as secondary `NetCash`. |
|
||||
| 2 | **"Due" meaning.** Unpaid part of that day's invoices, or the running outstanding balance? | Unpaid part of that day's invoices. |
|
||||
| 3 | **Credit method.** Count `Credit` payments as collected? | No — shown separately, not in "Collected" (same as Payment Analytics). |
|
||||
| 4 | **Card.** You asked for cash vs card in the other project; here only Cash/Cheque/Credit exist. Add a Card method (schema change + UI) or just report what exists? | Report what exists; no schema change. |
|
||||
| 5 | **Expense date.** `BillDate` or `PaidAt`? | `BillDate`. |
|
||||
|
||||
## 7. Verification
|
||||
|
||||
- Backend tests (in `Distribution.Tests`): mixed methods, partial payments, cancelled order excluded, void/draft expense excluded, instalment on an older invoice, day-boundary case, empty days.
|
||||
- Cross-check: the "Collected" total for a range equals Payment Analytics' collected for the same range.
|
||||
- Frontend: cards match the daily table; PDF paginates over a 92-day range.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implemented (2026-10-23) — differences from the plan
|
||||
|
||||
Built with all five recommended defaults.
|
||||
|
||||
- **No region filter and no new permission.** Expenses are attributed to a warehouse, not a region, so a region filter would make income inconsistent. The endpoints follow `PaymentReport` (any authenticated user); the nav entry reuses `warehouses.view` like the other reports.
|
||||
- **Endpoints:** `GET /api/reports/day-end?date=` and `GET /api/reports/day-end/daily?from=&to=`.
|
||||
- **PDF:** reuses `exportPdf` from `lib/export.ts` (day-wise table with totals row). No chart image in the PDF.
|
||||
- **Tests:** `Distribution.Tests/Services/DayEndReportServiceTests.cs` (12 passing). The older test files in that project (`OrderServiceTests`, `StoreServiceTests`, `AuthServiceTests`, `ItemServiceTests`, `RouteServiceTests`, `RegionServiceTests`) no longer compile, so the project can't build until they are fixed. Not touched here.
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { dayEndReports, getApiError } from '@/lib/api/index'
|
||||
import type { DayEndDailyResponse, DayEndResponse, DayEndRow } from '@/lib/api/reports'
|
||||
import { exportPdf, type ExportColumn } from '@/lib/export'
|
||||
|
||||
import { daysFromToday, fmtCompact, fmtMoney, fmtNum, fmtShortDay } from '@/app/(dashboard)/network/warehouses/_components/dashboard/format'
|
||||
import { EmptyNote, Metric, Section } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared'
|
||||
|
||||
const PRESETS = [
|
||||
{ label: '7 days', days: -6 },
|
||||
{ label: '30 days', days: -29 },
|
||||
{ label: '92 days', days: -91 },
|
||||
]
|
||||
|
||||
const trendConfig = {
|
||||
totalSales: { label: 'Sales', color: 'var(--chart-1)' },
|
||||
expenses: { label: 'Expenses', color: 'var(--chart-3)' },
|
||||
income: { label: 'Income', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const pdfColumns: ExportColumn<DayEndRow>[] = [
|
||||
{ header: 'Date', value: r => r.date },
|
||||
{ header: 'Sales', value: r => fmtNum(r.totalSales), numeric: true },
|
||||
{ header: 'Collected', value: r => fmtNum(r.collected), numeric: true },
|
||||
{ header: 'Due', value: r => fmtNum(r.due), numeric: true },
|
||||
{ header: 'Cash', value: r => fmtNum(r.cash), numeric: true },
|
||||
{ header: 'Cheque', value: r => fmtNum(r.cheque), numeric: true },
|
||||
{ header: 'Credit', value: r => fmtNum(r.credit), numeric: true },
|
||||
{ header: 'Returns', value: r => fmtNum(r.returns), numeric: true },
|
||||
{ header: 'Expenses', value: r => fmtNum(r.expenses), numeric: true },
|
||||
{ header: 'Income', value: r => fmtNum(r.income), numeric: true },
|
||||
]
|
||||
|
||||
const incomeTone = (v: number) => (v < 0 ? 'danger' : undefined)
|
||||
|
||||
export default function DayEndReportPage() {
|
||||
const [date, setDate] = useState(() => daysFromToday(0))
|
||||
const [day, setDay] = useState<DayEndResponse | null>(null)
|
||||
const [dayError, setDayError] = useState<string | null>(null)
|
||||
const [dayLoading, setDayLoading] = useState(true)
|
||||
|
||||
const [from, setFrom] = useState(() => daysFromToday(-29))
|
||||
const [to, setTo] = useState(() => daysFromToday(0))
|
||||
const [daily, setDaily] = useState<DayEndDailyResponse | null>(null)
|
||||
const [dailyError, setDailyError] = useState<string | null>(null)
|
||||
const [dailyLoading, setDailyLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!date) return
|
||||
let cancelled = false
|
||||
setDayLoading(true)
|
||||
setDayError(null)
|
||||
dayEndReports.day({ date })
|
||||
.then(res => { if (!cancelled) setDay(res) })
|
||||
.catch(err => { if (!cancelled) setDayError(getApiError(err)) })
|
||||
.finally(() => { if (!cancelled) setDayLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [date])
|
||||
|
||||
useEffect(() => {
|
||||
if (!from || !to) return
|
||||
let cancelled = false
|
||||
setDailyLoading(true)
|
||||
setDailyError(null)
|
||||
dayEndReports.daily({ from, to })
|
||||
.then(res => { if (!cancelled) setDaily(res) })
|
||||
.catch(err => { if (!cancelled) setDailyError(getApiError(err)) })
|
||||
.finally(() => { if (!cancelled) setDailyLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [from, to])
|
||||
|
||||
const s = day?.summary
|
||||
const hasActivity = !!daily && daily.days.some(d => d.totalSales > 0 || d.expenses > 0)
|
||||
|
||||
const downloadPdf = () => {
|
||||
if (!daily) return
|
||||
void exportPdf(
|
||||
'day-end-sales',
|
||||
'Day-End Sales Report',
|
||||
pdfColumns,
|
||||
[...daily.days, daily.totals],
|
||||
`${from} to ${to}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Day-End Sales</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Sales, collections, returns, expenses and income for a day.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="date" value={date} max={daysFromToday(0)} onChange={e => setDate(e.target.value)} className="w-40" aria-label="Report date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dayError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{dayError}</div>
|
||||
)}
|
||||
|
||||
{!dayError && !day && dayLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{day && s && (
|
||||
<div className={dayLoading ? 'flex flex-col gap-6 opacity-60 transition-opacity' : 'flex flex-col gap-6'}>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<Metric label="Total sales" value={fmtMoney(s.totalSales)} sub={`${fmtNum(s.invoiceCount)} invoices`} />
|
||||
<Metric label="Collected" value={fmtMoney(s.collected)} sub={`${fmtNum(s.paymentCount)} payments · cash + cheque`} />
|
||||
<Metric label="Due" value={fmtMoney(s.due)} sub="Unpaid on today's invoices" tone={s.due > 0 ? 'warning' : undefined} />
|
||||
<Metric label="Cash" value={fmtMoney(s.cash)} sub={`${fmtNum(day.byMethod.find(m => m.method === 'Cash')?.count ?? 0)} payments`} />
|
||||
<Metric label="Cheque" value={fmtMoney(s.cheque)} sub={`${fmtNum(day.byMethod.find(m => m.method === 'Cheque')?.count ?? 0)} payments`} />
|
||||
<Metric label="Credit" value={fmtMoney(s.credit)} sub="Extended, not received" />
|
||||
<Metric label="Returns" value={fmtMoney(s.returns)} />
|
||||
<Metric label="Expenses" value={fmtMoney(s.expenses)} />
|
||||
<Metric label="Net cash" value={fmtMoney(s.netCash)} sub="Cash collected − cash expenses paid" tone={incomeTone(s.netCash)} />
|
||||
<Metric label="Income" value={fmtMoney(s.income)} sub="Sales − returns − expenses" tone={incomeTone(s.income)} />
|
||||
</div>
|
||||
|
||||
<Section title="Expenses" description="Posted and paid expenses billed on this day">
|
||||
{day.expenses.length === 0 ? (
|
||||
<EmptyNote>No expenses on this day.</EmptyNote>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Payee</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{day.expenses.map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="font-medium">{e.category}</TableCell>
|
||||
<TableCell>{e.payee ?? '—'}</TableCell>
|
||||
<TableCell>{e.description ?? '—'}</TableCell>
|
||||
<TableCell>{e.method ?? 'Unpaid'}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtMoney(e.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Up to 92 days.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<Button
|
||||
key={p.label}
|
||||
variant={from === daysFromToday(p.days) && to === daysFromToday(0) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => { setFrom(daysFromToday(p.days)); setTo(daysFromToday(0)) }}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<Input type="date" value={from} max={to} onChange={e => setFrom(e.target.value)} className="w-40" aria-label="From date" />
|
||||
<Input type="date" value={to} min={from} onChange={e => setTo(e.target.value)} className="w-40" aria-label="To date" />
|
||||
<Button size="sm" variant="outline" onClick={downloadPdf} disabled={!daily || dailyLoading}>Download PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dailyError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{dailyError}</div>
|
||||
)}
|
||||
|
||||
{!dailyError && !daily && dailyLoading && <Skeleton className="h-64 w-full" />}
|
||||
|
||||
{daily && (
|
||||
<div className={dailyLoading ? 'flex flex-col gap-6 opacity-60 transition-opacity' : 'flex flex-col gap-6'}>
|
||||
<Section title="Sales, expenses and income" description="Per day over the selected range">
|
||||
{!hasActivity ? (
|
||||
<EmptyNote>No sales or expenses in this period.</EmptyNote>
|
||||
) : (
|
||||
<ChartContainer config={trendConfig} className="w-full" style={{ height: 280 }}>
|
||||
<LineChart data={daily.days} margin={{ left: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={16} />
|
||||
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||
<ChartTooltip content={<ChartTooltipContent labelFormatter={(l) => fmtShortDay(String(l))} formatter={(v) => fmtMoney(Number(v))} />} />
|
||||
<Line dataKey="totalSales" type="monotone" stroke="var(--color-totalSales)" strokeWidth={2} dot={false} />
|
||||
<Line dataKey="expenses" type="monotone" stroke="var(--color-expenses)" strokeWidth={2} dot={false} />
|
||||
<Line dataKey="income" type="monotone" stroke="var(--color-income)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Daily breakdown">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Sales</TableHead>
|
||||
<TableHead className="text-right">Collected</TableHead>
|
||||
<TableHead className="text-right">Due</TableHead>
|
||||
<TableHead className="text-right">Cash</TableHead>
|
||||
<TableHead className="text-right">Cheque</TableHead>
|
||||
<TableHead className="text-right">Credit</TableHead>
|
||||
<TableHead className="text-right">Returns</TableHead>
|
||||
<TableHead className="text-right">Expenses</TableHead>
|
||||
<TableHead className="text-right">Income</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...daily.days].reverse().map(r => (
|
||||
<TableRow key={r.date}>
|
||||
<TableCell className="font-medium">{r.date}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.totalSales)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.collected)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.due)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.cash)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.cheque)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.credit)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.returns)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.expenses)}</TableCell>
|
||||
<TableCell className={`text-right tabular-nums ${r.income < 0 ? 'text-destructive' : ''}`}>{fmtNum(r.income)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="font-semibold">
|
||||
<TableCell>Total</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.totalSales)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.collected)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.due)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.cash)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.cheque)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.credit)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.returns)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.expenses)}</TableCell>
|
||||
<TableCell className={`text-right tabular-nums ${daily.totals.income < 0 ? 'text-destructive' : ''}`}>{fmtNum(daily.totals.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -193,6 +193,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
|
||||
{ title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' },
|
||||
{ title: 'Day-End Sales', href: '/reports/day-end', icon: ClipboardListIcon, description: 'Daily sales, collections, expenses and income', permission: 'warehouses.view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers'
|
||||
export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
||||
export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||
export { stock } from './stock'
|
||||
export { productReports, stockReports, dashboardReports, paymentReports } from './reports'
|
||||
export { productReports, stockReports, dashboardReports, paymentReports, dayEndReports } from './reports'
|
||||
export { shopPos, invoices, payments } from './sales'
|
||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS, invoiceCreationMode, INVOICE_CREATION_MODE_KEY, DEFAULT_INVOICE_CREATION_MODE } from './sys-config'
|
||||
|
||||
@@ -231,3 +231,43 @@ export const paymentReports = {
|
||||
analytics: (params?: { from?: string; to?: string; regionId?: string }) =>
|
||||
api.get<PaymentAnalyticsResponse>('/reports/payments/analytics', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
export interface DayEndRow {
|
||||
date: string
|
||||
totalSales: number
|
||||
collected: number
|
||||
due: number
|
||||
cash: number
|
||||
cheque: number
|
||||
credit: number
|
||||
returns: number
|
||||
expenses: number
|
||||
income: number
|
||||
invoiceCount: number
|
||||
paymentCount: number
|
||||
cashCollected: number
|
||||
cashExpensesPaid: number
|
||||
netCash: number
|
||||
}
|
||||
|
||||
export interface DayEndResponse {
|
||||
date: string
|
||||
summary: DayEndRow
|
||||
byMethod: Array<{ method: 'Cash' | 'Cheque' | 'Credit'; count: number; amount: number }>
|
||||
expenses: Array<{ id: string; category: string; payee: string | null; description: string | null; method: string | null; amount: number }>
|
||||
}
|
||||
|
||||
export interface DayEndDailyResponse {
|
||||
from: string
|
||||
to: string
|
||||
days: DayEndRow[]
|
||||
totals: DayEndRow
|
||||
}
|
||||
|
||||
export const dayEndReports = {
|
||||
day: (params?: { date?: string }) =>
|
||||
api.get<DayEndResponse>('/reports/day-end', { params }).then(r => r.data),
|
||||
|
||||
daily: (params?: { from?: string; to?: string }) =>
|
||||
api.get<DayEndDailyResponse>('/reports/day-end/daily', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user