diff --git a/.claude/worktrees/agent-a98805dc618e0f67c b/.claude/worktrees/agent-a98805dc618e0f67c
deleted file mode 160000
index b4993b2..0000000
--- a/.claude/worktrees/agent-a98805dc618e0f67c
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit b4993b202deed0c14f4739b3ce363e8c63bb91b4
diff --git a/Backend/ERPCore.Tests/BankDepositServiceTests.cs b/Backend/ERPCore.Tests/BankDepositServiceTests.cs
new file mode 100644
index 0000000..837aaf7
--- /dev/null
+++ b/Backend/ERPCore.Tests/BankDepositServiceTests.cs
@@ -0,0 +1,211 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Accounts;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+using Microsoft.EntityFrameworkCore;
+using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
+
+namespace ERPCore.Tests;
+
+/// Recording and cancelling bank deposits (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.2, §11).
+public class BankDepositServiceTests
+{
+ private static readonly DateOnly Today = Clock.Today;
+ private static readonly DateOnly Start = Today.AddDays(-10);
+ private static readonly DateOnly Day = Today.AddDays(-2);
+
+ [Fact]
+ public async Task Create_PostsBankDebitCashCredit_AndLowersCashInHand()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000));
+ await db.SaveChangesAsync();
+ var gl = new FakeGeneralLedger();
+
+ var dto = await DepositService(db, Start, gl).CreateAsync(Request(600, depositDate: Day.AddDays(1)));
+
+ Assert.Equal(BankDepositStatus.Posted, dto.Status);
+ Assert.Equal(("BOC Current", "Bank of Ceylon", "0001234567"), (dto.ToBankAccountName, dto.ToBankName, dto.ToAccountNumber));
+ Assert.Equal("JE-1", dto.GlJournalNo);
+
+ var entry = Assert.Single(gl.Posted);
+ Assert.Equal(Day.AddDays(1), entry.EntryDate);
+ Assert.Equal("BANK_DEPOSIT", entry.SourceModule);
+ Assert.Collection(entry.Lines,
+ l => Assert.Equal(("1010-01", 600m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
+ l => Assert.Equal(("1000-01", 0m, 600m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
+
+ var position = await CashService(db, Start).GetPositionAsync(Day, Branch1);
+ Assert.Equal((600m, 400m), (position.Position.Deposited, position.Position.Closing));
+ }
+
+ [Fact]
+ public async Task Create_MoreThanCashInHand_IsRejected()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000, card: 5000));
+ await db.SaveChangesAsync();
+ var gl = new FakeGeneralLedger();
+
+ var ex = await Assert.ThrowsAsync(() => DepositService(db, Start, gl).CreateAsync(Request(1000.01m)));
+
+ Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
+ Assert.Empty(gl.Posted);
+ }
+
+ [Fact]
+ public async Task Create_BackDatedDepositThatOverdrawsALaterDay_IsRejected()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000));
+ db.BankDeposits.Add(Deposit(Day.AddDays(1), 900)); // the next day already banked most of it
+ await db.SaveChangesAsync();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(500)));
+
+ Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
+ Assert.Contains("100.00", ex.Message);
+ }
+
+ [Fact]
+ public async Task Cancel_ReversesGl_AndPutsTheCashBack()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000));
+ await db.SaveChangesAsync();
+ var gl = new FakeGeneralLedger();
+ var service = DepositService(db, Start, gl);
+ var created = await service.CreateAsync(Request(1000));
+
+ var cancelled = await service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = " wrong bank " });
+
+ Assert.Equal(BankDepositStatus.Cancelled, cancelled.Status);
+ Assert.Equal("wrong bank", cancelled.CancelReason);
+ Assert.Equal("JE-2", cancelled.GlReversalJournalNo);
+ Assert.Collection(gl.Posted[1].Lines,
+ l => Assert.Equal(("1000-01", 1000m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
+ l => Assert.Equal(("1010-01", 0m, 1000m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
+ Assert.Equal(1000m, (await CashService(db, Start).GetPositionAsync(Day, Branch1)).Position.Closing);
+
+ var again = await Assert.ThrowsAsync(() =>
+ service.CancelAsync(created.BankDepositId, new CancelBankDepositRequest { Reason = "again" }));
+ Assert.Equal(ErrorCodes.BankDepositNotCancellable, again.Code);
+ }
+
+ [Theory]
+ [InlineData(FakeGeneralLedger.BankAccountId, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
+ [InlineData(FakeGeneralLedger.CashAccountId, FakeGeneralLedger.CashAccountId, ErrorCodes.BankDepositBankAccountInvalid)]
+ [InlineData(99L, FakeGeneralLedger.BankAccountId, ErrorCodes.BankDepositCashAccountInvalid)]
+ public async Task Create_WrongAccountTypes_AreRejected(long from, long to, string code)
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000));
+ await db.SaveChangesAsync();
+
+ var request = Request(100);
+ request.FromGlCashAccountId = from;
+ request.ToGlBankAccountId = to;
+ var ex = await Assert.ThrowsAsync(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
+
+ Assert.Equal(code, ex.Code);
+ }
+
+ [Fact]
+ public async Task Create_CashAndBankAccountsSharingAnId_AreToldApartByType()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000));
+ await db.SaveChangesAsync();
+ // GL numbers cash and bank accounts in separate tables, so both can be account 1.
+ var gl = new FakeGeneralLedger
+ {
+ Accounts =
+ [
+ new("Cash", 1, "main", null, "Cash in Transit", "CASH-000001", 22, "1400-01-01", "LKR"),
+ new("Bank", 1, "Main - 01", "Commercial Bank", null, "2032434543", 2, "1000-01", "LKR")
+ ]
+ };
+ var request = Request(400);
+ request.FromGlCashAccountId = 1;
+ request.ToGlBankAccountId = 1;
+
+ var dto = await DepositService(db, Start, gl).CreateAsync(request);
+
+ Assert.Equal(("main", "Main - 01"), (dto.FromCashAccountName, dto.ToBankAccountName));
+ Assert.Collection(Assert.Single(gl.Posted).Lines,
+ l => Assert.Equal(("1000-01", 400m), (l.AccountCode, l.DebitAmount)),
+ l => Assert.Equal(("1400-01-01", 400m), (l.AccountCode, l.CreditAmount)));
+ }
+
+ [Fact]
+ public async Task Create_CannotUseAnotherBranchsCash()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000, warehouseId: Warehouse2));
+ await db.SaveChangesAsync();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(Request(100, branchId: Branch1)));
+
+ Assert.Equal(ErrorCodes.BankDepositExceedsCashInHand, ex.Code);
+ }
+
+ [Theory]
+ [InlineData(-11, null)] // before tracking starts
+ [InlineData(1, null)] // business date in the future
+ [InlineData(-2, -3)] // deposited before the business day
+ [InlineData(-2, 2)] // deposit date beyond tomorrow
+ public async Task Create_InvalidDates_AreRejected(int businessOffset, int? depositOffset)
+ {
+ await using var db = CreateContext();
+ var request = Request(100);
+ request.BusinessDate = Today.AddDays(businessOffset);
+ request.DepositDate = depositOffset is { } d ? Today.AddDays(d) : null;
+
+ var ex = await Assert.ThrowsAsync(() => DepositService(db, Start, new FakeGeneralLedger()).CreateAsync(request));
+
+ Assert.Equal(ErrorCodes.BankDepositDateInvalid, ex.Code);
+ }
+
+ [Fact]
+ public async Task Create_GlRejection_LeavesNoDeposit()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.Add(CashSlip(Day, 1000));
+ await db.SaveChangesAsync();
+
+ await Assert.ThrowsAsync(() =>
+ DepositService(db, Start, new FakeGeneralLedger { Reject = true }).CreateAsync(Request(500)));
+
+ Assert.Equal(0, await db.BankDeposits.CountAsync());
+ }
+
+ [Fact]
+ public async Task List_FiltersAndTotalsEveryMatchingRow()
+ {
+ await using var db = CreateContext();
+ db.BankDeposits.AddRange(
+ Deposit(Day, 100), Deposit(Day, 200), Deposit(Day.AddDays(-1), 400),
+ Deposit(Day, 800, branchId: Branch2), Deposit(Day, 1600, status: BankDepositStatus.Cancelled));
+ await db.SaveChangesAsync();
+
+ var result = await DepositService(db, Start, new FakeGeneralLedger()).ListAsync(
+ new() { PageSize = 1 }, new BankDepositListFilter(Day, Day, Branch1, null, BankDepositStatus.Posted));
+
+ Assert.Single(result.Items);
+ Assert.Equal(2, result.Pagination.TotalItems);
+ Assert.Equal(300m, result.TotalAmount);
+ }
+
+ private static CreateBankDepositRequest Request(decimal amount, int branchId = Branch1, DateOnly? depositDate = null) => new()
+ {
+ BranchId = branchId,
+ BusinessDate = Day,
+ DepositDate = depositDate,
+ Amount = amount,
+ FromGlCashAccountId = FakeGeneralLedger.CashAccountId,
+ ToGlBankAccountId = FakeGeneralLedger.BankAccountId,
+ DepositSlipNo = "SLIP-1"
+ };
+}
diff --git a/Backend/ERPCore.Tests/CashInHandServiceTests.cs b/Backend/ERPCore.Tests/CashInHandServiceTests.cs
new file mode 100644
index 0000000..0241c10
--- /dev/null
+++ b/Backend/ERPCore.Tests/CashInHandServiceTests.cs
@@ -0,0 +1,134 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
+
+namespace ERPCore.Tests;
+
+/// Till cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §11).
+public class CashInHandServiceTests
+{
+ private static readonly DateOnly Today = Clock.Today;
+ private static readonly DateOnly Start = Today.AddDays(-10);
+
+ [Fact]
+ public async Task OnlyCashIsCounted()
+ {
+ await using var db = CreateContext();
+ var day = Today.AddDays(-2);
+ db.SalesSlips.AddRange(
+ CashSlip(day, cash: 300, card: 200), // only the 300 is till cash
+ CashSlip(day, cash: 0, card: 900));
+ db.SalesInvoices.Add(new SalesInvoice
+ {
+ SalesInvoiceId = 1, InvoiceNo = "INV-1", InvoiceDate = At(day.AddDays(-5)), Status = SalesInvoiceStatus.Posted, BranchId = Branch1
+ });
+ db.SalesInvoicePayments.AddRange(
+ new SalesInvoicePayment { SalesInvoicePaymentId = 1, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cash, Amount = 150 },
+ new SalesInvoicePayment { SalesInvoicePaymentId = 2, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Card, Amount = 500 },
+ new SalesInvoicePayment { SalesInvoicePaymentId = 3, SalesInvoiceId = 1, PaymentDate = At(day), PaymentMethod = SalesPaymentMethod.Cheque, Amount = 70 });
+ db.Expenses.Add(new Expense { ExpenseId = 1, DocNo = "EXP-1", BranchId = Branch1, ExpenseDate = day, Amount = 100, Status = ExpenseStatus.Posted });
+ db.ExpensePayments.AddRange(
+ new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.Cash, Amount = 40 },
+ new ExpensePayment { ExpensePaymentId = 2, ExpenseId = 1, PaymentDate = At(day), PaymentMethod = ExpensePaymentMethod.BankTransfer, Amount = 60 });
+ await db.SaveChangesAsync();
+
+ var p = (await CashService(db, Start).GetPositionAsync(day, Branch1)).Position;
+
+ Assert.Equal(450m, p.CashIn);
+ Assert.Equal(40m, p.CashOut);
+ Assert.Equal(410m, p.NetCash);
+ Assert.Equal(410m, p.Closing);
+ }
+
+ [Fact]
+ public async Task UndepositedCash_CarriesToTheNextDay_AndDepositsReduceIt()
+ {
+ await using var db = CreateContext();
+ var d1 = Today.AddDays(-3);
+ var d2 = Today.AddDays(-2);
+ db.SalesSlips.AddRange(CashSlip(d1, 1000), CashSlip(d2, 500));
+ db.BankDeposits.AddRange(
+ Deposit(d1, 600),
+ Deposit(d1, 999, status: BankDepositStatus.Cancelled)); // ignored
+ await db.SaveChangesAsync();
+
+ var days = (await CashService(db, Start).GetDailyAsync(d1, d2, Branch1)).Days;
+
+ Assert.Equal((0m, 1000m, 600m, 400m), (days[0].Opening, days[0].NetCash, days[0].Deposited, days[0].Closing));
+ Assert.Equal((400m, 500m, 0m, 900m), (days[1].Opening, days[1].NetCash, days[1].Deposited, days[1].Closing));
+ }
+
+ [Fact]
+ public async Task Opening_IsCarriedInFromBeforeTheWindow()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.AddRange(CashSlip(Today.AddDays(-6), 700), CashSlip(Today.AddDays(-5), 300));
+ db.BankDeposits.Add(Deposit(Today.AddDays(-5), 200));
+ await db.SaveChangesAsync();
+
+ var day = (await CashService(db, Start).GetPositionAsync(Today.AddDays(-1), Branch1)).Position;
+
+ Assert.Equal(800m, day.Opening);
+ Assert.Equal(800m, day.Closing);
+ }
+
+ [Fact]
+ public async Task CashBeforeTheTrackingStart_IsNotCarried()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.AddRange(CashSlip(Start.AddDays(-1), 5000), CashSlip(Start, 100));
+ await db.SaveChangesAsync();
+
+ var days = (await CashService(db, Start).GetDailyAsync(Start.AddDays(-1), Start, Branch1)).Days;
+
+ Assert.Equal(5000m, days[0].NetCash); // still reported for the day itself
+ Assert.Equal(0m, days[1].Opening); // but not carried into tracking
+ Assert.Equal(100m, days[1].Closing);
+ }
+
+ [Fact]
+ public async Task Available_IsTheLowestClosingFromThatDayToToday()
+ {
+ await using var db = CreateContext();
+ var d1 = Today.AddDays(-2);
+ db.SalesSlips.Add(CashSlip(d1, 1000));
+ db.BankDeposits.Add(Deposit(Today.AddDays(-1), 800)); // already took most of it the next day
+ await db.SaveChangesAsync();
+
+ var position = await CashService(db, Start).GetPositionAsync(d1, Branch1);
+
+ Assert.Equal(1000m, position.Position.Closing);
+ Assert.Equal(200m, position.AvailableToDeposit);
+ }
+
+ [Fact]
+ public async Task Branches_AreKeptApart()
+ {
+ await using var db = CreateContext();
+ var day = Today.AddDays(-1);
+ db.SalesSlips.AddRange(CashSlip(day, 100, warehouseId: Warehouse1), CashSlip(day, 900, warehouseId: Warehouse2));
+ await db.SaveChangesAsync();
+
+ var service = CashService(db, Start);
+
+ Assert.Equal(100m, (await service.GetPositionAsync(day, Branch1)).Position.Closing);
+ Assert.Equal(900m, (await service.GetPositionAsync(day, Branch2)).Position.Closing);
+ Assert.Equal(1000m, (await service.GetPositionAsync(day, null)).Position.Closing);
+ }
+
+ [Fact]
+ public async Task LateEveningSale_CountsOnTheLocalDay()
+ {
+ await using var db = CreateContext();
+ var day = Today.AddDays(-1);
+ var slip = CashSlip(day, 250);
+ slip.SlipDate = Clock.ToUtc(day.AddDays(1)).AddMinutes(-30); // 23:30 local
+ db.SalesSlips.Add(slip);
+ await db.SaveChangesAsync();
+
+ var days = (await CashService(db, Start).GetDailyAsync(day, day.AddDays(1), Branch1)).Days;
+
+ Assert.Equal(250m, days[0].CashIn);
+ Assert.Equal(0m, days[1].CashIn);
+ }
+}
diff --git a/Backend/ERPCore.Tests/DayEndReportServiceTests.cs b/Backend/ERPCore.Tests/DayEndReportServiceTests.cs
new file mode 100644
index 0000000..bf9613c
--- /dev/null
+++ b/Backend/ERPCore.Tests/DayEndReportServiceTests.cs
@@ -0,0 +1,257 @@
+using ERPCore.Domain;
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Infra.Auth;
+using ERPCore.Infra.Persistence;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Configuration;
+
+namespace ERPCore.Tests;
+
+/// Aggregates of the Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §7).
+public class DayEndReportServiceTests
+{
+ private static readonly InMemoryDatabaseRoot Root = new();
+
+ // Asia/Colombo is UTC+05:30 all year (no DST), so local midnight is 18:30 UTC the day before.
+ private static readonly DateOnly Day = new(2026, 10, 8);
+ private static DateTime UtcAt(int hour, int minute = 0) => new(2026, 10, 8, hour, minute, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public async Task Day_MixOfSlipsBundlesAndInvoices_TotalsAndSplitsByMethod()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.AddRange(
+ Slip(1, UtcAt(5), total: 1000, paid: 600, cash: 400, card: 200), // partial, mixed
+ Slip(2, UtcAt(6), total: 200, paid: 200, cash: 200, card: 0), // all cash
+ Slip(3, UtcAt(7), total: 999, paid: 999, cash: 999, card: 0, status: SalesSlipStatus.Draft));
+ db.BundleSales.Add(new BundleSale { BundleSaleId = 1, BundleDate = UtcAt(8), Status = BundleSaleStatus.Posted, GrandTotal = 500, CashAmount = 0, CardAmount = 500 });
+ db.SalesInvoices.Add(Invoice(1, UtcAt(9), netPayable: 2000, paid: 1500));
+ db.SalesInvoicePayments.AddRange(
+ Payment(1, invoiceId: 1, UtcAt(9), SalesPaymentMethod.Cash, 500),
+ Payment(2, invoiceId: 1, UtcAt(10), SalesPaymentMethod.Card, 1000));
+ SeedExpenseSetup(db);
+ db.Expenses.AddRange(
+ Expense(1, Day, 300, ExpenseStatus.Posted),
+ Expense(2, Day, 700, ExpenseStatus.Cancelled),
+ Expense(3, Day.AddDays(1), 50, ExpenseStatus.Posted));
+ await db.SaveChangesAsync();
+
+ var report = await CreateService(db).GetDayAsync(Day, null);
+ var s = report.Summary;
+
+ Assert.Equal(3700m, s.TotalSales);
+ Assert.Equal(2800m, s.PaidAmount);
+ Assert.Equal(900m, s.DueAmount);
+ Assert.Equal(1100m, s.PaidByCash);
+ Assert.Equal(1700m, s.PaidByCard);
+ Assert.Equal(0m, s.PaidByOther);
+ Assert.Equal(4, s.SalesCount);
+ Assert.Equal(1, s.CashSalesCount);
+ Assert.Equal(1, s.CardSalesCount);
+ Assert.Equal(2, s.MixedSalesCount);
+ Assert.Equal(0, s.UnpaidSalesCount);
+ Assert.Equal(300m, s.ExpenseTotal);
+ Assert.Equal(3400m, s.Income);
+ Assert.Single(report.Expenses);
+ Assert.Equal("Electricity", report.Expenses[0].TitleName);
+ }
+
+ [Fact]
+ public async Task Day_SaleLateInTheLocalEvening_CountsOnTheLocalDay()
+ {
+ await using var db = CreateContext();
+ // 19:00 UTC on Oct 7 is 00:30 on Oct 8 in Colombo; 18:00 UTC on Oct 8 is 23:30 on Oct 8.
+ db.SalesSlips.AddRange(
+ Slip(1, new DateTime(2026, 10, 7, 19, 0, 0, DateTimeKind.Utc), total: 100, paid: 100, cash: 100, card: 0),
+ Slip(2, new DateTime(2026, 10, 8, 18, 0, 0, DateTimeKind.Utc), total: 40, paid: 40, cash: 40, card: 0),
+ Slip(3, new DateTime(2026, 10, 8, 19, 0, 0, DateTimeKind.Utc), total: 7, paid: 7, cash: 7, card: 0));
+ await db.SaveChangesAsync();
+
+ var report = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day.AddDays(1), null);
+
+ Assert.Equal([0m, 140m, 7m], report.Days.Select(d => d.TotalSales));
+ Assert.Equal(147m, report.Totals.TotalSales);
+ }
+
+ [Fact]
+ public async Task Day_PaymentOnAnEarlierInvoice_IsACollectionAndCountsInNetCash()
+ {
+ await using var db = CreateContext();
+ db.SalesInvoices.Add(Invoice(1, UtcAt(5).AddDays(-3), netPayable: 800, paid: 250));
+ db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(6), SalesPaymentMethod.Cash, 250));
+ db.SalesSlips.Add(Slip(1, UtcAt(7), total: 60, paid: 60, cash: 60, card: 0));
+ SeedExpenseSetup(db);
+ db.Expenses.Add(Expense(1, Day, 30, ExpenseStatus.Posted));
+ db.ExpensePayments.Add(new ExpensePayment { ExpensePaymentId = 1, ExpenseId = 1, Amount = 30, PaymentDate = UtcAt(8), PaymentMethod = ExpensePaymentMethod.Cash });
+ await db.SaveChangesAsync();
+
+ var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
+
+ Assert.Equal(60m, s.TotalSales);
+ Assert.Equal(60m, s.PaidAmount);
+ Assert.Equal(250m, s.EarlierInvoiceCollections);
+ Assert.Equal(60m + 250m - 30m, s.NetCash);
+ }
+
+ [Fact]
+ public async Task Day_ShowsBankDepositsAndTheCashLeftInHand()
+ {
+ await using var db = CreateContext();
+ db.SalesSlips.AddRange(
+ Slip(1, UtcAt(5).AddDays(-1), total: 300, paid: 300, cash: 300, card: 0), // yesterday, never banked
+ Slip(2, UtcAt(6), total: 1000, paid: 1000, cash: 1000, card: 0));
+ db.BankDeposits.AddRange(
+ BankDeposit(1, Day, 800, BankDepositStatus.Posted),
+ BankDeposit(2, Day, 5000, BankDepositStatus.Cancelled));
+ await db.SaveChangesAsync();
+
+ var report = await CreateService(db).GetDayAsync(Day, null);
+ var s = report.Summary;
+
+ Assert.Equal((300m, 1000m, 800m, 500m), (s.OpeningCashInHand, s.NetCash, s.CashDeposited, s.ClosingCashInHand));
+ var deposit = Assert.Single(report.Deposits);
+ Assert.Equal(("BD-1", "BOC Current", 800m), (deposit.DocNo, deposit.BankAccountName, deposit.Amount));
+
+ var range = await CreateService(db).GetDailyAsync(Day.AddDays(-1), Day, null);
+ Assert.Equal((0m, 800m, 500m), (range.Totals.OpeningCashInHand, range.Totals.CashDeposited, range.Totals.ClosingCashInHand));
+ }
+
+ [Fact]
+ public async Task Day_Profit_IsSalesLessStockCostLessExpenses_AndCanBeALoss()
+ {
+ await using var db = CreateContext();
+ db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 1000, paid: 1000));
+ db.SalesInvoicePayments.Add(Payment(1, invoiceId: 1, UtcAt(5), SalesPaymentMethod.Card, 1000));
+ db.SalesSlips.Add(Slip(1, UtcAt(6), total: 200, paid: 0, cash: 0, card: 0));
+ db.StockLedger.AddRange(
+ Ledger(1, DocumentTypes.SalesInvoice, 1, Direction.Out, 600),
+ Ledger(2, DocumentTypes.SalesInvoice, 1, Direction.Out, 100),
+ Ledger(3, DocumentTypes.SalesSlip, 1, Direction.Out, 150),
+ Ledger(4, DocumentTypes.SalesInvoice, 1, Direction.In, 999)); // not a sale's cost
+ SeedExpenseSetup(db);
+ db.Expenses.Add(Expense(1, Day, 900, ExpenseStatus.Posted));
+ await db.SaveChangesAsync();
+
+ var report = await CreateService(db).GetDayAsync(Day, null);
+ var s = report.Summary;
+
+ Assert.Equal(850m, s.CostOfGoodsSold);
+ Assert.Equal(350m, s.GrossProfit);
+ Assert.Equal(-550m, s.NetProfit);
+ Assert.Equal(2, report.Sales.Count);
+ var invoice = report.Sales.Single(x => x.DocType == "Invoice");
+ Assert.Equal(("Card", 700m, 300m), (invoice.PaidBy, invoice.Cost, invoice.GrossProfit));
+ Assert.Equal("Unpaid", report.Sales.Single(x => x.DocType == "Slip").PaidBy);
+ }
+
+ [Fact]
+ public async Task Day_UnpaidInvoice_CountsAsUnpaidAndDue()
+ {
+ await using var db = CreateContext();
+ db.SalesInvoices.Add(Invoice(1, UtcAt(5), netPayable: 450, paid: 0));
+ await db.SaveChangesAsync();
+
+ var s = (await CreateService(db).GetDayAsync(Day, null)).Summary;
+
+ Assert.Equal(1, s.UnpaidSalesCount);
+ Assert.Equal(450m, s.DueAmount);
+ }
+
+ [Fact]
+ public async Task Daily_FillsQuietDaysWithZeros()
+ {
+ await using var db = CreateContext();
+ var report = await CreateService(db).GetDailyAsync(Day, Day.AddDays(4), null);
+
+ Assert.Equal(5, report.Days.Count);
+ Assert.All(report.Days, d => Assert.Equal(0m, d.Income));
+ Assert.Equal(Day.AddDays(4), report.Days[^1].Date);
+ }
+
+ [Theory]
+ [InlineData(1, 0)] // to before from
+ [InlineData(0, 92)] // 93 days
+ public async Task Daily_InvalidRange_IsRejected(int fromOffset, int toOffset)
+ {
+ await using var db = CreateContext();
+ var ex = await Assert.ThrowsAsync(() =>
+ CreateService(db).GetDailyAsync(Day.AddDays(fromOffset), Day.AddDays(toOffset), null));
+
+ Assert.Equal(ErrorCodes.DayEndReportRangeInvalid, ex.Code);
+ }
+
+ // --- helpers ------------------------------------------------------------------------------
+
+ private static SalesSlip Slip(int id, DateTime at, decimal total, decimal paid, decimal cash, decimal card,
+ SalesSlipStatus status = SalesSlipStatus.Posted) => new()
+ {
+ SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = at, Status = status,
+ GrandTotal = total, PaidAmount = paid, BalanceAmount = total - paid, CashAmount = cash, CardAmount = card
+ };
+
+ private static SalesInvoice Invoice(int id, DateTime at, decimal netPayable, decimal paid) => new()
+ {
+ SalesInvoiceId = id, InvoiceNo = $"INV-{id}", InvoiceDate = at, Status = SalesInvoiceStatus.Posted,
+ GrandTotal = netPayable, NetPayable = netPayable, PaidAmount = paid, BalanceAmount = netPayable - paid
+ };
+
+ private static SalesInvoicePayment Payment(int id, int invoiceId, DateTime at, SalesPaymentMethod method, decimal amount) => new()
+ {
+ SalesInvoicePaymentId = id, SalesInvoiceId = invoiceId, PaymentDate = at, PaymentMethod = method, Amount = amount
+ };
+
+ private static StockLedger Ledger(int id, string docType, int docId, Direction direction, decimal value) => new()
+ {
+ LedgerId = id, SourceDocType = docType, SourceDocId = docId, Direction = direction, Value = value, QtyBase = 1, ItemId = 1, WarehouseId = 1
+ };
+
+ private static Expense Expense(int id, DateOnly date, decimal amount, ExpenseStatus status) => new()
+ {
+ ExpenseId = id, DocNo = $"EXP-{id}", ExpenseDate = date, Amount = amount, Status = status,
+ ExpenseCategoryId = 1, ExpenseTitleId = 1
+ };
+
+ private static BankDeposit BankDeposit(int id, DateOnly businessDate, decimal amount, BankDepositStatus status) => new()
+ {
+ BankDepositId = id, DocNo = $"BD-{id}", BranchId = 1, BusinessDate = businessDate, DepositDate = businessDate, Amount = amount,
+ Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
+ ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current"
+ };
+
+ private static void SeedExpenseSetup(ErpDbContext db)
+ {
+ db.ExpenseCategories.Add(new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility" });
+ db.ExpenseTitles.Add(new ExpenseTitle { ExpenseTitleId = 1, ExpenseCategoryId = 1, Name = "Electricity", GlExpenseAccountCode = "5200-01" });
+ }
+
+ private static DayEndReportService CreateService(ErpDbContext db)
+ {
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { ["DayEndReport:TimeZone"] = "Asia/Colombo" })
+ .Build();
+ var clock = new BusinessDayClock(config);
+ var branch = new CurrentBranchContext(new TestCurrentUser());
+ var cash = new CashInHandService(
+ new Repository(db), new Repository(db), new Repository(db),
+ new Repository(db), new Repository(db), branch, clock, config);
+ return new DayEndReportService(
+ new Repository(db), new Repository(db), new Repository(db),
+ new Repository(db), new Repository(db),
+ new Repository(db), new Repository(db), cash, branch, clock);
+ }
+
+ private static ErpDbContext CreateContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
+ .Options;
+ var currentUser = new TestCurrentUser();
+ return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
+ }
+}
diff --git a/Backend/ERPCore.Tests/DualApprovalTests.cs b/Backend/ERPCore.Tests/DualApprovalTests.cs
new file mode 100644
index 0000000..638f4f0
--- /dev/null
+++ b/Backend/ERPCore.Tests/DualApprovalTests.cs
@@ -0,0 +1,392 @@
+using ERPCore.Domain;
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Approvals;
+using ERPCore.Dtos.Notifications;
+using ERPCore.Dtos.Products;
+using ERPCore.Dtos.Stock;
+using ERPCore.Infra.Auth;
+using ERPCore.Infra.Persistence;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.Services.Gl;
+using ERPCore.Services.Interfaces;
+using ERPCore.Services.Stock;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Configuration;
+
+namespace ERPCore.Tests;
+
+/// Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md §13).
+public class DualApprovalTests
+{
+ private static readonly InMemoryDatabaseRoot Root = new();
+
+ // Requester, two approvers, and a clerk who can approve but isn't a manager.
+ private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
+ private const int ApproverRoleId = 2, ClerkRoleId = 3;
+ private const int ItemId = 1, WarehouseId = 1, ReasonId = 1;
+
+ // --- Stock adjustments ------------------------------------------------------------------
+
+ [Fact]
+ public async Task StockAdjustment_IsARequest_NothingMovesUntilApproved()
+ {
+ var h = await Harness.CreateAsync();
+
+ var dto = await h.Stock.CreateAsync(Request(-3));
+
+ Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
+ Assert.Equal(2, dto.RequiredApprovals);
+ Assert.Equal(1, dto.PendingLevel);
+ Assert.Empty(h.Mutator.Calls);
+ Assert.Empty(h.Gl.Posted);
+ var pending = Assert.Single(h.Notifications.Targeted, n => n.ModuleKey == "approval.pending");
+ Assert.Equal([ApproverA, ApproverB, Clerk], pending.UserIds.Order()); // never the requester
+ }
+
+ [Fact]
+ public async Task StockAdjustment_TwoDifferentApprovers_ThenItPosts()
+ {
+ var h = await Harness.CreateAsync();
+ var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
+
+ h.User.Be(Requester);
+ var own = await Assert.ThrowsAsync(() => h.Stock.ApproveAsync(id, null));
+ Assert.Equal(ErrorCodes.ApprovalForbidden, own.Code);
+
+ h.User.Be(ApproverA);
+ var first = await h.Stock.ApproveAsync(id, "counted twice");
+ Assert.Equal(AdjustmentStatus.PendingSecondApproval, first.Status);
+ Assert.Empty(h.Mutator.Calls);
+
+ var twice = await Assert.ThrowsAsync(() => h.Stock.ApproveAsync(id, null));
+ Assert.Equal(ErrorCodes.ApprovalSamePerson, twice.Code);
+
+ h.User.Be(ApproverB);
+ var posted = await h.Stock.ApproveAsync(id, null);
+
+ Assert.Equal(AdjustmentStatus.Posted, posted.Status);
+ Assert.Equal((ApproverA, ApproverB), (posted.ApprovedLevel1By, posted.ApprovedLevel2By));
+ var call = Assert.Single(h.Mutator.Calls);
+ Assert.Equal(-3m, Assert.Single(call).QtyDelta);
+ Assert.Single(h.Gl.Posted);
+ Assert.NotNull(posted.PostedAt);
+ Assert.Equal([1, 2], posted.ApprovalHistory!.Select(s => s.Level));
+ Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.approved" && n.UserIds.SequenceEqual([Requester]));
+ }
+
+ [Fact]
+ public async Task StockAdjustment_Rejected_NeedsAReason_AndNeverPosts()
+ {
+ var h = await Harness.CreateAsync();
+ var id = (await h.Stock.CreateAsync(Request(5))).AdjustmentId;
+ h.User.Be(ApproverA);
+
+ var noReason = await Assert.ThrowsAsync(() => h.Stock.RejectAsync(id, " "));
+ Assert.Equal(ErrorCodes.ApprovalReasonRequired, noReason.Code);
+
+ var rejected = await h.Stock.RejectAsync(id, "wrong warehouse");
+ Assert.Equal((AdjustmentStatus.Rejected, "wrong warehouse"), (rejected.Status, rejected.RejectReason));
+
+ h.User.Be(ApproverB);
+ var late = await Assert.ThrowsAsync(() => h.Stock.ApproveAsync(id, null));
+ Assert.Equal(ErrorCodes.ApprovalNotPending, late.Code);
+ Assert.Empty(h.Mutator.Calls);
+ Assert.Contains(h.Notifications.Targeted, n => n.ModuleKey == "approval.rejected");
+ }
+
+ [Fact]
+ public async Task StockAdjustment_ApprovalsOff_PostsOnCreate()
+ {
+ var h = await Harness.CreateAsync(stockLevels: 0);
+
+ var dto = await h.Stock.CreateAsync(Request(2));
+
+ Assert.Equal((AdjustmentStatus.Posted, 0), (dto.Status, dto.RequiredApprovals));
+ Assert.Single(h.Mutator.Calls);
+ }
+
+ [Fact]
+ public async Task StockAdjustment_BelowTheValueThreshold_NeedsOneApproval()
+ {
+ // 3 units at 10.00 each = 30.00, below the 1,000 threshold.
+ var h = await Harness.CreateAsync(stockThreshold: 1000);
+ var id = (await h.Stock.CreateAsync(Request(-3))).AdjustmentId;
+
+ h.User.Be(ApproverA);
+ var posted = await h.Stock.ApproveAsync(id, null);
+
+ Assert.Equal((AdjustmentStatus.Posted, 1), (posted.Status, posted.RequiredApprovals));
+ Assert.Single(h.Mutator.Calls);
+ }
+
+ [Fact]
+ public async Task StockAdjustment_OnlyTheRequesterCanWithdraw()
+ {
+ var h = await Harness.CreateAsync();
+ var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
+
+ h.User.Be(ApproverA);
+ var other = await Assert.ThrowsAsync(() => h.Stock.CancelAsync(id));
+ Assert.Equal(ErrorCodes.ApprovalForbidden, other.Code);
+
+ h.User.Be(Requester);
+ Assert.Equal(AdjustmentStatus.Cancelled, (await h.Stock.CancelAsync(id)).Status);
+ }
+
+ [Fact]
+ public async Task SecondApproval_CanBeLimitedToRoles()
+ {
+ var h = await Harness.CreateAsync(level2Roles: "Approver");
+ var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
+ h.User.Be(ApproverA);
+ await h.Stock.ApproveAsync(id, null);
+
+ h.User.Be(Clerk);
+ var clerk = await Assert.ThrowsAsync(() => h.Stock.ApproveAsync(id, null));
+ Assert.Equal(ErrorCodes.ApprovalRoleRequired, clerk.Code);
+
+ h.User.Be(ApproverB);
+ Assert.Equal(AdjustmentStatus.Posted, (await h.Stock.ApproveAsync(id, null)).Status);
+ }
+
+ // --- Price adjustments ------------------------------------------------------------------
+
+ [Fact]
+ public async Task PriceAdjustment_AppliesOnlyAfterTheSecondApproval_AndKeepsHistory()
+ {
+ var h = await Harness.CreateAsync();
+ var dto = await h.Prices.CreateAsync(PriceRequest(flat: 120m));
+ Assert.Equal(PriceAdjustmentStatus.PendingApproval, dto.Status);
+ Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
+
+ h.User.Be(ApproverA);
+ await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
+ Assert.Equal(100m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
+
+ h.User.Be(ApproverB);
+ var applied = await h.Prices.ApproveAsync(dto.PriceAdjustmentId, null);
+
+ Assert.Equal(PriceAdjustmentStatus.Approved, applied.Status);
+ Assert.Equal(120m, (await h.Db.Items.AsNoTracking().FirstAsync()).SellingFlatPrice);
+ var history = Assert.Single(await h.Prices.ItemHistoryAsync(ItemId));
+ Assert.Equal((100m, 120m, ApproverA, ApproverB, dto.DocNo), (history.OldFlatPrice, history.NewFlatPrice, history.ApprovedLevel1By, history.ApprovedLevel2By, history.DocNo));
+ }
+
+ [Fact]
+ public async Task PriceAdjustment_ItemRepricedSinceTheRequest_IsRefusedAsStale()
+ {
+ var h = await Harness.CreateAsync();
+ var id = (await h.Prices.CreateAsync(PriceRequest(flat: 120m))).PriceAdjustmentId;
+ h.User.Be(ApproverA);
+ await h.Prices.ApproveAsync(id, null);
+
+ var item = await h.Db.Items.FirstAsync();
+ item.SellingFlatPrice = 105m; // someone changed it in between
+ await h.Db.SaveChangesAsync();
+
+ h.User.Be(ApproverB);
+ var stale = await Assert.ThrowsAsync(() => h.Prices.ApproveAsync(id, null));
+ Assert.Equal(ErrorCodes.PriceAdjustmentStale, stale.Code);
+ Assert.Empty(await h.Prices.ItemHistoryAsync(ItemId));
+ }
+
+ [Fact]
+ public async Task PriceAdjustment_OnePendingRequestPerItem()
+ {
+ var h = await Harness.CreateAsync();
+ await h.Prices.CreateAsync(PriceRequest(flat: 120m));
+
+ var second = await Assert.ThrowsAsync(() => h.Prices.CreateAsync(PriceRequest(flat: 130m)));
+ Assert.Equal(ErrorCodes.PriceAdjustmentItemPending, second.Code);
+ }
+
+ [Theory]
+ [InlineData(100, 120, 20)]
+ [InlineData(100, 80, 20)]
+ [InlineData(-1, 50, 100)] // setting a price where there was none (-1 = no price)
+ [InlineData(50, -1, 100)] // clearing one
+ [InlineData(40, 40, 0)]
+ public void ChangePercent_MeasuresTheSizeOfAPriceChange(int oldPrice, int newPrice, int expected)
+ {
+ decimal? Price(int v) => v < 0 ? null : v;
+ Assert.Equal(expected, PriceAdjustmentService.ChangePercent(Price(oldPrice), Price(newPrice)));
+ }
+
+ [Theory]
+ [InlineData(2, 0, 5000, 2)] // no threshold: always two
+ [InlineData(2, 1000, 50, 1)] // below threshold: one
+ [InlineData(2, 1000, 1000, 2)] // at threshold: two
+ [InlineData(1, 1000, 5000, 1)]
+ [InlineData(0, 0, 5000, 0)]
+ public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
+ => Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
+
+ // --- helpers ------------------------------------------------------------------------------
+
+ private static CreateAdjustmentRequest Request(decimal qty) => new()
+ {
+ WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = ReasonId,
+ Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
+ };
+
+ private static CreatePriceAdjustmentRequest PriceRequest(decimal flat) => new()
+ {
+ Reason = "Supplier price rise",
+ Lines = [new PriceAdjustmentLineInput { ItemId = ItemId, NewMinPrice = 90m, NewMaxPrice = 150m, NewFlatPrice = flat }]
+ };
+
+ private sealed class Harness
+ {
+ public required ErpDbContext Db { get; init; }
+ public required SwitchableUser User { get; init; }
+ public required RecordingMutator Mutator { get; init; }
+ public required FakeGeneralLedger Gl { get; init; }
+ public required RecordingNotifications Notifications { get; init; }
+ public required AdjustmentService Stock { get; init; }
+ public required PriceAdjustmentService Prices { get; init; }
+
+ public static async Task CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
+ {
+ var db = new ErpDbContext(
+ new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
+ new TestCurrentUser(), new CurrentBranchContext(new TestCurrentUser()));
+
+ db.Roles.AddRange(
+ new Role { RoleId = 1, Code = "Requester", Name = "Requester" },
+ new Role { RoleId = ApproverRoleId, Code = "Approver", Name = "Approver" },
+ new Role { RoleId = ClerkRoleId, Code = "Clerk", Name = "Clerk" });
+ db.Users.AddRange(
+ new User { UserId = ERPCore.Domain.Entities.User.SystemUserId, Username = "system", DisplayName = "System" },
+ new User { UserId = Requester, Username = "req", DisplayName = "Requester", RoleId = 1 },
+ new User { UserId = ApproverA, Username = "a", DisplayName = "Approver A", RoleId = ApproverRoleId },
+ new User { UserId = ApproverB, Username = "b", DisplayName = "Approver B", RoleId = ApproverRoleId },
+ new User { UserId = Clerk, Username = "c", DisplayName = "Clerk", RoleId = ClerkRoleId });
+ db.Permissions.AddRange(
+ new Permission { PermissionId = 45, Code = "NAV:stock.adjustments" },
+ new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments" });
+ foreach (var roleId in new[] { ApproverRoleId, ClerkRoleId })
+ foreach (var permissionId in new[] { 45, 58 })
+ db.RolePermissions.Add(new RolePermission { RoleId = roleId, PermissionId = permissionId, Actions = RolePermissionAction.All });
+ db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 45, Actions = RolePermissionAction.View | RolePermissionAction.Create });
+
+ db.Branches.Add(new Branch { BranchId = 1, Code = "BR-001", Name = "Main" });
+ db.Warehouses.Add(new Warehouse { WarehouseId = WarehouseId, Code = "WH-1", Name = "Main Store", BranchId = 1 });
+ db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
+ db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
+ SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
+ db.ReasonCodes.Add(new ReasonCode { ReasonCodeId = ReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
+ db.ApprovalConfig.Add(new ApprovalConfig
+ {
+ ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
+ StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
+ });
+ await db.SaveChangesAsync();
+
+ var user = new SwitchableUser(Requester, "Requester");
+ var uow = new PassThroughUnitOfWork(db);
+ var notifications = new RecordingNotifications();
+ var approvals = new ApprovalService(
+ new Repository(db), new Repository(db), new Repository(db),
+ new Repository(db), user, notifications, uow);
+ var gl = new FakeGeneralLedger();
+ var mutator = new RecordingMutator();
+ var numbers = new FakeNumberSequence();
+
+ var stock = new AdjustmentService(
+ new Repository(db), new Repository(db), new Repository- (db),
+ new Repository(db), new Repository(db), mutator, new FixedValuation(),
+ numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
+ new StubGlAccountConfigService(
+ (GlAccountRole.Inventory, "1200"),
+ (GlAccountRole.InventoryGain, "4900"),
+ (GlAccountRole.InventoryLoss, "5900")),
+ new SerialTrackingService(new Repository(db), new Repository(db), new Repository(db),
+ new Repository(db), user));
+ var prices = new PriceAdjustmentService(
+ new Repository(db), new Repository
- (db), new Repository(db),
+ new Repository(db), new Repository(db), approvals, notifications, numbers, user, uow);
+
+ return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
+ }
+ }
+
+ /// A signed-in user whose identity the test switches between steps.
+ private sealed class SwitchableUser(int userId, string roleCode) : ICurrentUser
+ {
+ private static readonly Dictionary Roles = new()
+ {
+ [Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
+ };
+
+ public int AuditUserId { get; private set; } = userId;
+ public string? RoleCode { get; private set; } = roleCode;
+ public string UserId => AuditUserId.ToString();
+ public bool IsAuthenticated => true;
+ public bool IsAdmin => false;
+ public int? BranchId => null;
+
+ public void Be(int userId)
+ {
+ AuditUserId = userId;
+ RoleCode = Roles[userId];
+ }
+ }
+
+ /// Records what would be posted to stock; every unit is worth 10.00.
+ private sealed class RecordingMutator : IStockMutator
+ {
+ public List> Calls { get; } = new();
+
+ public Task> ApplyAsync(int warehouseId, string sourceDocType, int sourceDocId, DateTime now,
+ IReadOnlyList deltas, CancellationToken ct = default)
+ {
+ Calls.Add(deltas);
+ IReadOnlyList refs = deltas.Select(d => new StockLedger
+ {
+ ItemId = d.ItemId, WarehouseId = warehouseId, QtyBase = Math.Abs(d.QtyDelta), UnitCost = 10m, Value = Math.Abs(d.QtyDelta) * 10m,
+ Direction = d.QtyDelta < 0 ? Direction.Out : Direction.In, SourceDocType = sourceDocType, SourceDocId = sourceDocId, CreatedAt = now
+ }).ToList();
+ return Task.FromResult(refs);
+ }
+ }
+
+ /// 10 units on hand worth 100.00 — 10.00 each, which prices requests for the threshold.
+ private sealed class FixedValuation : IFifoCostingService
+ {
+ public Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
+ => Task.FromResult(new StockValuationDto(itemId, warehouseId, [], 10m, 100m, "LKR", "FIFO"));
+ public Task CreateInboundLayerAsync(int itemId, int warehouseId, int? batchId, int? serialId, int? grnLineId,
+ decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task> ConsumeAsync(int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
+ bool allowShortfall = false, IReadOnlyCollection? serialIds = null) => throw new NotSupportedException();
+ public Task> SuggestBatchesAsync(int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ public Task PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
+ Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
+ DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException();
+ public Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
+ }
+
+ private sealed class RecordingNotifications : INotificationService
+ {
+ public List<(IReadOnlyList UserIds, string ModuleKey, string Message)> Targeted { get; } = new();
+
+ public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null,
+ CancellationToken ct = default) => Task.CompletedTask;
+
+ public Task NotifyUsersAsync(IEnumerable userIds, string moduleKey, string title, string message,
+ string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default)
+ {
+ Targeted.Add((userIds.ToList(), moduleKey, message));
+ return Task.CompletedTask;
+ }
+
+ public Task GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
+ }
+}
diff --git a/Backend/ERPCore.Tests/ItemTrackingTests.cs b/Backend/ERPCore.Tests/ItemTrackingTests.cs
new file mode 100644
index 0000000..3b83fd2
--- /dev/null
+++ b/Backend/ERPCore.Tests/ItemTrackingTests.cs
@@ -0,0 +1,366 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Infra.Auth;
+using ERPCore.Infra.Persistence;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.Services.Stock;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace ERPCore.Tests;
+
+///
+/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO
+/// SQL: the sales-line check () and the
+/// shared stock-document rules ().
+///
+public class ItemTrackingTests
+{
+ private static readonly InMemoryDatabaseRoot Root = new();
+
+ private const int Wh1 = 1;
+ private const int Wh2 = 2;
+ private const int SerialItemId = 10;
+ private const int BatchItemId = 20;
+ private const int PlainItemId = 30;
+
+ // --- sales line validation --------------------------------------------------------------
+
+ [Fact]
+ public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet()));
+
+ Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
+ }
+
+ [Fact]
+ public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet()));
+
+ Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
+ }
+
+ [Fact]
+ public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock()
+ {
+ await using var db = await SeedAsync();
+ // D6: stock received before serial capture — a layer with no serial.
+ db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow });
+ await db.SaveChangesAsync();
+ var sales = CreateSalesDomain(db);
+
+ var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet());
+
+ Assert.Empty(serials);
+ }
+
+ [Fact]
+ public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ // Serial 3 is in stock at warehouse 2.
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet()));
+
+ Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
+ }
+
+ [Fact]
+ public async Task SalesLine_SoldSerial_IsNotAvailable()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet()));
+
+ Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
+ }
+
+ [Fact]
+ public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+ var documentSerials = new HashSet();
+
+ await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials);
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials));
+
+ Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
+ }
+
+ [Fact]
+ public async Task SalesLine_ValidSerials_AreReturned()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet());
+
+ Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i));
+ }
+
+ [Fact]
+ public async Task SalesLine_UntrackedItem_RejectsSerials()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet()));
+
+ Assert.Equal(ErrorCodes.Validation, ex.Code);
+ }
+
+ [Fact]
+ public async Task SalesLine_ExpiredBatch_IsBlocked()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet()));
+
+ Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code);
+ }
+
+ [Fact]
+ public async Task SalesLine_BatchOfAnotherItem_IsRejected()
+ {
+ await using var db = await SeedAsync();
+ var sales = CreateSalesDomain(db);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet()));
+
+ Assert.Equal(ErrorCodes.Validation, ex.Code);
+ }
+
+ // --- stock documents (transfers / adjustments / purchase returns) ------------------------
+
+ [Fact]
+ public async Task Outbound_DamagedSerial_OnlyWhenAllowed()
+ {
+ await using var db = await SeedAsync();
+ var tracking = CreateSerialTracking(db);
+ var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
+
+ // Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it...
+ var ex = await Assert.ThrowsAsync(() => tracking.ResolveOutboundAsync(
+ item, Wh1, 1, new[] { 5 }, new HashSet(), new[] { SerialStatus.InStock }));
+ Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
+
+ // ...a write-off or purchase return (InStock or Damaged) takes it.
+ var serials = await tracking.ResolveOutboundAsync(
+ item, Wh1, 1, new[] { 5 }, new HashSet(), new[] { SerialStatus.InStock, SerialStatus.Damaged });
+ Assert.Single(serials);
+ }
+
+ [Fact]
+ public async Task Inbound_SerialAlreadyInStock_IsADuplicate()
+ {
+ await using var db = await SeedAsync();
+ var tracking = CreateSerialTracking(db);
+ var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
+
+ var ex = await Assert.ThrowsAsync(() => tracking.ResolveInboundAsync(
+ item, Wh1, 1, new[] { "SN-1" }, new HashSet()));
+
+ Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
+ }
+
+ [Fact]
+ public async Task Inbound_FractionalQty_IsRejected_ForSerialItems()
+ {
+ await using var db = await SeedAsync();
+ var tracking = CreateSerialTracking(db);
+ var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
+
+ var ex = await Assert.ThrowsAsync(() => tracking.ResolveInboundAsync(
+ item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet()));
+
+ Assert.Equal(ErrorCodes.Validation, ex.Code);
+ }
+
+ [Fact]
+ public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack()
+ {
+ await using var db = await SeedAsync();
+ var tracking = CreateSerialTracking(db);
+ var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
+
+ // SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created.
+ var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet());
+ await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow);
+ await db.SaveChangesAsync();
+
+ var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync();
+ Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status));
+ Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId));
+ Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ"));
+ }
+
+ [Fact]
+ public async Task Move_RecordsHistory_WithPreviousWarehouse()
+ {
+ await using var db = await SeedAsync();
+ var tracking = CreateSerialTracking(db);
+ var serial = await db.Serials.FirstAsync(s => s.SerialId == 1);
+
+ await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow);
+ await db.SaveChangesAsync();
+
+ var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1);
+ Assert.Equal(SerialStatus.InTransit, movement.Status);
+ Assert.Equal(Wh1, movement.FromWarehouseId);
+ Assert.Null(movement.WarehouseId);
+ Assert.Null(serial.WarehouseId);
+ }
+
+ // --- auto-generated serial numbers -------------------------------------------------------
+
+ [Fact]
+ public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers()
+ {
+ await using var db = await SeedAsync();
+ db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold });
+ // A draft GRN line already holds PH-0042 (not yet a Serial).
+ db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1,
+ Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } });
+ await db.SaveChangesAsync();
+ var tracking = new ItemTrackingService(db, null!);
+
+ var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3);
+
+ Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next);
+ }
+
+ [Fact]
+ public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems()
+ {
+ await using var db = await SeedAsync();
+ db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock });
+ await db.SaveChangesAsync();
+ var tracking = new ItemTrackingService(db, null!);
+
+ var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2);
+
+ Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next);
+ }
+
+ [Fact]
+ public async Task NextNumbers_RejectsTooMany()
+ {
+ await using var db = await SeedAsync();
+ var tracking = new ItemTrackingService(db, null!);
+
+ var ex = await Assert.ThrowsAsync(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000));
+
+ Assert.Equal(ErrorCodes.Validation, ex.Code);
+ }
+
+ // --- fixtures ---------------------------------------------------------------------------
+
+ private static async Task SeedAsync()
+ {
+ var db = CreateContext();
+ db.Items.AddRange(
+ new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial },
+ new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch },
+ new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None });
+ db.Batches.AddRange(
+ new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) },
+ new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) });
+ db.Serials.AddRange(
+ new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 },
+ new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 },
+ new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 },
+ new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null },
+ new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 });
+ await db.SaveChangesAsync();
+ return db;
+ }
+
+ private static ErpDbContext CreateContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
+ .Options;
+ var currentUser = new TestCurrentUser();
+ return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
+ }
+
+ // Pricing is never touched by the tracking check, so it is not wired.
+ private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new(
+ new Repository(db), new Repository(db), new Repository(db),
+ new Repository
- (db), new Repository(db), null!, new Repository(db),
+ new Repository(db), new Repository(db), new Repository(db),
+ CreateSerialTracking(db));
+
+ private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new(
+ new Repository(db), new Repository(db), new Repository(db),
+ new Repository(db), new TestCurrentUser());
+
+ private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled)
+ {
+ db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled });
+ await db.SaveChangesAsync();
+ }
+
+ // --- Product Configuration "Serial numbers" switch ---------------------------------------
+
+ [Fact]
+ public async Task SwitchOff_SerialItemSells_WithoutSerials()
+ {
+ await using var db = await SeedAsync();
+ await SwitchSerialNumbersAsync(db, enabled: false);
+ var sales = CreateSalesDomain(db);
+
+ // No pre-tracking stock exists, yet nothing is asked: the item is plain stock now.
+ var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet());
+
+ Assert.Empty(serials);
+ }
+
+ [Fact]
+ public async Task SwitchOff_SerialsSent_AreRefused()
+ {
+ await using var db = await SeedAsync();
+ await SwitchSerialNumbersAsync(db, enabled: false);
+ var sales = CreateSalesDomain(db);
+ var tracking = CreateSerialTracking(db);
+ var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
+
+ var sale = await Assert.ThrowsAsync(() =>
+ sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet()));
+ var adjustment = await Assert.ThrowsAsync(() =>
+ tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet()));
+
+ Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code);
+ Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code);
+ }
+
+ [Fact]
+ public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing()
+ {
+ await using var db = await SeedAsync();
+ Assert.True(await CreateSerialTracking(db).IsEnabledAsync());
+ }
+}
diff --git a/Backend/ERPCore.Tests/LowStockAlertServiceTests.cs b/Backend/ERPCore.Tests/LowStockAlertServiceTests.cs
new file mode 100644
index 0000000..9360a77
--- /dev/null
+++ b/Backend/ERPCore.Tests/LowStockAlertServiceTests.cs
@@ -0,0 +1,116 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Notifications;
+using ERPCore.Dtos.Stock;
+using ERPCore.Infra.Persistence;
+using ERPCore.Repositories;
+using ERPCore.Services.Interfaces;
+using ERPCore.Services.Stock;
+using ERPCore.Tests.TestSupport;
+
+namespace ERPCore.Tests;
+
+/// Low-stock / reorder alerts raised after stock issues.
+public class LowStockAlertServiceTests
+{
+ private const int ItemId = 1, WarehouseId = 1;
+
+ [Theory]
+ [InlineData(12, 4, 8, true)] // 12 → 8 crosses the alert level of 10
+ [InlineData(12, 2, 10, true)] // landing exactly on the level counts
+ [InlineData(20, 5, 15, false)] // still above the level
+ [InlineData(9, 3, 6, false)] // was already low — no repeat alert on every sale
+ public async Task Alerts_OnlyWhenAnIssueCrossesTheReorderPoint(decimal before, decimal issued, decimal after, bool alerts)
+ {
+ Assert.Equal(before - issued, after);
+ await using var db = Seed();
+ var tracker = new LowStockTracker();
+ tracker.Track(ItemId, WarehouseId, issued);
+ var notifications = new RecordingNotifications();
+
+ await Service(db, tracker, after, notifications).CheckAsync();
+
+ if (!alerts)
+ {
+ Assert.Empty(notifications.Raised);
+ return;
+ }
+ var raised = Assert.Single(notifications.Raised);
+ Assert.Equal(LowStockAlertService.ModuleKey, raised.ModuleKey);
+ Assert.Contains("SKU-1", raised.Message);
+ Assert.Contains($"down to {after:0.####} pcs at WH-1", raised.Message);
+ Assert.Contains("Suggested reorder: 50 pcs", raised.Message);
+ }
+
+ [Fact]
+ public async Task ItemsWithoutARule_OrUntouched_AreIgnored()
+ {
+ await using var db = Seed();
+ var tracker = new LowStockTracker();
+ tracker.Track(itemId: 99, WarehouseId, 100); // no rule for this item
+ var notifications = new RecordingNotifications();
+
+ await Service(db, tracker, available: 0, notifications).CheckAsync();
+ await Service(db, new LowStockTracker(), available: 0, notifications).CheckAsync(); // nothing issued
+
+ Assert.Empty(notifications.Raised);
+ }
+
+ [Fact]
+ public async Task Tracker_SumsIssuesWithinARequest_AndEmptiesWhenDrained()
+ {
+ var tracker = new LowStockTracker();
+ tracker.Track(ItemId, WarehouseId, 3);
+ tracker.Track(ItemId, WarehouseId, 4);
+ tracker.Track(ItemId, WarehouseId, 0);
+
+ Assert.Equal(7m, tracker.Drain()[(ItemId, WarehouseId)]);
+ Assert.Empty(tracker.Drain());
+ }
+
+ private static LowStockAlertService Service(ErpDbContext db, LowStockTracker tracker, decimal available, INotificationService notifications)
+ => new(tracker, new Repository(db), new FixedStock(available), notifications);
+
+ private static ErpDbContext Seed()
+ {
+ var db = BankDepositTestSupport.CreateContext(); // users, branches, warehouses WH-1 / WH-2
+ db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
+ db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola 500ml", BaseUomId = 1, CategoryId = 1 });
+ db.ItemReorders.Add(new ItemReorder { ReorderId = 1, ItemId = ItemId, WarehouseId = WarehouseId, ReorderPoint = 10, ReorderQty = 50 });
+ db.SaveChanges();
+ return db;
+ }
+
+ private sealed class FixedStock(decimal available) : IStockService
+ {
+ public Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
+ => Task.FromResult(new StockOnHandDto(itemId, warehouseId, available, available, 0, 0, 0, DateTime.UtcNow));
+ public Task> GetOnHandListAsync(int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ public Task> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ public Task> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
+ string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ }
+
+ private sealed class RecordingNotifications : INotificationService
+ {
+ public List<(string ModuleKey, string Title, string Message)> Raised { get; } = new();
+
+ public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null,
+ int? relatedEntityId = null, CancellationToken ct = default)
+ {
+ Raised.Add((moduleKey, title, message));
+ return Task.CompletedTask;
+ }
+
+ public Task NotifyUsersAsync(IEnumerable userIds, string moduleKey, string title, string message,
+ string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => throw new NotSupportedException();
+
+ public Task GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
+ public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => throw new NotSupportedException();
+ }
+}
diff --git a/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs
new file mode 100644
index 0000000..e56526c
--- /dev/null
+++ b/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs
@@ -0,0 +1,172 @@
+using ERPCore.Common.Http;
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Config;
+using ERPCore.Infra.Auth;
+using ERPCore.Infra.Gl;
+using ERPCore.Infra.Persistence;
+using ERPCore.Infra.UoW;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.Services.Gl;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Configuration;
+
+namespace ERPCore.Tests.TestSupport;
+
+/// Records every journal it is asked to post; can be told to reject them instead.
+internal sealed class FakeGeneralLedger : IGeneralLedgerService
+{
+ public const long CashAccountId = 1, BankAccountId = 2, OtherBankAccountId = 3;
+
+ public List Posted { get; } = new();
+ public bool Reject { get; set; }
+
+ public IReadOnlyList Accounts { get; init; } =
+ [
+ new("Cash", CashAccountId, "Main Till", null, "Petty", null, 101, "1000-01", "LKR"),
+ new("Bank", BankAccountId, "BOC Current", "Bank of Ceylon", null, "0001234567", 102, "1010-01", "LKR"),
+ new("Bank", OtherBankAccountId, "HNB Savings", "Hatton National Bank", null, "9876543210", 103, "1010-02", "LKR")
+ ];
+
+ public Task ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
+ => throw new NotSupportedException();
+
+ public Task PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
+ {
+ if (Reject) throw new DomainException(ErrorCodes.Validation, "GL rejected the entry.", 502);
+ Posted.Add(request);
+ return Task.FromResult(new GlJournalEntryResult($"JE-{Posted.Count}", true));
+ }
+
+ public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
+
+ public Task> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts);
+
+ /// Not implemented on purpose — nothing here picks a GL account by code; see
+ /// for the fake that does.
+ public Task> ListAccountsAsync(CancellationToken ct) => throw new NotSupportedException();
+}
+
+/// The in-memory provider has no transactions: run the work, then save — what the real one commits.
+internal sealed class PassThroughUnitOfWork(ErpDbContext db) : IUnitOfWork
+{
+ public Task SaveChangesAsync(CancellationToken ct = default) => db.SaveChangesAsync(ct);
+
+ public async Task ExecuteInTransactionAsync(Func action, CancellationToken ct = default)
+ {
+ await action(ct);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task ExecuteInTransactionAsync(Func> action, CancellationToken ct = default)
+ {
+ var result = await action(ct);
+ await db.SaveChangesAsync(ct);
+ return result;
+ }
+}
+
+internal sealed class FakeNumberSequence : INumberSequenceService
+{
+ private int _next;
+ public Task NextAsync(string docType, int branchId, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
+ public Task NextAsync(string docType, CancellationToken ct = default) => Task.FromResult($"{docType}-{++_next:00000}");
+}
+
+/// Branching on: the requested branch is used as-is.
+internal sealed class FakeBranchConfig : IBranchConfigService
+{
+ public Task> GetAsync(CancellationToken ct = default) => throw new NotSupportedException();
+ public Task> UpdateAsync(UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
+ => throw new NotSupportedException();
+ public Task IsBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
+ public Task ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
+ => requestedBranchId > 0 ? Task.FromResult(requestedBranchId) : throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
+ public Task IsWarehouseBranchingEnabledAsync(CancellationToken ct = default) => Task.FromResult(true);
+ public Task ResolveWarehouseBranchIdAsync(int requestedBranchId, CancellationToken ct = default) => Task.FromResult(requestedBranchId);
+ public Task ResolveDefaultBranchIdAsync(CancellationToken ct = default) => Task.FromResult(1);
+}
+
+/// Seeds and services for the cash-in-hand / bank-deposit tests. Two branches, one warehouse each.
+internal static class BankDepositTestSupport
+{
+ private static readonly InMemoryDatabaseRoot Root = new();
+
+ public const int Branch1 = 1, Branch2 = 2;
+ public const int Warehouse1 = 1, Warehouse2 = 2;
+
+ public static BusinessDayClock Clock { get; } = new(Config());
+
+ public static IConfiguration Config(DateOnly? trackingStart = null) => new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["DayEndReport:TimeZone"] = "Asia/Colombo",
+ ["BankDeposit:DefaultCashAccountCode"] = "1000-01",
+ ["BankDeposit:CashTrackingStartDate"] = trackingStart?.ToString("yyyy-MM-dd")
+ })
+ .Build();
+
+ /// Midday on a local business day, as the UTC instant documents are stamped with.
+ public static DateTime At(DateOnly day) => Clock.ToUtc(day).AddHours(6);
+
+ public static ErpDbContext CreateContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
+ .Options;
+ var currentUser = new TestCurrentUser();
+ var db = new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
+
+ db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
+ db.Branches.AddRange(
+ new Branch { BranchId = Branch1, Code = "BR-001", Name = "Colombo" },
+ new Branch { BranchId = Branch2, Code = "BR-002", Name = "Kandy" });
+ db.Warehouses.AddRange(
+ new Warehouse { WarehouseId = Warehouse1, Code = "WH-1", Name = "Colombo Store", BranchId = Branch1 },
+ new Warehouse { WarehouseId = Warehouse2, Code = "WH-2", Name = "Kandy Store", BranchId = Branch2 });
+ db.SaveChanges();
+ return db;
+ }
+
+ private static int _id;
+
+ public static SalesSlip CashSlip(DateOnly day, decimal cash, decimal card = 0m, int warehouseId = Warehouse1)
+ {
+ var id = Interlocked.Increment(ref _id);
+ return new SalesSlip
+ {
+ SalesSlipId = id, SlipNo = $"SL-{id}", SlipDate = At(day), Status = SalesSlipStatus.Posted, WarehouseId = warehouseId,
+ GrandTotal = cash + card, PaidAmount = cash + card, CashAmount = cash, CardAmount = card
+ };
+ }
+
+ public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
+ => new(new Repository(db), new Repository(db), new Repository(db),
+ new Repository(db), new Repository(db),
+ new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
+
+ public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
+ {
+ var currentUser = new TestCurrentUser();
+ return new BankDepositService(
+ new Repository(db), new Repository(db), CashService(db, trackingStart), gl,
+ currentUser, new CurrentBranchContext(currentUser), new FakeNumberSequence(), new FakeBranchConfig(),
+ new PassThroughUnitOfWork(db), db, Clock);
+ }
+
+ public static BankDeposit Deposit(DateOnly businessDate, decimal amount, int branchId = Branch1,
+ BankDepositStatus status = BankDepositStatus.Posted)
+ {
+ var id = Interlocked.Increment(ref _id);
+ return new BankDeposit
+ {
+ BankDepositId = id, DocNo = $"BD-{id}", BranchId = branchId, BusinessDate = businessDate, DepositDate = businessDate,
+ Amount = amount, Status = status, FromCashAccountCode = "1000-01", FromCashAccountName = "Main Till",
+ ToBankAccountCode = "1010-01", ToBankAccountName = "BOC Current", CreatedBy = User.SystemUserId
+ };
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ApprovalsController.cs b/Backend/ERPCore/Controllers/ApprovalsController.cs
new file mode 100644
index 0000000..f3d691a
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ApprovalsController.cs
@@ -0,0 +1,43 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Dtos.Approvals;
+using ERPCore.Infra.Auth;
+using ERPCore.Services;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10). The inbox is open to every
+/// signed-in user — it only lists what they may approve and their own requests. Reading the
+/// settings is open too (screens need to know whether prices are locked); changing them needs
+/// settings.approvals.
+///
+[Route("api/v1/approvals")]
+public sealed class ApprovalsController : ApiControllerBase
+{
+ private readonly ApprovalInboxService _inbox;
+ private readonly IApprovalService _approvals;
+
+ public ApprovalsController(ApprovalInboxService inbox, IApprovalService approvals)
+ {
+ _inbox = inbox;
+ _approvals = approvals;
+ }
+
+ [HttpGet("pending")]
+ [ProducesResponseType(typeof(PendingApprovalsDto), StatusCodes.Status200OK)]
+ public async Task> Pending(CancellationToken ct)
+ => Ok(await _inbox.PendingAsync(ct));
+
+ [HttpGet("config")]
+ [ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
+ public async Task> GetConfig(CancellationToken ct)
+ => Ok(await _approvals.GetConfigAsync(ct));
+
+ [HttpPut("config")]
+ [Authorize(Policy = NavPolicies.Prefix + "settings.approvals")]
+ [ProducesResponseType(typeof(ApprovalConfigDto), StatusCodes.Status200OK)]
+ public async Task> UpdateConfig([FromBody] UpdateApprovalConfigRequest request, CancellationToken ct)
+ => Ok(await _approvals.UpdateConfigAsync(request, ct));
+}
diff --git a/Backend/ERPCore/Controllers/BankDepositsController.cs b/Backend/ERPCore/Controllers/BankDepositsController.cs
new file mode 100644
index 0000000..8c25e40
--- /dev/null
+++ b/Backend/ERPCore/Controllers/BankDepositsController.cs
@@ -0,0 +1,78 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Accounts;
+using ERPCore.Dtos.Common;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Till cash deposited to the bank, and the cash in hand behind it (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3).
+/// Recording needs the Create action on accounts.bank-deposits; cancelling needs Delete.
+///
+[Route("api/v1/bank-deposits")]
+[Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits")]
+public sealed class BankDepositsController : ApiControllerBase
+{
+ private readonly IBankDepositService _deposits;
+ private readonly ICashInHandService _cash;
+
+ public BankDepositsController(IBankDepositService deposits, ICashInHandService cash)
+ {
+ _deposits = deposits;
+ _cash = cash;
+ }
+
+ /// Deposits filtered by business date range, branch, bank account and status, plus their total.
+ [HttpGet]
+ [ProducesResponseType(typeof(BankDepositListDto), StatusCodes.Status200OK)]
+ public async Task> List(
+ [FromQuery] PageQuery query, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] int? branchId,
+ [FromQuery] long? bankAccountId, [FromQuery] BankDepositStatus? status, CancellationToken ct)
+ => Ok(await _deposits.ListAsync(query, new BankDepositListFilter(from, to, branchId, bankAccountId, status), ct));
+
+ [HttpGet("{bankDepositId:int}")]
+ [ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int bankDepositId, CancellationToken ct)
+ {
+ var result = await _deposits.GetAsync(bankDepositId, ct);
+ return result is null ? NotFound() : Ok(result);
+ }
+
+ /// One business day's cash position (defaults to today) and how much of it can still be deposited.
+ [HttpGet("cash-in-hand")]
+ [ProducesResponseType(typeof(CashInHandDto), StatusCodes.Status200OK)]
+ public async Task> CashInHand([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _cash.GetPositionAsync(date, branchId, ct));
+
+ /// Day-wise cash position for from..to inclusive (at most 92 days).
+ [HttpGet("cash-in-hand/daily")]
+ [ProducesResponseType(typeof(CashInHandDailyDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> CashInHandDaily(
+ [FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _cash.GetDailyAsync(from, to, branchId, ct));
+
+ /// Record a deposit; posts GL (Debit bank / Credit cash). Rejected above the cash in hand.
+ [HttpPost]
+ [Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
+ [ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> Create([FromBody] CreateBankDepositRequest request, CancellationToken ct)
+ {
+ var result = await _deposits.CreateAsync(request, ct);
+ return Created($"/api/v1/bank-deposits/{result.BankDepositId}", result);
+ }
+
+ /// Cancel a Posted deposit; posts the reversing GL entry and puts the cash back in hand.
+ [HttpPost("{bankDepositId:int}/cancel")]
+ [Authorize(Policy = NavPolicies.Prefix + "accounts.bank-deposits" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
+ [ProducesResponseType(typeof(BankDepositDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Cancel(int bankDepositId, [FromBody] CancelBankDepositRequest request, CancellationToken ct)
+ => Ok(await _deposits.CancelAsync(bankDepositId, request, ct));
+}
diff --git a/Backend/ERPCore/Controllers/DayEndReportController.cs b/Backend/ERPCore/Controllers/DayEndReportController.cs
new file mode 100644
index 0000000..590a239
--- /dev/null
+++ b/Backend/ERPCore/Controllers/DayEndReportController.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Dtos.Reports;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Day-End Sales Report — a day's sales, payments by method, expenses and income, and the
+/// same day-wise over a range for the income chart/PDF (docs/DAY_END_SALES_REPORT_PLAN.md §4).
+[Route("api/v1/day-end-report")]
+[Authorize(Policy = NavPolicies.Prefix + "sales.day-end-report")]
+public sealed class DayEndReportController : ApiControllerBase
+{
+ private readonly IDayEndReportService _reports;
+
+ public DayEndReportController(IDayEndReportService reports) => _reports = reports;
+
+ /// One business day (defaults to today in the report's time zone).
+ [HttpGet]
+ [ProducesResponseType(typeof(DayEndReportDto), StatusCodes.Status200OK)]
+ public async Task> GetDay([FromQuery] DateOnly? date, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _reports.GetDayAsync(date, branchId, ct));
+
+ /// Day-wise figures for from..to inclusive (at most 92 days).
+ [HttpGet("daily")]
+ [ProducesResponseType(typeof(DayEndDailyReportDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> GetDaily([FromQuery] DateOnly from, [FromQuery] DateOnly to, [FromQuery] int? branchId, CancellationToken ct)
+ => Ok(await _reports.GetDailyAsync(from, to, branchId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/ExpenseSetupController.cs b/Backend/ERPCore/Controllers/ExpenseSetupController.cs
new file mode 100644
index 0000000..bd6fea8
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ExpenseSetupController.cs
@@ -0,0 +1,57 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Dtos.Expenses;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Expense categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup"). Reading is open to
+/// anyone with Accounts access (the expense form needs the lists); changes need the
+/// Expense Setup permission. Categories are the fixed seeded four — there is no create
+/// endpoint; only their active flag can change. Titles can be added under them.
+///
+[Route("api/v1")]
+[Authorize(Policy = NavPolicies.Prefix + "accounts")]
+public sealed class ExpenseSetupController : ApiControllerBase
+{
+ private const string SetupPolicy = NavPolicies.Prefix + "accounts.expense-setup";
+
+ private readonly IExpenseSetupService _setup;
+
+ public ExpenseSetupController(IExpenseSetupService setup) => _setup = setup;
+
+ /// Categories, each with its titles, in display order.
+ [HttpGet("expense-categories")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> List([FromQuery] bool includeInactive, CancellationToken ct)
+ => Ok(await _setup.ListAsync(includeInactive, ct));
+
+ [HttpPut("expense-categories/{expenseCategoryId:int}")]
+ [Authorize(Policy = SetupPolicy)]
+ [ProducesResponseType(typeof(ExpenseCategoryDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> UpdateCategory(int expenseCategoryId, [FromBody] UpdateExpenseCategoryRequest request, CancellationToken ct)
+ => Ok(await _setup.UpdateCategoryAsync(expenseCategoryId, request, ct));
+
+ [HttpPost("expense-categories/{expenseCategoryId:int}/titles")]
+ [Authorize(Policy = SetupPolicy)]
+ [ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> CreateTitle(int expenseCategoryId, [FromBody] CreateExpenseTitleRequest request, CancellationToken ct)
+ {
+ var dto = await _setup.CreateTitleAsync(expenseCategoryId, request, ct);
+ return Created($"/api/v1/expense-titles/{dto.ExpenseTitleId}", dto);
+ }
+
+ [HttpPut("expense-titles/{expenseTitleId:int}")]
+ [Authorize(Policy = SetupPolicy)]
+ [ProducesResponseType(typeof(ExpenseTitleDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> UpdateTitle(int expenseTitleId, [FromBody] UpdateExpenseTitleRequest request, CancellationToken ct)
+ => Ok(await _setup.UpdateTitleAsync(expenseTitleId, request, ct));
+}
diff --git a/Backend/ERPCore/Controllers/ExpensesController.cs b/Backend/ERPCore/Controllers/ExpensesController.cs
new file mode 100644
index 0000000..0488524
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ExpensesController.cs
@@ -0,0 +1,127 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Expenses;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Business expense bills — draft, post (GL accrual), pay, cancel (docs/EXPENSE_MODULE_PLAN.md §6).
+[Route("api/v1/expenses")]
+[Authorize(Policy = NavPolicies.Prefix + "accounts.expenses")]
+public sealed class ExpensesController : ApiControllerBase
+{
+ private readonly IExpenseService _expenses;
+ private readonly IExpensePaymentService _payments;
+
+ public ExpensesController(IExpenseService expenses, IExpensePaymentService payments)
+ {
+ _expenses = expenses;
+ _payments = payments;
+ }
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] ExpenseStatus? status, [FromQuery] ExpensePaymentStatus? paymentStatus,
+ [FromQuery] int? categoryId, [FromQuery] int? titleId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
+ [FromQuery] bool overdueOnly, CancellationToken ct)
+ => Ok(await _expenses.ListAsync(query, new ExpenseListFilter(status, paymentStatus, categoryId, titleId, from, to, overdueOnly), ct));
+
+ /// Totals by category → title for Posted expenses dated in [from, to] (default: this month), plus unpaid/overdue position.
+ [HttpGet("summary")]
+ [ProducesResponseType(typeof(ExpenseSummaryReportDto), StatusCodes.Status200OK)]
+ public async Task> Summary([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
+ => Ok(await _expenses.GetSummaryAsync(from, to, ct));
+
+ [HttpGet("{expenseId:int}")]
+ [ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int expenseId, CancellationToken ct)
+ {
+ var result = await _expenses.GetAsync(expenseId, ct);
+ if (result is null) return NotFound();
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreateExpenseRequest request, CancellationToken ct)
+ {
+ var result = await _expenses.CreateAsync(request, ct);
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/expenses/{result.Value.ExpenseId}", result.Value);
+ }
+
+ [HttpPut("{expenseId:int}")]
+ [ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update(int expenseId, [FromBody] UpdateExpenseRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _expenses.UpdateAsync(expenseId, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ /// Draft only.
+ [HttpDelete("{expenseId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int expenseId, CancellationToken ct)
+ {
+ await _expenses.DeleteAsync(expenseId, ct);
+ return NoContent();
+ }
+
+ /// Accrue the expense to GL (Debit expense account / Credit Accrued Expenses).
+ [HttpPost("{expenseId:int}/post")]
+ [ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Post(int expenseId, CancellationToken ct)
+ => Ok(await _expenses.PostAsync(expenseId, ct));
+
+ /// Post a Draft and pay it, in full or partially, in one transaction; any remainder stays as the balance.
+ [HttpPost("{expenseId:int}/post-and-pay")]
+ [ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> PostAndPay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
+ => Ok(await _payments.PostAndPayAsync(expenseId, request, ct));
+
+ /// Draft, or Posted with no payments (posts the reversing GL entry).
+ [HttpPost("{expenseId:int}/cancel")]
+ [ProducesResponseType(typeof(ExpenseDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Cancel(int expenseId, [FromBody] CancelExpenseRequest request, CancellationToken ct)
+ => Ok(await _expenses.CancelAsync(expenseId, request, ct));
+
+ /// Pay against the balance, in full or in installments; one GL entry per submission.
+ [HttpPost("{expenseId:int}/payments")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task>> Pay(int expenseId, [FromBody] CreateExpensePaymentRequest request, CancellationToken ct)
+ {
+ var rows = await _payments.PayAsync(expenseId, request, ct);
+ return Created($"/api/v1/expenses/{expenseId}/payments", rows);
+ }
+
+ /// Payment history, newest first.
+ [HttpGet("{expenseId:int}/payments")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> ListPayments(int expenseId, CancellationToken ct)
+ => Ok(await _payments.ListAsync(expenseId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs
index b420a19..8f3dbe2 100644
--- a/Backend/ERPCore/Controllers/ItemsController.cs
+++ b/Backend/ERPCore/Controllers/ItemsController.cs
@@ -57,6 +57,13 @@ public sealed class ItemsController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
+ /// The item's latest posted GRN receipts (default 5, newest first): unit cost, discount, net cost, vendor.
+ [HttpGet("{itemId:int}/purchase-prices")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> PurchasePrices(int itemId, [FromQuery] int take, CancellationToken ct)
+ => Ok(await _items.GetPurchasePricesAsync(itemId, take <= 0 ? 5 : take, ct));
+
/// Get a single item; returns an ETag for optimistic concurrency.
[HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/PriceAdjustmentsController.cs b/Backend/ERPCore/Controllers/PriceAdjustmentsController.cs
new file mode 100644
index 0000000..0f9fe51
--- /dev/null
+++ b/Backend/ERPCore/Controllers/PriceAdjustmentsController.cs
@@ -0,0 +1,89 @@
+using Microsoft.AspNetCore.Authorization;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Approvals;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Products;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6.4). Requesting needs Create on
+/// products.price-adjustments, deciding needs Approve; an item's applied price history is
+/// readable by anyone who can see Products.
+///
+[Route("api/v1/price-adjustments")]
+[Authorize(Policy = NavPolicies.Prefix + "products")]
+public sealed class PriceAdjustmentsController : ApiControllerBase
+{
+ private const string Screen = NavPolicies.Prefix + "products.price-adjustments";
+ private const string CreatePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.CreateAction;
+ private const string ApprovePolicy = Screen + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
+
+ private readonly IPriceAdjustmentService _prices;
+
+ public PriceAdjustmentsController(IPriceAdjustmentService prices) => _prices = prices;
+
+ [HttpGet]
+ [Authorize(Policy = Screen)]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] PriceAdjustmentStatus? status, [FromQuery] int? itemId, CancellationToken ct)
+ => Ok(await _prices.ListAsync(query, status, itemId, ct));
+
+ [HttpGet("{priceAdjustmentId:int}")]
+ [Authorize(Policy = Screen)]
+ [ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int priceAdjustmentId, CancellationToken ct)
+ {
+ var dto = await _prices.GetAsync(priceAdjustmentId, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+
+ /// Request new selling prices for one or more items.
+ [HttpPost]
+ [Authorize(Policy = CreatePolicy)]
+ [ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Create([FromBody] CreatePriceAdjustmentRequest request, CancellationToken ct)
+ {
+ var dto = await _prices.CreateAsync(request, ct);
+ return Created($"/api/v1/price-adjustments/{dto.PriceAdjustmentId}", dto);
+ }
+
+ /// Give the next approval; the final one applies the prices.
+ [HttpPost("{priceAdjustmentId:int}/approve")]
+ [Authorize(Policy = ApprovePolicy)]
+ [ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Approve(int priceAdjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
+ => Ok(await _prices.ApproveAsync(priceAdjustmentId, request?.Comment, ct));
+
+ [HttpPost("{priceAdjustmentId:int}/reject")]
+ [Authorize(Policy = ApprovePolicy)]
+ [ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Reject(int priceAdjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
+ => Ok(await _prices.RejectAsync(priceAdjustmentId, request.Reason, ct));
+
+ /// The requester withdraws a pending request.
+ [HttpPost("{priceAdjustmentId:int}/cancel")]
+ [Authorize(Policy = Screen)]
+ [ProducesResponseType(typeof(PriceAdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Cancel(int priceAdjustmentId, CancellationToken ct)
+ => Ok(await _prices.CancelAsync(priceAdjustmentId, ct));
+
+ /// Every applied price change for an item, newest first, with who approved it.
+ [HttpGet("/api/v1/items/{itemId:int}/price-history")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> ItemHistory(int itemId, CancellationToken ct)
+ => Ok(await _prices.ItemHistoryAsync(itemId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/SerialsController.cs b/Backend/ERPCore/Controllers/SerialsController.cs
new file mode 100644
index 0000000..ba90c36
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SerialsController.cs
@@ -0,0 +1,41 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Stock;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9).
+[Route("api/v1/serials")]
+[Authorize(Policy = NavPolicies.Prefix + "stock")]
+public sealed class SerialsController : ApiControllerBase
+{
+ private readonly IItemTrackingService _tracking;
+
+ public SerialsController(IItemTrackingService tracking) => _tracking = tracking;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status,
+ [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct));
+
+ /// Exact lookup by serial number or barcode, across items.
+ [HttpGet("lookup")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> Lookup([FromQuery] string serialNo, CancellationToken ct)
+ => Ok(await _tracking.FindSerialAsync(serialNo, ct));
+
+ [HttpGet("{serialId:int}")]
+ [ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> Get(int serialId, CancellationToken ct)
+ {
+ var dto = await _tracking.GetSerialAsync(serialId, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
index 1b10b37..5f8e6e0 100644
--- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
+++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
+using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
+using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
@@ -7,23 +9,30 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
-/// Stock-adjustment endpoints (docs/11 §5.5).
+///
+/// Stock-adjustment endpoints (docs/11 §5.5). Creating submits a request; it moves stock once
+/// approved (docs/DUAL_APPROVAL_PLAN.md §5.4). Approving or rejecting needs the Approve action on
+/// stock.adjustments; the two-person rules are enforced by the service.
+///
[Route("api/v1/stock-adjustments")]
[Authorize(Policy = NavPolicies.Prefix + "stock")]
public sealed class StockAdjustmentsController : ApiControllerBase
{
+ private const string ApprovePolicy = NavPolicies.Prefix + "stock.adjustments" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction;
+
private readonly IAdjustmentService _adjustments;
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
- /// List posted adjustments, newest first.
+ /// List adjustments (posted and requests), newest first.
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
- [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId, CancellationToken ct)
- => Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, ct));
+ [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
+ [FromQuery] AdjustmentStatus? status, CancellationToken ct)
+ => Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct));
- /// Get one adjustment with its lines and the ledger entries it posted.
+ /// Get one adjustment with its lines, approval trail and the ledger entries it posted.
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -33,7 +42,7 @@ public sealed class StockAdjustmentsController : ApiControllerBase
return dto is null ? NotFound() : Ok(dto);
}
- /// Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).
+ /// Submit an adjustment (mandatory reason code). Posts at once only when approvals are off.
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -44,4 +53,29 @@ public sealed class StockAdjustmentsController : ApiControllerBase
var dto = await _adjustments.CreateAsync(request, ct);
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
}
+
+ /// Give the next approval. The final one posts the stock movement and GL.
+ [HttpPost("{adjustmentId:int}/approve")]
+ [Authorize(Policy = ApprovePolicy)]
+ [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Approve(int adjustmentId, [FromBody] ApproveRequest? request, CancellationToken ct)
+ => Ok(await _adjustments.ApproveAsync(adjustmentId, request?.Comment, ct));
+
+ [HttpPost("{adjustmentId:int}/reject")]
+ [Authorize(Policy = ApprovePolicy)]
+ [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Reject(int adjustmentId, [FromBody] RejectRequest request, CancellationToken ct)
+ => Ok(await _adjustments.RejectAsync(adjustmentId, request.Reason, ct));
+
+ /// The requester withdraws a pending request.
+ [HttpPost("{adjustmentId:int}/cancel")]
+ [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Cancel(int adjustmentId, CancellationToken ct)
+ => Ok(await _adjustments.CancelAsync(adjustmentId, ct));
}
diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs
index 7e4436a..01e5363 100644
--- a/Backend/ERPCore/Controllers/StockController.cs
+++ b/Backend/ERPCore/Controllers/StockController.cs
@@ -15,13 +15,26 @@ public sealed class StockController : ApiControllerBase
{
private readonly IStockService _stock;
private readonly IReorderService _reorder;
+ private readonly IItemTrackingService _tracking;
- public StockController(IStockService stock, IReorderService reorder)
+ public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
{
_stock = stock;
_reorder = reorder;
+ _tracking = tracking;
}
+ ///
+ /// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry
+ /// first. expiringWithinDays keeps batches expiring within that window, expired included.
+ ///
+ [HttpGet("batches")]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> Batches(
+ [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays,
+ [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct));
+
[HttpGet("on-hand")]
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
diff --git a/Backend/ERPCore/Controllers/TrackingController.cs b/Backend/ERPCore/Controllers/TrackingController.cs
new file mode 100644
index 0000000..5c2a14f
--- /dev/null
+++ b/Backend/ERPCore/Controllers/TrackingController.cs
@@ -0,0 +1,64 @@
+using ERPCore.Dtos.Stock;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Serial/batch pickers used across GRN, sales, returns and stock screens
+/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need
+/// only ERP access — not one module's nav permission — as every one of those screens calls them.
+///
+[Route("api/v1/tracking")]
+public sealed class TrackingController : ApiControllerBase
+{
+ private readonly IItemTrackingService _tracking;
+ private readonly ISerialTrackingService _serialTracking;
+
+ public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking)
+ {
+ _tracking = tracking;
+ _serialTracking = serialTracking;
+ }
+
+ ///
+ /// Which tracking features are switched on (Product Configuration) — readable by every
+ /// screen, unlike /product-config which needs the Products permission.
+ ///
+ [HttpGet("settings")]
+ [ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)]
+ public async Task> Settings(CancellationToken ct)
+ => Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct)));
+
+ ///
+ /// Auto-generate: the next count free serial numbers for an item under prefix
+ /// (continues the highest number already used). Nothing is reserved — the GRN still checks
+ /// uniqueness when it is saved and confirmed.
+ ///
+ [HttpGet("serials/next-numbers")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> NextSerialNumbers(
+ [FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default)
+ => Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct));
+
+ /// Issuable serials of an item at a warehouse (search by number/barcode with q).
+ [HttpGet("serials/available")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> AvailableSerials(
+ [FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default)
+ => Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct));
+
+ /// Issuable stock per batch with expiry, soonest expiry first.
+ [HttpGet("batches/available")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> AvailableBatches(
+ [FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
+ => Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct));
+
+ /// The FEFO pick posting would make for qty when no batch is chosen.
+ [HttpGet("batches/suggest")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task>> SuggestBatches(
+ [FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct)
+ => Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct));
+}
diff --git a/Backend/ERPCore/Controllers/VendorsController.cs b/Backend/ERPCore/Controllers/VendorsController.cs
index 29f3175..05199c7 100644
--- a/Backend/ERPCore/Controllers/VendorsController.cs
+++ b/Backend/ERPCore/Controllers/VendorsController.cs
@@ -65,4 +65,15 @@ public sealed class VendorsController : ApiControllerBase
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent();
}
+
+ [HttpDelete("{vendorId:int}")]
+ [Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int vendorId, CancellationToken ct)
+ {
+ await _vendors.DeleteAsync(vendorId, ct);
+ return NoContent();
+ }
}
diff --git a/Backend/ERPCore/Controllers/WarrantiesController.cs b/Backend/ERPCore/Controllers/WarrantiesController.cs
new file mode 100644
index 0000000..0a9cb25
--- /dev/null
+++ b/Backend/ERPCore/Controllers/WarrantiesController.cs
@@ -0,0 +1,30 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Stock;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by
+/// posting a sale, never directly — this controller only reads.
+///
+[Route("api/v1/warranties")]
+[Authorize(Policy = NavPolicies.Prefix + "sales")]
+public sealed class WarrantiesController : ApiControllerBase
+{
+ private readonly IItemTrackingService _tracking;
+
+ public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo,
+ [FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired,
+ [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, ct));
+}
diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs
index f00ad99..1fe3fdb 100644
--- a/Backend/ERPCore/Domain/DocumentTypes.cs
+++ b/Backend/ERPCore/Domain/DocumentTypes.cs
@@ -26,4 +26,13 @@ public static class DocumentTypes
/// Cashier day-end close-out (Sales) — DEND-2026-00001.
public const string SalesDayEnd = "DEND";
+
+ /// Business expense bill (Accounts) — EXP-BR-001-2026-00001.
+ public const string Expense = "EXP";
+
+ /// Till cash deposited to a bank account (Accounts) — BD-BR-001-2026-00001.
+ public const string BankDeposit = "BD";
+
+ /// Selling-price change request (Products) — PADJ-2026-00001.
+ public const string PriceAdjustment = "PADJ";
}
diff --git a/Backend/ERPCore/Domain/Entities/BankDeposit.cs b/Backend/ERPCore/Domain/Entities/BankDeposit.cs
new file mode 100644
index 0000000..57e01a2
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/BankDeposit.cs
@@ -0,0 +1,55 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Till cash taken to the bank (docs/BANK_DEPOSIT_MODULE_PLAN.md §4). Saving it posts a GL entry
+/// (Debit the bank account / Credit the cash account) and lowers the branch's cash in hand for
+/// . There is no draft: a wrong deposit is cancelled (the entry is
+/// reversed) and entered again. GL accounts have no local FK, so the account details are snapshots.
+///
+public class BankDeposit
+{
+ public int BankDepositId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
+ /// The day whose cash is deposited — cash in hand and the day-end report group by this.
+ public DateOnly BusinessDate { get; set; }
+ /// The day the bank received it; the GL entry date.
+ public DateOnly DepositDate { get; set; }
+
+ public decimal Amount { get; set; }
+
+ public long FromGlCashAccountId { get; set; }
+ public string FromCashAccountCode { get; set; } = string.Empty;
+ public string FromCashAccountName { get; set; } = string.Empty;
+
+ public long ToGlBankAccountId { get; set; }
+ public string ToBankAccountCode { get; set; } = string.Empty;
+ public string ToBankAccountName { get; set; } = string.Empty;
+ public string? ToBankName { get; set; }
+ public string? ToAccountNumber { get; set; }
+
+ public string? DepositSlipNo { get; set; }
+ public string? DepositedBy { get; set; }
+ public string? Remarks { get; set; }
+
+ public BankDepositStatus Status { get; set; } = BankDepositStatus.Posted;
+
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+
+ public string? GlReversalJournalNo { get; set; }
+ public string? CancelReason { get; set; }
+ public int? CancelledBy { get; set; }
+ public DateTime? CancelledAt { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+ public DateTime CreatedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/BundleSale.cs b/Backend/ERPCore/Domain/Entities/BundleSale.cs
index c0109ac..a195f29 100644
--- a/Backend/ERPCore/Domain/Entities/BundleSale.cs
+++ b/Backend/ERPCore/Domain/Entities/BundleSale.cs
@@ -25,6 +25,11 @@ public class BundleSale
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
+
+ /// How the (always fully paid) was tendered:
+ /// CashAmount + CardAmount == GrandTotal. Backfilled as all cash.
+ public decimal CashAmount { get; set; }
+ public decimal CardAmount { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/DocumentApproval.cs b/Backend/ERPCore/Domain/Entities/DocumentApproval.cs
new file mode 100644
index 0000000..eaffa73
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/DocumentApproval.cs
@@ -0,0 +1,48 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One approval decision on a document (docs/DUAL_APPROVAL_PLAN.md §4.1) — a row per decision, so
+/// the full trail survives. Polymorphic like the stock ledger: +
+/// , no FK. Doc types: ADJ (stock adjustment), PADJ (price adjustment).
+///
+public class DocumentApproval
+{
+ public int DocumentApprovalId { get; set; }
+ public string DocType { get; set; } = string.Empty;
+ public int DocId { get; set; }
+ /// 1 or 2.
+ public int Level { get; set; }
+ public ApprovalDecision Decision { get; set; }
+ public int DecidedBy { get; set; }
+ public User? Decider { get; set; }
+ public DateTime DecidedAt { get; set; }
+ public string? Comment { get; set; }
+}
+
+///
+/// Approval rules (docs/DUAL_APPROVAL_PLAN.md §7). Singleton row, edited under Settings → Approvals.
+/// Levels: 0 = no approval (applies on create), 1 = one approver, 2 = two different approvers.
+///
+public class ApprovalConfig
+{
+ public const int SingletonId = 1;
+
+ public int ApprovalConfigId { get; set; }
+
+ public int StockAdjustmentLevels { get; set; } = 2;
+ /// With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.
+ public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
+
+ public int PriceAdjustmentLevels { get; set; } = 2;
+ /// With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.
+ public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
+
+ /// Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.
+ public string? Level2RoleCodes { get; set; }
+
+ public DateTime? UpdatedAt { get; set; }
+ public int? UpdatedBy { get; set; }
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Expense.cs b/Backend/ERPCore/Domain/Entities/Expense.cs
new file mode 100644
index 0000000..14c4f3b
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Expense.cs
@@ -0,0 +1,92 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One business expense bill (electricity, rent, …) outside procurement. Draft is editable;
+/// Post accrues it to GL (Debit the title's expense account / Credit Accrued Expenses);
+/// payments then settle the accrual in one or more installments (see
+/// ExpensePaymentService). Model: docs/EXPENSE_MODULE_PLAN.md §3.3.
+///
+public class Expense
+{
+ public int ExpenseId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int BranchId { get; set; }
+ public Branch? Branch { get; set; }
+
+ public DateOnly ExpenseDate { get; set; }
+ public DateOnly? DueDate { get; set; }
+
+ public int ExpenseCategoryId { get; set; }
+ public ExpenseCategory? Category { get; set; }
+
+ public int ExpenseTitleId { get; set; }
+ public ExpenseTitle? Title { get; set; }
+
+ public string? Payee { get; set; }
+ public int? VendorId { get; set; }
+ public Vendor? Vendor { get; set; }
+
+ public string? BillReference { get; set; }
+ public string? Description { get; set; }
+ /// Optional month the bill covers, as yyyy-MM (e.g. September's electricity).
+ public string? PeriodMonth { get; set; }
+
+ public decimal Amount { get; set; }
+ public decimal PaidAmount { get; set; }
+ public decimal BalanceAmount { get; set; }
+
+ public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
+ public ExpensePaymentStatus PaymentStatus { get; set; } = ExpensePaymentStatus.Unpaid;
+
+ /// Snapshot of the title's GL expense account at post time.
+ public string? GlExpenseAccountCode { get; set; }
+ /// Journal number of the accrual entry posted on Post.
+ public string? GlJournalNo { get; set; }
+ public DateTime? GlPostedAt { get; set; }
+ /// Journal number of the reversing entry posted on Cancel (Posted expenses only).
+ public string? GlReversalJournalNo { get; set; }
+ public string? CancelReason { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Payments { get; set; } = new List();
+}
+
+///
+/// A payment against a Posted expense's balance (installments allowed). Every line of one
+/// payment submission shares the single GL journal entry that submission posted (Debit
+/// Accrued Expenses / Credit each selected cash-or-bank account). Mirrors
+/// .
+///
+public class ExpensePayment
+{
+ public int ExpensePaymentId { get; set; }
+
+ public int ExpenseId { get; set; }
+ public Expense? Expense { get; set; }
+
+ public decimal Amount { get; set; }
+ public DateTime PaymentDate { get; set; }
+ public ExpensePaymentMethod PaymentMethod { get; set; }
+
+ /// GL's numeric id for the bank/cash account the payment was made from.
+ public long GlBankAccountId { get; set; }
+ /// Snapshot of the account's display name at payment time (GL account lists have no local FK).
+ public string BankAccountName { get; set; } = string.Empty;
+
+ public string? ChequeNo { get; set; }
+ public string? Reference { get; set; }
+ public string? GlJournalNo { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ExpenseCategory.cs b/Backend/ERPCore/Domain/Entities/ExpenseCategory.cs
new file mode 100644
index 0000000..2062573
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ExpenseCategory.cs
@@ -0,0 +1,48 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Top-level expense grouping (Utility, Rental, Salaries, Other). The four seeded rows are
+/// — they can be deactivated but not renamed; admins can add more.
+/// Each category owns its s. Model: docs/EXPENSE_MODULE_PLAN.md §3.1.
+///
+public class ExpenseCategory
+{
+ public int ExpenseCategoryId { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public bool IsSystem { get; set; }
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+ public int SortOrder { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+ public uint RowVersion { get; set; }
+
+ public ICollection Titles { get; set; } = new List();
+}
+
+///
+/// A specific expense under a category (Utility → Electricity, Water, …). Carries the GL
+/// expense account debited when an expense with this title is posted — snapshotted onto the
+/// expense at post time, so changing it later does not touch already-posted expenses.
+/// Model: docs/EXPENSE_MODULE_PLAN.md §3.2.
+///
+public class ExpenseTitle
+{
+ public int ExpenseTitleId { get; set; }
+
+ public int ExpenseCategoryId { get; set; }
+ public ExpenseCategory? Category { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+ public string GlExpenseAccountCode { get; set; } = string.Empty;
+ public bool IsSystem { get; set; }
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+ public int SortOrder { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs
index 74ed6d1..f451205 100644
--- a/Backend/ERPCore/Domain/Entities/GrnLine.cs
+++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs
@@ -65,4 +65,12 @@ public class GrnLine
/// Empty for a non-warranty item.
///
public ICollection WarrantyNumbers { get; set; } = new List();
+
+ ///
+ /// One row per received unit when is
+ /// — count must equal . For a serial item
+ /// under warranty the serial is the warranty identity, so it carries no
+ /// . Empty otherwise.
+ ///
+ public ICollection Serials { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs b/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs
new file mode 100644
index 0000000..e1bfca6
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs
@@ -0,0 +1,22 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A serial number captured on a Draft GRN line for a serial-tracked item
+/// ( = ). The line must
+/// carry exactly of these. The draft holds the numbers only — the
+/// rows (and their qty-1 FIFO layers) are created when the GRN is
+/// confirmed, so a draft that is never confirmed reserves nothing (docs/ITEM_TRACKING_PLAN.md Phase 2).
+///
+public class GrnLineSerial
+{
+ public int GrnLineSerialId { get; set; }
+
+ public int GrnLineId { get; set; }
+ public GrnLine? GrnLine { get; set; }
+
+ public string SerialNo { get; set; } = string.Empty;
+
+ /// Set on confirm — the created for this number.
+ public int? SerialId { get; set; }
+ public Serial? Serial { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/PriceAdjustment.cs b/Backend/ERPCore/Domain/Entities/PriceAdjustment.cs
new file mode 100644
index 0000000..0089dd4
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PriceAdjustment.cs
@@ -0,0 +1,78 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// A request to change items' selling prices (docs/DUAL_APPROVAL_PLAN.md §6). Nothing changes on the
+/// items until it is fully approved; then each line's new prices are written to its item and an
+/// row is kept. is the requester.
+///
+public class PriceAdjustment
+{
+ public int PriceAdjustmentId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+ public string Reason { get; set; } = string.Empty;
+
+ public PriceAdjustmentStatus Status { get; set; } = PriceAdjustmentStatus.PendingApproval;
+ public int RequiredApprovals { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+ public DateTime CreatedAt { get; set; }
+
+ public int? ApprovedLevel1By { get; set; }
+ public DateTime? ApprovedLevel1At { get; set; }
+ public int? ApprovedLevel2By { get; set; }
+ public DateTime? ApprovedLevel2At { get; set; }
+ public int? RejectedBy { get; set; }
+ public DateTime? RejectedAt { get; set; }
+ public string? RejectReason { get; set; }
+ public DateTime? CancelledAt { get; set; }
+ /// When the new prices were written to the items.
+ public DateTime? AppliedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
+
+/// One item's change. The Old* values are the item's prices when the request was made —
+/// approval fails if the item has been repriced since (stale request).
+public class PriceAdjustmentLine
+{
+ public int PriceAdjustmentLineId { get; set; }
+ public int PriceAdjustmentId { get; set; }
+ public PriceAdjustment? PriceAdjustment { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public decimal? OldMinPrice { get; set; }
+ public decimal? OldMaxPrice { get; set; }
+ public decimal? OldFlatPrice { get; set; }
+ public decimal? NewMinPrice { get; set; }
+ public decimal? NewMaxPrice { get; set; }
+ public decimal? NewFlatPrice { get; set; }
+}
+
+/// An applied selling-price change: what it was, what it became, and who approved it.
+public class ItemPriceHistory
+{
+ public int ItemPriceHistoryId { get; set; }
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+ public int? PriceAdjustmentId { get; set; }
+ public PriceAdjustment? PriceAdjustment { get; set; }
+
+ public decimal? OldMinPrice { get; set; }
+ public decimal? OldMaxPrice { get; set; }
+ public decimal? OldFlatPrice { get; set; }
+ public decimal? NewMinPrice { get; set; }
+ public decimal? NewMaxPrice { get; set; }
+ public decimal? NewFlatPrice { get; set; }
+
+ public int RequestedBy { get; set; }
+ public int? ApprovedLevel1By { get; set; }
+ public int? ApprovedLevel2By { get; set; }
+ public DateTime AppliedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
index d8ff592..27aad21 100644
--- a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs
@@ -19,6 +19,13 @@ public class SalesInvoiceLine
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
+ /// Pins the line to one batch; null lets posting pick batches FEFO.
+ public int? BatchId { get; set; }
+ public Batch? Batch { get; set; }
+
+ /// Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).
+ public ICollection Serials { get; set; } = new List();
+
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs b/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
index 27705b3..20c2e97 100644
--- a/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesInvoicePayment.cs
@@ -1,3 +1,5 @@
+using ERPCore.Domain.Enums;
+
namespace ERPCore.Domain.Entities;
///
@@ -21,6 +23,9 @@ public class SalesInvoicePayment
/// Snapshot of the account's display name at payment time (GL account lists have no local FK).
public string BankAccountName { get; set; } = string.Empty;
+ /// How the customer paid. Payments recorded before this existed were backfilled as Cash.
+ public SalesPaymentMethod PaymentMethod { get; set; } = SalesPaymentMethod.Cash;
+
public string? Reference { get; set; }
/// Journal number of the real GL journal entry this payment posted.
diff --git a/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs b/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs
new file mode 100644
index 0000000..d545915
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs
@@ -0,0 +1,35 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// What a posted sales line actually issued: one row per consumed FIFO segment, carrying the
+/// batch (FEFO-picked when the line named none) and, for serial items, the serial. The source
+/// line is referenced polymorphically ( is a
+/// value) — same convention as —
+/// so invoices, slips and bundles share one table. Returns read it to restock into the same
+/// batch/serial (docs/ITEM_TRACKING_PLAN.md Phases 4 and 6).
+///
+public class SalesLineAllocation
+{
+ public int SalesLineAllocationId { get; set; }
+
+ public string SourceDocType { get; set; } = string.Empty;
+ public int SourceDocId { get; set; }
+ public int SourceLineId { get; set; }
+
+ public int ItemId { get; set; }
+ public int WarehouseId { get; set; }
+
+ public int? BatchId { get; set; }
+ public Batch? Batch { get; set; }
+
+ public int? SerialId { get; set; }
+ public Serial? Serial { get; set; }
+
+ public decimal Qty { get; set; }
+ public decimal UnitCost { get; set; }
+
+ /// Quantity of this allocation already taken back by sales returns.
+ public decimal QtyReturned { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs b/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs
new file mode 100644
index 0000000..691019d
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs
@@ -0,0 +1,29 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A serial chosen on a Draft sales-invoice line for a serial-tracked item — one row per unit
+/// (count = Qty + FreeQty). Choosing does not reserve: posting re-checks each serial is still
+/// in stock and consumes exactly its layer (docs/ITEM_TRACKING_PLAN.md Phase 4, D4).
+///
+public class SalesInvoiceLineSerial
+{
+ public int SalesInvoiceLineSerialId { get; set; }
+
+ public int SalesInvoiceLineId { get; set; }
+ public SalesInvoiceLine? SalesInvoiceLine { get; set; }
+
+ public int SerialId { get; set; }
+ public Serial? Serial { get; set; }
+}
+
+/// Sales-slip counterpart of .
+public class SalesSlipLineSerial
+{
+ public int SalesSlipLineSerialId { get; set; }
+
+ public int SalesSlipLineId { get; set; }
+ public SalesSlipLine? SalesSlipLine { get; set; }
+
+ public int SerialId { get; set; }
+ public Serial? Serial { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs
index 191adc0..d59214d 100644
--- a/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs
@@ -2,7 +2,9 @@ namespace ERPCore.Domain.Entities;
///
/// Sales-return line referencing the original sales invoice line for traceability.
-/// is in base UOM.
+/// is in base UOM. is the batch the goods are restocked
+/// into — the batch they were sold from (docs/ITEM_TRACKING_PLAN.md Phase 6);
+/// lists the returned units of a serial-tracked item.
///
public class SalesReturnLine
{
@@ -18,4 +20,21 @@ public class SalesReturnLine
public Item? Item { get; set; }
public decimal Qty { get; set; }
+
+ public int? BatchId { get; set; }
+ public Batch? Batch { get; set; }
+
+ public ICollection Serials { get; set; } = new List();
+}
+
+/// One serial taken back on a .
+public class SalesReturnLineSerial
+{
+ public int SalesReturnLineSerialId { get; set; }
+
+ public int ReturnLineId { get; set; }
+ public SalesReturnLine? ReturnLine { get; set; }
+
+ public int SerialId { get; set; }
+ public Serial? Serial { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/SalesSlip.cs b/Backend/ERPCore/Domain/Entities/SalesSlip.cs
index c3d713f..eabcda7 100644
--- a/Backend/ERPCore/Domain/Entities/SalesSlip.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesSlip.cs
@@ -27,6 +27,11 @@ public class SalesSlip
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
+ /// How was tendered: CashAmount + CardAmount == PaidAmount.
+ /// Slips from before the split existed were backfilled as all cash.
+ public decimal CashAmount { get; set; }
+ public decimal CardAmount { get; set; }
+
/// Set once this slip's business date is closed by — a closed slip is frozen (no further edits).
public int? DayEndId { get; set; }
public SalesDayEnd? DayEnd { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
index 5497afe..3570250 100644
--- a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
+++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs
@@ -22,9 +22,16 @@ public class SalesSlipLine
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
+ ///
+ /// Legacy single-serial pin, from before a line could carry several. Kept for old drafts and
+ /// still set when exactly one serial is chosen; is authoritative.
+ ///
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
+ /// Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).
+ public ICollection Serials { get; set; } = new List();
+
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
diff --git a/Backend/ERPCore/Domain/Entities/Serial.cs b/Backend/ERPCore/Domain/Entities/Serial.cs
index dbbf7a2..2276703 100644
--- a/Backend/ERPCore/Domain/Entities/Serial.cs
+++ b/Backend/ERPCore/Domain/Entities/Serial.cs
@@ -1,8 +1,13 @@
+using ERPCore.Domain.Enums;
+
namespace ERPCore.Domain.Entities;
///
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
-/// Model: docs/10 Part C.4.
+/// Created when the GRN that received it is confirmed; each in-stock serial owns exactly one
+/// FIFO layer of qty 1 (), so issuing a serial consumes that
+/// layer. / are its current location and are
+/// cleared once it leaves stock. Model: docs/10 Part C.4, docs/ITEM_TRACKING_PLAN.md Phase 1.
///
public class Serial
{
@@ -12,8 +17,33 @@ public class Serial
public Item? Item { get; set; }
public string SerialNo { get; set; } = string.Empty;
- public string Status { get; set; } = "InStock";
+ public SerialStatus Status { get; set; } = SerialStatus.InStock;
/// Optional scan code for barcode/RFID lookup (unique when set).
public string? Barcode { get; set; }
+
+ /// Current warehouse while in stock; null once sold/returned to vendor/scrapped.
+ public int? WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public int? BinId { get; set; }
+ public Bin? Bin { get; set; }
+
+ /// The GRN line that received this unit (provenance); null for count/adjustment finds.
+ public int? GrnLineId { get; set; }
+ public GrnLine? GrnLine { get; set; }
+
+ public DateTime? ReceivedAt { get; set; }
+
+ ///
+ /// Warranty coverage snapshotted from at receipt
+ /// (same rule as ); null for a
+ /// non-warranty item. The clock does not start here — it starts at sale
+ /// ().
+ ///
+ public int? WarrantyPeriodMonths { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ public uint RowVersion { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/SerialMovement.cs b/Backend/ERPCore/Domain/Entities/SerialMovement.cs
new file mode 100644
index 0000000..a872c00
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SerialMovement.cs
@@ -0,0 +1,46 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Append-only history of one serial: a row each time a document changes its status or
+/// location (receipt, sale, return, transfer, adjustment, count). and
+/// are the serial's state after the movement. The source
+/// document is referenced polymorphically, as on . This is where serial
+/// history is read from — the stock ledger keeps one row per document line, not per unit
+/// (docs/ITEM_TRACKING_PLAN.md Phases 2 and 9).
+///
+public class SerialMovement
+{
+ public int SerialMovementId { get; set; }
+
+ public int SerialId { get; set; }
+ public Serial? Serial { get; set; }
+
+ public string SourceDocType { get; set; } = string.Empty;
+ public int SourceDocId { get; set; }
+
+ public SerialStatus Status { get; set; }
+ public int? FromWarehouseId { get; set; }
+ public int? WarehouseId { get; set; }
+
+ public int UserId { get; set; }
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// Records 's current state — call after updating it. Linked by
+ /// navigation, so it works for a serial inserted in the same save.
+ ///
+ public static SerialMovement Of(Serial serial, int? fromWarehouseId, string sourceDocType, int sourceDocId, int userId, DateTime at) => new()
+ {
+ Serial = serial,
+ SerialId = serial.SerialId,
+ SourceDocType = sourceDocType,
+ SourceDocId = sourceDocId,
+ Status = serial.Status,
+ FromWarehouseId = fromWarehouseId,
+ WarehouseId = serial.WarehouseId,
+ UserId = userId,
+ CreatedAt = at
+ };
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustment.cs b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
index bcece95..8f72fd1 100644
--- a/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
+++ b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
@@ -37,5 +37,20 @@ public class StockAdjustment
public string? GlJournalNo { get; set; }
public DateTime? GlPostedAt { get; set; }
+ // --- Approval (docs/DUAL_APPROVAL_PLAN.md §5). CreatedBy is the requester. ---
+
+ /// Approvals this request needs (0 = posted on create, 1 or 2), fixed when it is made.
+ public int RequiredApprovals { get; set; }
+ public int? ApprovedLevel1By { get; set; }
+ public DateTime? ApprovedLevel1At { get; set; }
+ public int? ApprovedLevel2By { get; set; }
+ public DateTime? ApprovedLevel2At { get; set; }
+ public int? RejectedBy { get; set; }
+ public DateTime? RejectedAt { get; set; }
+ public string? RejectReason { get; set; }
+ public DateTime? CancelledAt { get; set; }
+ /// When stock actually moved — the ledger and GL use this, not .
+ public DateTime? PostedAt { get; set; }
+
public ICollection Lines { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs
index 8bbc1ad..ecab917 100644
--- a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs
+++ b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs
@@ -18,6 +18,15 @@ public class StockAdjustmentLine
public int? BinId { get; set; }
public int? BatchId { get; set; }
public int? SerialId { get; set; }
+ public Serial? Serial { get; set; }
public decimal QtyDelta { get; set; }
+
+ ///
+ /// The serials named on the request, kept until it posts (JSON arrays): outbound serial ids
+ /// to write off, inbound serial numbers found. Re-validated at posting, because a pending
+ /// request moves nothing and the serials may have moved in the meantime.
+ ///
+ public string? RequestedSerialIds { get; set; }
+ public string? RequestedSerialNos { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs
index 240d815..c4266a3 100644
--- a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs
+++ b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs
@@ -32,4 +32,27 @@ public class StockTransferLine
/// Quantity already received at the destination (partial-receive support).
public decimal QtyReceived { get; set; }
+
+ /// The units moved, for a serial-tracked item (one per unit of ).
+ public ICollection Serials { get; set; } = new List();
+}
+
+///
+/// One serial on a transfer line. / are captured from
+/// its own source layer at dispatch, so the destination layer is recreated exactly;
+/// is set when it arrives (partial receive names which serials).
+///
+public class StockTransferLineSerial
+{
+ public int StockTransferLineSerialId { get; set; }
+
+ public int TransferLineId { get; set; }
+ public StockTransferLine? TransferLine { get; set; }
+
+ public int SerialId { get; set; }
+ public Serial? Serial { get; set; }
+
+ public int? BatchId { get; set; }
+ public decimal? UnitCost { get; set; }
+ public DateTime? ReceivedAt { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs b/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs
new file mode 100644
index 0000000..e5c2c3c
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs
@@ -0,0 +1,42 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// A customer's warranty, started when the sale that issued the goods is posted
+/// (docs/ITEM_TRACKING_PLAN.md Phase 5, D3/D7) — stock waiting in a warehouse never consumes
+/// warranty. For a serial item there is one row per serial ( set,
+/// = 1); for a warranty item that is not serial-tracked there is one row per
+/// sales line covering its . is the coverage
+/// snapshotted at receipt (), else the item's period.
+///
+public class WarrantyRegistration
+{
+ public int WarrantyRegistrationId { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public int? SerialId { get; set; }
+ public Serial? Serial { get; set; }
+
+ public int CustomerId { get; set; }
+ public Customer? Customer { get; set; }
+
+ public string SourceDocType { get; set; } = string.Empty;
+ public int SourceDocId { get; set; }
+ public int SourceLineId { get; set; }
+
+ public decimal Qty { get; set; }
+
+ public DateOnly StartDate { get; set; }
+ public int PeriodMonths { get; set; }
+ public DateOnly EndDate { get; set; }
+
+ public WarrantyStatus Status { get; set; } = WarrantyStatus.Active;
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs
index 7d56708..1301e79 100644
--- a/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs
+++ b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs
@@ -1,13 +1,33 @@
namespace ERPCore.Domain.Enums;
///
-/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
-/// is reserved for the future threshold-approval
-/// workflow (FR-STK-07). Stored as a string.
+/// Stock-adjustment lifecycle (docs/DUAL_APPROVAL_PLAN.md §5.1). A new adjustment is a request
+/// (, then when two approvals are
+/// required) and only moves stock when it becomes . With approvals switched off
+/// (ApprovalConfig.StockAdjustmentLevels = 0) it posts on create, as before. Stored as a string.
///
public enum AdjustmentStatus
{
Draft,
PendingApproval,
- Posted
+ Posted,
+ PendingSecondApproval,
+ Rejected,
+ Cancelled
+}
+
+/// Price-adjustment lifecycle — same request flow as ; means applied.
+public enum PriceAdjustmentStatus
+{
+ PendingApproval,
+ PendingSecondApproval,
+ Approved,
+ Rejected,
+ Cancelled
+}
+
+public enum ApprovalDecision
+{
+ Approved,
+ Rejected
}
diff --git a/Backend/ERPCore/Domain/Enums/BankDepositStatus.cs b/Backend/ERPCore/Domain/Enums/BankDepositStatus.cs
new file mode 100644
index 0000000..1e530f0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/BankDepositStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+/// A deposit is posted as soon as it is saved; a wrong one is cancelled (GL reversed) and re-entered.
+public enum BankDepositStatus
+{
+ Posted,
+ Cancelled
+}
diff --git a/Backend/ERPCore/Domain/Enums/ExpenseStatus.cs b/Backend/ERPCore/Domain/Enums/ExpenseStatus.cs
new file mode 100644
index 0000000..6dc3236
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/ExpenseStatus.cs
@@ -0,0 +1,24 @@
+namespace ERPCore.Domain.Enums;
+
+public enum ExpenseStatus
+{
+ Draft,
+ Posted,
+ Cancelled
+}
+
+/// Settlement state of a Posted expense, derived from its paid/balance amounts.
+public enum ExpensePaymentStatus
+{
+ Unpaid,
+ PartiallyPaid,
+ Paid
+}
+
+public enum ExpensePaymentMethod
+{
+ Cash,
+ BankTransfer,
+ Cheque,
+ Card
+}
diff --git a/Backend/ERPCore/Domain/Enums/SalesPaymentMethod.cs b/Backend/ERPCore/Domain/Enums/SalesPaymentMethod.cs
new file mode 100644
index 0000000..43f461b
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SalesPaymentMethod.cs
@@ -0,0 +1,12 @@
+namespace ERPCore.Domain.Enums;
+
+/// How a customer paid for a sale — recorded per invoice payment (and split into
+/// cash/card amounts on slips and bundle sales) so the Day-End Sales Report can break
+/// collections down by method. Mirrors .
+public enum SalesPaymentMethod
+{
+ Cash,
+ Card,
+ BankTransfer,
+ Cheque
+}
diff --git a/Backend/ERPCore/Domain/Enums/SerialStatus.cs b/Backend/ERPCore/Domain/Enums/SerialStatus.cs
new file mode 100644
index 0000000..32c43db
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SerialStatus.cs
@@ -0,0 +1,25 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Lifecycle state of one serial-tracked unit (FR-WH-04). Stored as a string — the values
+/// InStock/Sold written before this enum existed stay valid as-is.
+/// Only is issuable.
+///
+public enum SerialStatus
+{
+ InStock,
+ Reserved,
+ /// Dispatched on a stock transfer, not yet received — in no warehouse.
+ InTransit,
+ Sold,
+ ReturnedByCustomer,
+ ReturnedToVendor,
+ Damaged,
+ Scrapped,
+ ///
+ /// Left stock without being named, while serial numbers were switched off in Product
+ /// Configuration — the unit was issued oldest-first like untracked stock, so which
+ /// document took it is not recorded against the serial.
+ ///
+ IssuedUntracked
+}
diff --git a/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs b/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs
new file mode 100644
index 0000000..ab53b54
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs
@@ -0,0 +1,10 @@
+namespace ERPCore.Domain.Enums;
+
+/// State of a . Stored as a string.
+public enum WarrantyStatus
+{
+ Active,
+ /// The unit came back on a sales return — the warranty no longer applies.
+ Voided,
+ Claimed
+}
diff --git a/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs b/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs
index 49c4efb..831df17 100644
--- a/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs
+++ b/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs
@@ -27,6 +27,18 @@ public sealed class StockAdjustmentCreatedNotificationModule;
[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
public sealed class StockTransferCreatedNotificationModule;
+[NotificationModule("stock.low", "Low Stock / Reorder Alert", "Raised when a sale, transfer or adjustment takes an item's stock at a warehouse to or below its reorder point.", 95)]
+public sealed class StockLowNotificationModule;
+
+[NotificationModule("approval.pending", "Approval Needed", "Sent to the people who can approve a stock or price adjustment that is waiting for them.", 96)]
+public sealed class ApprovalPendingNotificationModule;
+
+[NotificationModule("approval.approved", "Request Approved", "Sent to the requester when their stock or price adjustment is fully approved and applied.", 97)]
+public sealed class ApprovalApprovedNotificationModule;
+
+[NotificationModule("approval.rejected", "Request Rejected", "Sent to the requester when their stock or price adjustment is rejected.", 98)]
+public sealed class ApprovalRejectedNotificationModule;
+
[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
public sealed class ItemCreatedNotificationModule;
diff --git a/Backend/ERPCore/Dtos/Accounts/BankDepositDtos.cs b/Backend/ERPCore/Dtos/Accounts/BankDepositDtos.cs
new file mode 100644
index 0000000..25d86b0
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Accounts/BankDepositDtos.cs
@@ -0,0 +1,66 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+
+namespace ERPCore.Dtos.Accounts;
+
+// --- Cash in hand (docs/BANK_DEPOSIT_MODULE_PLAN.md §2, §7.1) ---
+
+///
+/// One business day's till cash. CashIn is slip/bundle cash plus cash invoice payments received
+/// that day; CashOut is expense payments made in cash; Deposited is Posted bank deposits for that
+/// business day. Closing = Opening + NetCash − Deposited, and becomes the next day's Opening.
+/// Days before the tracking start date carry nothing in: their Opening is 0.
+///
+public sealed record CashPositionDto(
+ DateOnly Date, decimal Opening, decimal CashIn, decimal CashOut, decimal NetCash, decimal Deposited, decimal Closing);
+
+///
+/// A day's cash position for the deposit form. is the lowest
+/// closing balance from that day up to today: a back-dated deposit lowers every later day too.
+/// is the GL cash account the form preselects.
+///
+public sealed record CashInHandDto(
+ CashPositionDto Position, decimal AvailableToDeposit, int? BranchId, DateOnly? TrackingStartDate, string TimeZone,
+ string? DefaultCashAccountCode);
+
+public sealed record CashInHandDailyDto(
+ DateOnly From, DateOnly To, IReadOnlyList Days, int? BranchId, DateOnly? TrackingStartDate, string TimeZone);
+
+// --- Deposits ---
+
+public sealed record BankDepositDto(
+ int BankDepositId, string DocNo, int BranchId, string? BranchName,
+ DateOnly BusinessDate, DateOnly DepositDate, decimal Amount,
+ long FromGlCashAccountId, string FromCashAccountCode, string FromCashAccountName,
+ long ToGlBankAccountId, string ToBankAccountCode, string ToBankAccountName, string? ToBankName, string? ToAccountNumber,
+ string? DepositSlipNo, string? DepositedBy, string? Remarks,
+ BankDepositStatus Status, string? GlJournalNo, DateTime? GlPostedAt,
+ string? GlReversalJournalNo, string? CancelReason, DateTime? CancelledAt,
+ int CreatedBy, DateTime CreatedAt);
+
+/// A page of deposits plus the total amount of every row matching the filter (not just this page).
+public sealed record BankDepositListDto(IReadOnlyList Items, PaginationDto Pagination, decimal TotalAmount);
+
+public sealed record BankDepositListFilter(
+ DateOnly? From, DateOnly? To, int? BranchId, long? BankAccountId, BankDepositStatus? Status);
+
+public sealed class CreateBankDepositRequest
+{
+ /// Required when branching is enabled; otherwise the default branch is used. A branch user always deposits for their own branch.
+ public int BranchId { get; set; }
+ [Required] public DateOnly BusinessDate { get; set; }
+ /// When the bank received it; also the GL entry date. Omitted ⇒ the business date.
+ public DateOnly? DepositDate { get; set; }
+ [Range(0.01, 999_999_999_999)] public decimal Amount { get; set; }
+ [Required] public long FromGlCashAccountId { get; set; }
+ [Required] public long ToGlBankAccountId { get; set; }
+ [StringLength(50)] public string? DepositSlipNo { get; set; }
+ [StringLength(150)] public string? DepositedBy { get; set; }
+ [StringLength(500)] public string? Remarks { get; set; }
+}
+
+public sealed class CancelBankDepositRequest
+{
+ [Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
+}
diff --git a/Backend/ERPCore/Dtos/Approvals/ApprovalDtos.cs b/Backend/ERPCore/Dtos/Approvals/ApprovalDtos.cs
new file mode 100644
index 0000000..94eaf00
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Approvals/ApprovalDtos.cs
@@ -0,0 +1,43 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Approvals;
+
+/// One decision in a document's approval trail.
+public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int DecidedBy, string? DecidedByName, DateTime DecidedAt, string? Comment);
+
+/// Settings → Approvals (docs/DUAL_APPROVAL_PLAN.md §7).
+public sealed record ApprovalConfigDto(
+ int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
+ int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
+ IReadOnlyList Level2RoleCodes, DateTime? UpdatedAt);
+
+public sealed class UpdateApprovalConfigRequest
+{
+ [Range(0, 2)] public int StockAdjustmentLevels { get; set; }
+ [Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
+ [Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
+ [Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
+ public List Level2RoleCodes { get; set; } = new();
+}
+
+public sealed class ApproveRequest
+{
+ [StringLength(250)] public string? Comment { get; set; }
+}
+
+public sealed class RejectRequest
+{
+ [Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
+}
+
+///
+/// A request waiting in the Approvals inbox. is the approval it waits for
+/// (1 or 2) of ; says whether the
+/// current user may decide it (not the requester, not the level-1 approver, has the right role).
+///
+public sealed record PendingApprovalDto(
+ string DocType, int DocId, string DocNo, string Summary, int Level, int RequiredApprovals,
+ int RequestedBy, string? RequestedByName, DateTime RequestedAt, bool CanApprove, string? WhyNot);
+
+public sealed record PendingApprovalsDto(IReadOnlyList Items, int ActionableCount);
diff --git a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
index 8176c60..473093b 100644
--- a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
+++ b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
@@ -4,6 +4,9 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Customers;
/// Customer resource used by sales documents.
+/// Unpaid balance across the customer's Posted sales invoices. Filled on the list only.
+/// Posted sales invoices with a balance still due. Filled on the list only.
+/// When the customer last paid against an invoice; null if never. Filled on the list only.
public sealed record CustomerDto(
int CustomerId,
string CustomerCode,
@@ -23,7 +26,10 @@ public sealed record CustomerDto(
EntityStatus Status,
int BranchId,
DateTime CreatedAt,
- DateTime? UpdatedAt);
+ DateTime? UpdatedAt,
+ decimal OutstandingAmount = 0m,
+ int OutstandingInvoiceCount = 0,
+ DateTime? LastPaymentDate = null);
public sealed class CreateCustomerRequest
{
diff --git a/Backend/ERPCore/Dtos/Expenses/ExpenseDtos.cs b/Backend/ERPCore/Dtos/Expenses/ExpenseDtos.cs
new file mode 100644
index 0000000..8d756f4
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Expenses/ExpenseDtos.cs
@@ -0,0 +1,120 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Expenses;
+
+// --- Setup: categories and titles (docs/EXPENSE_MODULE_PLAN.md §6 "Setup") ---
+
+public sealed record ExpenseTitleDto(
+ int ExpenseTitleId, int ExpenseCategoryId, string Name, string GlExpenseAccountCode,
+ bool IsSystem, EntityStatus Status, int SortOrder);
+
+public sealed record ExpenseCategoryDto(
+ int ExpenseCategoryId, string Code, string Name, bool IsSystem, EntityStatus Status, int SortOrder,
+ IReadOnlyList Titles);
+
+public sealed class UpdateExpenseCategoryRequest
+{
+ /// Ignored-if-unchanged for system rows; a different name on a system row is rejected.
+ [Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+}
+
+public sealed class CreateExpenseTitleRequest
+{
+ [Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
+ [Required, StringLength(20, MinimumLength = 1)] public string GlExpenseAccountCode { get; set; } = string.Empty;
+}
+
+public sealed class UpdateExpenseTitleRequest
+{
+ /// Ignored-if-unchanged for system rows; a different name on a system row is rejected.
+ [Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
+ [Required, StringLength(20, MinimumLength = 1)] public string GlExpenseAccountCode { get; set; } = string.Empty;
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+}
+
+// --- Expenses ---
+
+public sealed record ExpenseSummaryDto(
+ int ExpenseId, string DocNo, int BranchId, DateOnly ExpenseDate, DateOnly? DueDate,
+ int ExpenseCategoryId, string CategoryName, int ExpenseTitleId, string TitleName,
+ string? Payee, string? BillReference, string? PeriodMonth,
+ decimal Amount, decimal PaidAmount, decimal BalanceAmount,
+ ExpenseStatus Status, ExpensePaymentStatus PaymentStatus, string? GlJournalNo, DateTime CreatedAt);
+
+public sealed record ExpensePaymentDto(
+ int ExpensePaymentId, int ExpenseId, decimal Amount, DateTime PaymentDate, ExpensePaymentMethod PaymentMethod,
+ long GlBankAccountId, string BankAccountName, string? ChequeNo, string? Reference, string? GlJournalNo, DateTime CreatedAt);
+
+public sealed record ExpenseDto(
+ int ExpenseId, string DocNo, int BranchId, DateOnly ExpenseDate, DateOnly? DueDate,
+ int ExpenseCategoryId, string CategoryName, int ExpenseTitleId, string TitleName,
+ string? Payee, int? VendorId, string? VendorName, string? BillReference, string? Description, string? PeriodMonth,
+ decimal Amount, decimal PaidAmount, decimal BalanceAmount,
+ ExpenseStatus Status, ExpensePaymentStatus PaymentStatus,
+ string? GlExpenseAccountCode, string? GlJournalNo, DateTime? GlPostedAt, string? GlReversalJournalNo, string? CancelReason,
+ int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
+ IReadOnlyList Payments);
+
+public class ExpenseRequestBase
+{
+ /// Required when branching is enabled; otherwise the default branch is used.
+ public int BranchId { get; set; }
+ [Required] public DateOnly ExpenseDate { get; set; }
+ public DateOnly? DueDate { get; set; }
+ [Required] public int ExpenseCategoryId { get; set; }
+ [Required] public int ExpenseTitleId { get; set; }
+ [StringLength(150)] public string? Payee { get; set; }
+ public int? VendorId { get; set; }
+ [StringLength(50)] public string? BillReference { get; set; }
+ [StringLength(500)] public string? Description { get; set; }
+ [RegularExpression(@"^\d{4}-(0[1-9]|1[0-2])$", ErrorMessage = "PeriodMonth must be yyyy-MM.")]
+ public string? PeriodMonth { get; set; }
+ [Range(0.01, 999_999_999_999)] public decimal Amount { get; set; }
+}
+
+public sealed class CreateExpenseRequest : ExpenseRequestBase;
+
+public sealed class UpdateExpenseRequest : ExpenseRequestBase;
+
+public sealed class CancelExpenseRequest
+{
+ [Required, StringLength(250, MinimumLength = 1)] public string Reason { get; set; } = string.Empty;
+}
+
+public sealed class ExpensePaymentLineRequest
+{
+ [Required] public long GlBankAccountId { get; set; }
+ [Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
+}
+
+///
+/// One payment submission: one or more lines (e.g. part cash, part bank), all posted in a
+/// single GL journal entry. Total must not exceed the balance (for Post & Pay, the amount); partial is fine.
+///
+public sealed class CreateExpensePaymentRequest
+{
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+ public ExpensePaymentMethod PaymentMethod { get; set; } = ExpensePaymentMethod.Cash;
+ /// Required when is Cheque.
+ [StringLength(30)] public string? ChequeNo { get; set; }
+ [StringLength(100)] public string? Reference { get; set; }
+ /// When it was paid; also the GL entry date. Omitted ⇒ today. Never in the future.
+ public DateOnly? PaymentDate { get; set; }
+}
+
+public sealed record ExpenseListFilter(
+ ExpenseStatus? Status, ExpensePaymentStatus? PaymentStatus, int? CategoryId, int? TitleId,
+ DateOnly? From, DateOnly? To, bool OverdueOnly);
+
+public sealed record ExpenseTitleTotalDto(int ExpenseTitleId, string TitleName, decimal Amount, int Count);
+
+public sealed record ExpenseCategoryTotalDto(
+ int ExpenseCategoryId, string CategoryName, decimal Amount, int Count, IReadOnlyList Titles);
+
+/// Posted (non-cancelled) expenses dated within [From, To], plus the all-time unpaid/overdue position.
+public sealed record ExpenseSummaryReportDto(
+ DateOnly From, DateOnly To, decimal TotalAmount, decimal PaidAmount, decimal UnpaidAmount, int Count,
+ decimal OutstandingAmount, decimal OverdueAmount, int OverdueCount, int DraftCount,
+ IReadOnlyList Categories);
diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
index 84b47cd..9fad1dc 100644
--- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
+++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
@@ -7,12 +7,16 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
+/// A serial captured on a GRN line; is set once the GRN is confirmed.
+public sealed record GrnLineSerialDto(string SerialNo, int? SerialId);
+
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
- HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers);
+ HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers,
+ string? BatchNo, DateOnly? BatchExpiryDate, IReadOnlyList Serials);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
@@ -70,9 +74,15 @@ public sealed class CreateGrnLineInput
public BatchInput? Batch { get; set; }
///
/// Required, one per unit (count must equal ), when the item is
- /// warranty-tracked (Item.Warranty == Warranty.Warranty). Ignored otherwise.
+ /// warranty-tracked (Item.Warranty == Warranty.Warranty) and not serial-tracked —
+ /// a serial item's serial is its warranty identity. Ignored otherwise.
///
public List? WarrantyNumbers { get; set; }
+ ///
+ /// Required, one per unit (count must equal , so Qty must be whole), when the
+ /// item is serial-tracked (Item.TrackingMode == Serial). Rejected for any other item.
+ ///
+ public List? SerialNumbers { get; set; }
}
public sealed class CreateGrnRequest
diff --git a/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs b/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs
index 438ef01..164a11b 100644
--- a/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs
+++ b/Backend/ERPCore/Dtos/Items/ItemOverviewDtos.cs
@@ -2,6 +2,14 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Items;
+///
+/// One posted GRN receipt of an item — what it was bought at. is the
+/// cost after trade discount (the FIFO stock cost, VAT excluded) — the figure to price against.
+///
+public sealed record ItemPurchasePriceDto(
+ int GrnId, string GrnDocNo, DateTime ReceivedAt, int VendorId, string? VendorName, string? WarehouseCode,
+ decimal Qty, decimal UnitCost, decimal DiscountPct, decimal NetUnitCost, decimal VatPct);
+
///
/// Item row enriched with cross-module aggregates (current FIFO stock value, low-stock
/// flag, last purchase, trailing-30-day sales) for the Items list page. Deliberately a
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
index 483b8db..69e293e 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs
@@ -31,6 +31,8 @@ public sealed class CreatePurchaseInvoicePaymentRequest
[Range(0.01, double.MaxValue)] public decimal Amount { get; set; }
[Required] public long GlBankAccountId { get; set; }
[StringLength(100)] public string? Reference { get; set; }
+ /// When the vendor was paid; also the GL entry date. Omitted ⇒ today. Never in the future.
+ public DateOnly? PaymentDate { get; set; }
}
public sealed class CreatePurchaseInvoiceRequest
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
index d6fc74a..b8591c8 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
@@ -25,6 +25,12 @@ public sealed class CreatePurchaseReturnLineInput
public int? GrnLineId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
+ ///
+ /// Serials sent back, for a serial-tracked item — one per unit, each at the return warehouse
+ /// (InStock or Damaged; for a rejected GRN line, received on that line). They become
+ ///