Merge pull request 'Dev' (#89) from dev into production

Reviewed-on: #89
This commit was merged in pull request #89.
This commit is contained in:
2026-10-23 15:00:30 +05:30
12 changed files with 866 additions and 1 deletions
@@ -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);
}
+2
View File
@@ -85,6 +85,8 @@ builder.Services.AddScoped<IDashboardReportRepository, DashboardReportRepository
builder.Services.AddScoped<DashboardReportService>(); builder.Services.AddScoped<DashboardReportService>();
builder.Services.AddScoped<Distribution.Features.PaymentReport.IPaymentReportRepository, Distribution.Features.PaymentReport.PaymentReportRepository>(); builder.Services.AddScoped<Distribution.Features.PaymentReport.IPaymentReportRepository, Distribution.Features.PaymentReport.PaymentReportRepository>();
builder.Services.AddScoped<Distribution.Features.PaymentReport.PaymentReportService>(); 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<IStockReportRepository, StockReportRepository>();
builder.Services.AddScoped<StockReportService>(); builder.Services.AddScoped<StockReportService>();
builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>(); builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
+133
View File
@@ -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&regionId=`
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=&regionId=`
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>
)
}
+1
View File
@@ -193,6 +193,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [ items: [
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' }, { 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: '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' },
], ],
}, },
] ]
+1 -1
View File
@@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers'
export { purchaseOrders, grn, purchaseReturns } from './procurement' export { purchaseOrders, grn, purchaseReturns } from './procurement'
export { rides, loadingSheets, deliveryTasks } from './operations' export { rides, loadingSheets, deliveryTasks } from './operations'
export { stock } from './stock' 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 { shopPos, invoices, payments } from './sales'
export { saleReturns, claims, supplierClaims } from './returns-claims' 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' 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'
+40
View File
@@ -231,3 +231,43 @@ export const paymentReports = {
analytics: (params?: { from?: string; to?: string; regionId?: string }) => analytics: (params?: { from?: string; to?: string; regionId?: string }) =>
api.get<PaymentAnalyticsResponse>('/reports/payments/analytics', { params }).then(r => r.data), 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),
}