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/GlAccountCodeValidatorTests.cs b/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs
index 4e88af7..6e1217e 100644
--- a/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs
+++ b/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs
@@ -1,8 +1,8 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
-using ERPCore.System.Errors;
using ERPCore.System.Time;
+using ERPCore.Tests.TestSupport;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace ERPCore.Tests;
///
-/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
-/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
-/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
+/// The validator is advisory: enforcement lives in ,
+/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
+/// it cannot abort startup. An earlier revision threw on a missing code and core-dumped
+/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
///
public class GlAccountCodeValidatorTests
{
[Fact]
- public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
+ public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
{
var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
- new StubGl(["1100", "2000", "4000"]));
+ new(),
+ new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1100"),
+ StubGeneralLedgerService.Postable("2000")
+ ]),
+ (GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
await validator.StartAsync(CancellationToken.None);
}
- ///
- /// The default must not block startup. Throwing here cost an outage on 2026-10-07 — a config
- /// mismatch that had existed all along became a core-dump the moment the check shipped.
- ///
[Fact]
- public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault()
+ public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
{
var validator = CreateValidator(
- new()
- {
- ["Grn:GlInventoryAccountCode"] = "1100",
- ["SalesDayEnd:GlCashAccountCode"] = "1000-01",
- ["Adjustment:GlLossAccountCode"] = "5100"
- },
- new StubGl(["1000-01"]));
+ new(),
+ new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
+ (GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
await validator.StartAsync(CancellationToken.None);
}
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
{
var validator = CreateValidator(
- new()
- {
- ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true",
- ["Grn:GlInventoryAccountCode"] = "1100",
- ["SalesDayEnd:GlCashAccountCode"] = "1000-01",
- ["Adjustment:GlLossAccountCode"] = "5100"
- },
- new StubGl(["1100"]));
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
+ (GlAccountRole.Inventory, "1100"),
+ (GlAccountRole.Cash, "1000-01"),
+ (GlAccountRole.InventoryLoss, "5100"));
var ex = await Assert.ThrowsAsync(
() => validator.StartAsync(CancellationToken.None));
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
Assert.Contains("1000-01", ex.Message);
Assert.Contains("5100", ex.Message);
Assert.Contains("2 of 3", ex.Message);
- Assert.DoesNotContain("1100,", ex.Message);
}
///
- /// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
- /// validator would call every configured code missing and refuse to start.
+ /// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
+ /// "everything is missing" would refuse to start on a contract change.
///
[Fact]
- public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
+ public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
{
var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
- new StubGl([]));
-
- await validator.StartAsync(CancellationToken.None);
- }
-
- [Fact]
- public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
- {
- var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100" },
- new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ new StubGeneralLedgerService([]),
+ (GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
///
- /// The earlier catch filter only matched DomainException/OperationCanceledException, so anything
- /// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host.
- /// A diagnostic must not be able to crash the thing it is diagnosing.
+ /// The catch filter once matched only DomainException/OperationCanceledException, so anything
+ /// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
+ /// mapping — escaped a hosted service and aborted the host.
///
[Theory]
[MemberData(nameof(UnexpectedFailures))]
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
{
var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100" },
- new StubGl(failure));
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ new StubGeneralLedgerService(failure),
+ (GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
public static TheoryData UnexpectedFailures() =>
[
+ StubGeneralLedgerService.Unreachable(),
new NullReferenceException("items was null"),
new global::System.Text.Json.JsonException("unexpected shape"),
new InvalidOperationException("something else entirely"),
new HttpRequestException("connection reset"),
+ new OperationCanceledException(),
];
[Fact]
- public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
+ public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
{
- var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100" },
- new StubGl(new OperationCanceledException()));
+ var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
- await validator.StartAsync(CancellationToken.None);
- }
-
- /// Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.
- [Fact]
- public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
- {
- var gl = new StubGl(["1100"]);
- var validator = CreateValidator(
- new()
- {
- ["Grn:GlInventoryAccountCode"] = "1100",
- ["Grn:GlPostingEnabled"] = "true",
- ["Grn:SomeOtherCode"] = "not-an-account"
- },
- gl);
-
- await validator.StartAsync(CancellationToken.None);
-
- Assert.Equal(1, gl.Calls);
- }
-
- [Fact]
- public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
- {
- var gl = new StubGl(["1100"]);
var validator = CreateValidator(new(), gl);
-
await validator.StartAsync(CancellationToken.None);
Assert.Equal(0, gl.Calls);
}
+ /// Duplicate codes across roles are one account to verify, not several.
+ [Fact]
+ public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
+ {
+ var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
+
+ var validator = CreateValidator(
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ gl,
+ (GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
+
+ await validator.StartAsync(CancellationToken.None);
+ Assert.Equal(1, gl.Calls);
+ }
+
/// Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.
[Fact]
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
@@ -165,39 +138,14 @@ public class GlAccountCodeValidatorTests
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
}
- private static GlAccountCodeValidator CreateValidator(Dictionary settings, IGeneralLedgerService gl)
+ private static GlAccountCodeValidator CreateValidator(
+ Dictionary settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
- var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
+ var services = new ServiceCollection()
+ .AddScoped(_ => gl)
+ .AddScoped(_ => new StubGlAccountConfigService(mapping))
+ .BuildServiceProvider();
return new GlAccountCodeValidator(services, configuration, NullLogger.Instance);
}
-
- private sealed class StubGl(HashSet? codes = null, Exception? throws = null) : IGeneralLedgerService
- {
- private readonly HashSet _codes = codes ?? [];
- private readonly Exception? _throws = throws;
-
- public StubGl(Exception throws) : this(null, throws) { }
-
- public int Calls { get; private set; }
-
- public Task> ListAccountCodesAsync(CancellationToken ct)
- {
- Calls++;
- return _throws is not null ? Task.FromException>(_throws) : Task.FromResult(_codes);
- }
-
- 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)
- => throw new NotSupportedException();
-
- public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
- => throw new NotSupportedException();
-
- public Task> ListBankAccountsAsync(CancellationToken ct)
- => throw new NotSupportedException();
- }
}
diff --git a/Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs b/Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs
new file mode 100644
index 0000000..2f1732e
--- /dev/null
+++ b/Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs
@@ -0,0 +1,234 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Dtos.Config;
+using ERPCore.Infra.Persistence;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.Services.Gl;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+
+namespace ERPCore.Tests;
+
+///
+/// The point of this service is that a mapping GL cannot post to never reaches the database.
+/// Validation at the point of entry is what the old appsettings approach could not do: those codes
+/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
+/// existed in GL at all.
+///
+public class GlAccountConfigServiceTests
+{
+ [Fact]
+ public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var gl = new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1100", "Inventory"),
+ StubGeneralLedgerService.Postable("2000", "GRNI")
+ ]);
+ var service = CreateService(db, gl);
+ var before = (await service.GetAsync()).RowVersion;
+
+ var result = await service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
+
+ Assert.True(result.RowVersion > before);
+ var codes = await CreateService(db, gl).GetCodesAsync();
+ Assert.Equal("1100", codes[GlAccountRole.Inventory]);
+ Assert.Equal("2000", codes[GlAccountRole.Clearing]);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
+
+ Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
+ Assert.Equal(422, ex.StatusCode);
+ Assert.Contains("9999", ex.Message);
+ Assert.Contains("Clearing", ex.Message);
+ await AssertNothingStoredAsync(db);
+ }
+
+ ///
+ /// GL rejects a journal line against a non-postable account, so storing one only defers the
+ /// failure to posting time. The auto-created 1000 Bank/Cash header is exactly this trap.
+ ///
+ [Fact]
+ public async Task UpdateAsync_RejectsANonPostableAccount()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([
+ StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
+ ]));
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
+
+ Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
+ Assert.Contains("not postable", ex.Message);
+ await AssertNothingStoredAsync(db);
+ }
+
+ /// Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.
+ [Fact]
+ public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
+
+ Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
+ await AssertNothingStoredAsync(db);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([]));
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
+
+ Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
+ Assert.Equal(503, ex.StatusCode);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RejectsAStaleIfMatch()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
+
+ Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
+ Assert.Equal(412, ex.StatusCode);
+ }
+
+ /// An empty string clears a role rather than storing "" and posting it to GL.
+ [Fact]
+ public async Task UpdateAsync_TreatsBlankAsCleared()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
+ var service = CreateService(db, gl);
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
+
+ var codes = await CreateService(db, gl).GetCodesAsync();
+ Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
+ }
+
+ /// Reading an unmapped role names the role, instead of posting "" and letting GL 404.
+ [Fact]
+ public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([]));
+
+ var codes = await service.GetCodesAsync();
+ var ex = Assert.Throws(() => codes[GlAccountRole.Cogs]);
+
+ Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
+ Assert.Contains("Cogs", ex.Message);
+ }
+
+ /// One request can post through several services; they should not each re-read the row.
+ [Fact]
+ public async Task GetCodesAsync_ReadsOncePerScope()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([]));
+
+ var first = await service.GetCodesAsync();
+ var second = await service.GetCodesAsync();
+
+ Assert.Same(first, second);
+ }
+
+ /// GL being down must not stop the saved mapping from rendering on the settings screen.
+ [Fact]
+ public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
+ {
+ await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
+ var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
+
+ var result = await service.GetAsync();
+
+ Assert.True(result.Value.GlUnavailable);
+ Assert.Empty(result.Value.Options);
+ var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
+ Assert.Equal("1100", inventory.AccountCode);
+ }
+
+ /// The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.
+ [Fact]
+ public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
+ {
+ await using var db = await CreateSeededContextAsync(c =>
+ {
+ c.InventoryAccountCode = "1100";
+ c.CashAccountCode = "1000-01";
+ });
+ var service = CreateService(db, new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
+ ]));
+
+ var result = await service.GetAsync();
+
+ Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
+ Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
+ }
+
+ /// The picker must only offer what validation will accept.
+ [Fact]
+ public async Task GetAsync_OffersOnlyPostableActiveAccounts()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1100", "Inventory"),
+ StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
+ new GlAccount("1200", "Retired", 1, true, false)
+ ]));
+
+ var options = (await service.GetAsync()).Value.Options;
+
+ Assert.Equal(["1100"], options.Select(o => o.AccountCode));
+ }
+
+ private static async Task AssertNothingStoredAsync(ErpDbContext db)
+ {
+ var row = await new Repository(db).GetByIdAsync(GlAccountConfig.SingletonId);
+ Assert.Null(row!.InventoryAccountCode);
+ Assert.Null(row.ClearingAccountCode);
+ Assert.Null(row.CashAccountCode);
+ }
+
+ private static async Task CreateSeededContextAsync(Action? configure = null)
+ {
+ var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
+ db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
+
+ var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
+ configure?.Invoke(config);
+ db.GlAccountConfig.Add(config);
+ await db.SaveChangesAsync();
+ return db;
+ }
+
+ private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
+ new(new Repository(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
+}
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.Tests/TestSupport/GlTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/GlTestSupport.cs
new file mode 100644
index 0000000..14cd8af
--- /dev/null
+++ b/Backend/ERPCore.Tests/TestSupport/GlTestSupport.cs
@@ -0,0 +1,66 @@
+using ERPCore.Infra.Gl;
+using ERPCore.Services.Gl;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+
+namespace ERPCore.Tests.TestSupport;
+
+///
+/// Stands in for the external GL service. Only is implemented —
+/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
+/// doing so fails loudly rather than against a silently empty fake.
+///
+internal sealed class StubGeneralLedgerService : IGeneralLedgerService
+{
+ private readonly IReadOnlyList _accounts;
+ private readonly Exception? _throws;
+
+ public StubGeneralLedgerService(IReadOnlyList accounts) => _accounts = accounts;
+ public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
+
+ public int Calls { get; private set; }
+
+ /// A postable, active account — the ordinary case.
+ public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
+
+ /// A header/control account. GL rejects journal lines against these.
+ public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
+
+ public Task> ListAccountsAsync(CancellationToken ct)
+ {
+ Calls++;
+ return _throws is not null
+ ? Task.FromException>(_throws)
+ : Task.FromResult(_accounts);
+ }
+
+ 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)
+ => throw new NotSupportedException();
+
+ public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
+ => throw new NotSupportedException();
+
+ public Task> ListBankAccountsAsync(CancellationToken ct)
+ => throw new NotSupportedException();
+
+ public static DomainException Unreachable() =>
+ new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
+}
+
+/// Returns a fixed mapping, for exercising the startup validator without a database.
+internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
+{
+ public Task GetCodesAsync(CancellationToken ct = default) =>
+ Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
+
+ public Task> GetAsync(CancellationToken ct = default)
+ => throw new NotSupportedException();
+
+ public Task> UpdateAsync(
+ ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
+ => throw new NotSupportedException();
+}
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/GlAccountConfigController.cs b/Backend/ERPCore/Controllers/GlAccountConfigController.cs
new file mode 100644
index 0000000..519c9c5
--- /dev/null
+++ b/Backend/ERPCore/Controllers/GlAccountConfigController.cs
@@ -0,0 +1,53 @@
+using ERPCore.Dtos.Config;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Role → GL account mapping endpoints.
+///
+/// Authorization: the settings nav policy, like .
+/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
+/// proxy directly: is gated on ledgers, so a
+/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
+/// and the server-side validation work from the same list.
+///
+///
+[Route("api/v1/gl-account-config")]
+[Authorize(Policy = NavPolicies.Prefix + "settings")]
+public sealed class GlAccountConfigController : ApiControllerBase
+{
+ private readonly IGlAccountConfigService _config;
+
+ public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
+ public async Task> Get(CancellationToken ct)
+ {
+ var result = await _config.GetAsync(ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ ///
+ /// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
+ /// not be reached, since a mapping that could not be verified is not stored.
+ ///
+ [HttpPut]
+ [ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task> Update(
+ [FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _config.UpdateAsync(request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
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
///