using System.Text.Json; using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Notifications; using ERPCore.Dtos.OpeningBalances; using ERPCore.Dtos.Procurement; using ERPCore.Dtos.Vendors; using ERPCore.Infra.Gl; 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 static ERPCore.Tests.TestSupport.BankDepositTestSupport; namespace ERPCore.Tests; /// /// Procurement configuration (vendor contact / address, PO tax) and the one-time opening balances. /// Every rule here is also enforced by the UI, so these tests are about the server refusing what a stale /// or hand-built request sends. /// public class ProcurementAndOpeningBalanceTests { // ---------------------------------------------------------------- helpers private static async Task SeedProcurementConfigAsync( ErpDbContext db, VendorContactMode mode = VendorContactMode.None, bool address = false, bool tax = false, bool terms = true, bool taxReg = true) { db.ProcurementConfig.Add(new ProcurementConfig { ConfigId = ProcurementConfig.SingletonId, VendorContactMode = mode, VendorAddressEnabled = address, PoTaxEnabled = tax, VendorPaymentTermsEnabled = terms, VendorTaxRegEnabled = taxReg }); await db.SaveChangesAsync(); } private static ProcurementConfigService ConfigService(ErpDbContext db) => new(new Repository(db), new TestCurrentUser(), new PassThroughUnitOfWork(db)); private static VendorService VendorSvc(ErpDbContext db) => new(new Repository(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db)); private static CreateVendorRequest NewVendor(string code, Action? tweak = null) { var request = new CreateVendorRequest { Code = code, Name = $"Vendor {code}", Currency = "LKR", BranchId = Branch1 }; tweak?.Invoke(request); return request; } private sealed class NullNotifications : INotificationService { 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) => Task.CompletedTask; public Task GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException(); public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => Task.CompletedTask; } // ---------------------------------------------------------------- vendors [Fact] public async Task Vendor_Mobile_IsRejected_WhileContactModeIsNone() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, VendorContactMode.None); var ex = await Assert.ThrowsAsync( () => VendorSvc(db).CreateAsync(NewVendor("V1", r => r.Mobile = "0771234567"))); Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code); Assert.Empty(db.Vendors); } [Fact] public async Task Vendor_Mobile_IsSaved_WhenContactModeIsMobile_AndEmailIsStillRefused() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, VendorContactMode.Mobile); var created = (await VendorSvc(db).CreateAsync(NewVendor("V2", r => r.Mobile = " +94 77 123 4567 "))).Value; Assert.Equal("+94 77 123 4567", created.Mobile); Assert.Null(created.Email); var ex = await Assert.ThrowsAsync( () => VendorSvc(db).CreateAsync(NewVendor("V3", r => r.Email = "a@b.lk"))); Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code); } [Theory] [InlineData("abc")] [InlineData("12")] public async Task Vendor_InvalidMobile_IsRejected(string mobile) { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, VendorContactMode.Mobile); var ex = await Assert.ThrowsAsync( () => VendorSvc(db).CreateAsync(NewVendor("V4", r => r.Mobile = mobile))); Assert.Equal(ErrorCodes.Validation, ex.Code); } [Fact] public async Task Vendor_Address_NeedsTheSwitchOn() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, address: false); var off = await Assert.ThrowsAsync( () => VendorSvc(db).CreateAsync(NewVendor("V5", r => r.Address = "12 Galle Road"))); Assert.Equal(ErrorCodes.ConfigDisabled, off.Code); db.ProcurementConfig.Single().VendorAddressEnabled = true; await db.SaveChangesAsync(); var created = (await VendorSvc(db).CreateAsync(NewVendor("V6", r => r.Address = "12 Galle Road"))).Value; Assert.Equal("12 Galle Road", created.Address); } [Fact] public async Task Vendor_Update_KeepsStoredContact_WhenTheSwitchIsOff() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: true); var created = (await VendorSvc(db).CreateAsync(NewVendor("V7", r => { r.Email = "accounts@supplier.lk"; r.Address = "Colombo"; }))).Value; // Contact mode switched off later: an update must not erase what was recorded. db.ProcurementConfig.Single().VendorContactMode = VendorContactMode.None; await db.SaveChangesAsync(); var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId); var updated = (await VendorSvc(db).UpdateAsync( created.VendorId, new UpdateVendorRequest { Code = "V7", Name = "Renamed", Currency = "LKR", BranchId = Branch1 }, tracked.RowVersion)).Value; Assert.Equal("Renamed", updated.Name); Assert.Equal("accounts@supplier.lk", db.Vendors.Single().Email); } [Fact] public async Task Vendor_TermsAndTaxReg_AreOnByDefault_AndSaved() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db); // defaults: terms + tax registration on var created = (await VendorSvc(db).CreateAsync(NewVendor("V8", r => { r.Terms = " NET30 "; r.TaxReg = "134567890-7000"; }))).Value; Assert.Equal("NET30", created.Terms); Assert.Equal("134567890-7000", created.TaxReg); } [Fact] public async Task Vendor_TermsAndTaxReg_AreRejectedWhenTheirSwitchesAreOff() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, terms: false, taxReg: false); var terms = await Assert.ThrowsAsync(() => VendorSvc(db).CreateAsync(NewVendor("V9", r => r.Terms = "NET30"))); var taxReg = await Assert.ThrowsAsync(() => VendorSvc(db).CreateAsync(NewVendor("V10", r => r.TaxReg = "123"))); Assert.Equal(ErrorCodes.ConfigDisabled, terms.Code); Assert.Equal(ErrorCodes.ConfigDisabled, taxReg.Code); Assert.Empty(db.Vendors); // Blank is fine: the form simply doesn't send them. var ok = (await VendorSvc(db).CreateAsync(NewVendor("V11"))).Value; Assert.Null(ok.Terms); } [Fact] public async Task Vendor_Update_KeepsStoredTermsAndTaxReg_WhenSwitchedOff() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db); var created = (await VendorSvc(db).CreateAsync(NewVendor("V12", r => { r.Terms = "NET45"; r.TaxReg = "TRN-1"; }))).Value; var cfg = db.ProcurementConfig.Single(); cfg.VendorPaymentTermsEnabled = false; cfg.VendorTaxRegEnabled = false; await db.SaveChangesAsync(); var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId); var updated = (await VendorSvc(db).UpdateAsync( created.VendorId, new UpdateVendorRequest { Code = "V12", Name = "Renamed", Currency = "LKR", BranchId = Branch1 }, tracked.RowVersion)).Value; Assert.Equal("Renamed", updated.Name); Assert.Equal(("NET45", "TRN-1"), (db.Vendors.Single().Terms, db.Vendors.Single().TaxReg)); } // ---------------------------------------------------------------- purchase-order tax private static PurchaseOrderService PoSvc(ErpDbContext db) => new(new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db)); private static async Task SeedVendorAndItemAsync(ErpDbContext db) { db.Vendors.Add(new Vendor { VendorId = 1, Code = "V-PO", Name = "PO Vendor", BranchId = Branch1, Status = EntityStatus.Active }); db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola 500ml", CategoryId = 1, BaseUomId = 1 }); await db.SaveChangesAsync(); return 1; } private static CreatePurchaseOrderRequest PoRequest(decimal taxRate) => new() { VendorId = 1, BranchId = Branch1, SaveAsDraft = true, Lines = [new CreatePoLineInput { ItemId = 1, WarehouseId = Warehouse1, Qty = 10, UnitPrice = 100m, Tax = taxRate }] }; [Fact] public async Task Po_TaxOn_StoresTheLineTaxAmount_AndTotalsIncludeIt() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, tax: true); await SeedVendorAndItemAsync(db); var po = (await PoSvc(db).CreateAsync(PoRequest(0.18m))).Value; var line = Assert.Single(po.Lines); Assert.Equal(180m, line.TaxAmount); Assert.Equal((1000m, 180m, 1180m), (po.Totals.SubTotal, po.Totals.Tax, po.Totals.GrandTotal)); Assert.Equal(180m, db.PoLines.Single().TaxAmount); } [Fact] public async Task Po_TaxAmount_RoundsHalfUp_ToTwoDecimals() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, tax: true); await SeedVendorAndItemAsync(db); // 3 x 33.5 = 100.5; at 5% the tax is 5.025 -> 5.03 (half away from zero, not banker's rounding). var request = PoRequest(0.05m); request.Lines[0].Qty = 3; request.Lines[0].UnitPrice = 33.5m; var po = (await PoSvc(db).CreateAsync(request)).Value; Assert.Equal(5.03m, Assert.Single(po.Lines).TaxAmount); } [Fact] public async Task Po_TaxOff_RejectsANonZeroRate_ButAcceptsZero() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, tax: false); await SeedVendorAndItemAsync(db); var ex = await Assert.ThrowsAsync(() => PoSvc(db).CreateAsync(PoRequest(0.18m))); Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code); Assert.Empty(db.PurchaseOrders); var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value; Assert.Equal(0m, po.Totals.Tax); } [Fact] public async Task Po_CarriesVendorDetails_OnlyForTheEnabledFields() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: false); await SeedVendorAndItemAsync(db); var vendor = db.Vendors.Single(); vendor.Mobile = "0771234567"; vendor.Email = "po@vendor.lk"; vendor.Address = "Colombo"; await db.SaveChangesAsync(); var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value; Assert.NotNull(po.Vendor); Assert.Equal("PO Vendor", po.Vendor!.Name); Assert.Equal("po@vendor.lk", po.Vendor.Email); Assert.Null(po.Vendor.Mobile); // contact mode is Email Assert.Null(po.Vendor.Address); // address switched off } // ---------------------------------------------------------------- purchase-order list: date filter + sort /// Three POs from two vendors, created on known business days; returns their ids in creation order. private static async Task SeedListAsync(ErpDbContext db) { db.Vendors.AddRange( new Vendor { VendorId = 1, Code = "V-B", Name = "Beta Traders", BranchId = Branch1, Status = EntityStatus.Active }, new Vendor { VendorId = 2, Code = "V-A", Name = "Alpha Foods", BranchId = Branch1, Status = EntityStatus.Active }); db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola", CategoryId = 1, BaseUomId = 1 }); PurchaseOrder Po(int id, int vendor, DateTime createdUtc, decimal qty) => new() { PoId = id, DocNo = $"PO-{id}", VendorId = vendor, BranchId = Branch1, Status = PurchaseOrderStatus.Draft, CreatedAt = createdUtc, Lines = [new PoLine { ItemId = 1, WarehouseId = Warehouse1, Qty = qty, UnitPrice = 10m }] }; db.PurchaseOrders.AddRange( Po(1, 1, new DateTime(2026, 4, 1, 4, 0, 0, DateTimeKind.Utc), 5), // 1 Apr (Colombo 09:30), total 50 Po(2, 2, new DateTime(2026, 4, 1, 20, 0, 0, DateTimeKind.Utc), 30), // 2 Apr in Colombo (01:30), total 300 Po(3, 1, new DateTime(2026, 4, 10, 6, 0, 0, DateTimeKind.Utc), 10)); // 10 Apr, total 100 await db.SaveChangesAsync(); return [1, 2, 3]; } private static async Task> ListIds(PurchaseOrderService svc, string? sort = null, DateOnly? from = null, DateOnly? to = null) { var page = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Sort = sort, PageSize = 50 }, null, null, from, to); return page.Items.Select(i => i.PoId).ToList(); } [Fact] public async Task PoList_DateRange_UsesTheBusinessDayOfTheCreatedTime_AndIsInclusive() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db); await SeedListAsync(db); var svc = PoSvc(db); // PO 2 was created 20:00 UTC on 1 Apr = 2 Apr in Colombo, so a "1 Apr only" filter must not include it. Assert.Equal([1], await ListIds(svc, from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 1))); Assert.Equal([2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 2))); Assert.Equal([3, 2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 10))); Assert.Equal([3, 2, 1], await ListIds(svc, to: new DateOnly(2026, 4, 30))); Assert.Equal([3], await ListIds(svc, from: new DateOnly(2026, 4, 3))); } [Fact] public async Task PoList_Sorts_ByCreatedVendorAndTotal_BothWays() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db); await SeedListAsync(db); var svc = PoSvc(db); Assert.Equal([3, 2, 1], await ListIds(svc)); // default: newest first Assert.Equal([1, 2, 3], await ListIds(svc, "created")); // oldest first Assert.Equal([2, 3, 1], await ListIds(svc, "vendor")); // Alpha, then Beta (ties: newest first) Assert.Equal([3, 1, 2], await ListIds(svc, "-vendor")); Assert.Equal([2, 3, 1], await ListIds(svc, "-total")); // 300, 100, 50 Assert.Equal([1, 3, 2], await ListIds(svc, "total")); Assert.Equal([3, 2, 1], await ListIds(svc, "nonsense")); // unknown key falls back to the default } [Fact] public async Task PoList_Search_MatchesVendorNameAndCode() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db); await SeedListAsync(db); var svc = PoSvc(db); var byName = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "alpha", PageSize = 50 }, null, null); var byCode = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "V-B", PageSize = 50 }, null, null); Assert.Equal([2], byName.Items.Select(i => i.PoId)); Assert.Equal([3, 1], byCode.Items.Select(i => i.PoId)); } // ---------------------------------------------------------------- opening balances /// A GL that knows two LKR cash/bank accounts, optionally the equity account, and can create it. private sealed class OpeningGl : IGeneralLedgerService { public List Posted { get; } = new(); public int AccountCreates { get; private set; } public bool EquityExists { get; init; } = true; public string BankCurrency { get; init; } = "LKR"; public Task ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct) { AccountCreates++; return Task.FromResult(new GeneralLedgerResponse { StatusCode = 201, Body = """{"statusCode":201,"success":true,"message":"Account created successfully","data":{"accountCode":"3000"}}""" }); } public Task PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct) { Posted.Add(request); return Task.FromResult(new GlJournalEntryResult($"JV-2026-{Posted.Count:000000}", true)); } public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1)); public Task> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult>( [ new("Cash", 1, "Main Cash Account", null, "Petty Cash", "CASH-000001", 17, "1400-01-01", "LKR"), new("Bank", 2, "BOC Current", "Bank of Ceylon", null, "0001234567", 18, "1000-01", BankCurrency) ]); public Task> ListAccountsAsync(CancellationToken ct) => Task.FromResult>(EquityExists ? [new("3000", "Opening Balance Equity", 3, true, true)] : [new("1000", "Bank", 1, false, true)]); } private static OpeningBalanceService OpeningSvc(ErpDbContext db, OpeningGl gl) => new(new Repository(db), gl, new TestCurrentUser(), new PassThroughUnitOfWork(db)); private static PostCashBankOpeningRequest Opening(DateOnly date, params (string Code, decimal Amount)[] lines) => new() { CutoverDate = date, Lines = lines.Select(l => new OpeningBalanceLineInput { GlAccountCode = l.Code, Amount = l.Amount }).ToList() }; private static readonly DateOnly OpeningDate = new(2026, 4, 1); [Fact] public async Task Opening_PostsOneBalancedJournal_DebitingEachAccount_AndCreditingEquity() { await using var db = CreateContext(); var gl = new OpeningGl(); var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 500m), ("1000-01", 1500m))); var entry = Assert.Single(gl.Posted); Assert.Equal("OPENING_BALANCE", entry.JournalType); Assert.Equal("OPENING", entry.SourceModule); Assert.Equal(OpeningDate, entry.EntryDate); Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount)); Assert.Contains(entry.Lines, l => l is { AccountCode: "1400-01-01", DebitAmount: 500m, CreditAmount: 0m }); Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 1500m, CreditAmount: 0m }); Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 2000m }); Assert.Equal(2000m, run.TotalAmount); Assert.Equal("JV-2026-000001", run.GlJournalNo); Assert.Equal(2, run.Lines.Count); Assert.Equal(0, gl.AccountCreates); // the equity account already existed } [Fact] public async Task Opening_CreatesTheEquityAccount_WhenTheChartHasNone() { await using var db = CreateContext(); var gl = new OpeningGl { EquityExists = false }; await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m))); Assert.Equal(1, gl.AccountCreates); Assert.Contains(Assert.Single(gl.Posted).Lines, l => l.AccountCode == "3000" && l.CreditAmount == 100m); } [Fact] public async Task Opening_SecondPosting_IsRefused_AndPostsNothingMore() { await using var db = CreateContext(); var gl = new OpeningGl(); var svc = OpeningSvc(db, gl); await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m))); var ex = await Assert.ThrowsAsync( () => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 999m)))); Assert.Equal(ErrorCodes.OpeningBalanceAlreadyPosted, ex.Code); Assert.Equal(409, ex.StatusCode); Assert.Single(gl.Posted); Assert.Equal(100m, Assert.Single(db.OpeningBalanceRuns.Include(r => r.Lines)).Lines.Single().Amount); } [Fact] public async Task Opening_Overdraft_IsCreditedToTheAccount_AndEquityTakesTheNet() { await using var db = CreateContext(); var gl = new OpeningGl(); var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 300m), ("1000-01", -100m))); var entry = Assert.Single(gl.Posted); Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 0m, CreditAmount: 100m }); Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 200m }); Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount)); Assert.Equal(200m, run.TotalAmount); } [Fact] public async Task Opening_ShowsPostedAmounts_OnTheStateEndpoint() { await using var db = CreateContext(); var svc = OpeningSvc(db, new OpeningGl()); var before = await svc.GetCashBankAsync(); Assert.Null(before.Posted); Assert.All(before.Accounts, a => Assert.Null(a.Amount)); await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 250m))); var after = await svc.GetCashBankAsync(); Assert.NotNull(after.Posted); Assert.Equal(250m, after.Accounts.Single(a => a.GlAccountCode == "1400-01-01").Amount); Assert.Null(after.Accounts.Single(a => a.GlAccountCode == "1000-01").Amount); } [Fact] public async Task Opening_RejectsBadInput_WithoutPostingAnything() { await using var db = CreateContext(); var gl = new OpeningGl(); var svc = OpeningSvc(db, gl); // not a cash/bank account await Assert.ThrowsAsync(() => svc.PostCashBankAsync(Opening(OpeningDate, ("9999", 10m)))); // more than 2 decimals await Assert.ThrowsAsync(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 10.005m)))); // nothing but zeros await Assert.ThrowsAsync(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 0m)))); // the same account twice await Assert.ThrowsAsync(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 5m), ("1400-01-01", 6m)))); // in the future await Assert.ThrowsAsync(() => svc.PostCashBankAsync(Opening(DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30), ("1400-01-01", 5m)))); Assert.Empty(gl.Posted); Assert.Empty(db.OpeningBalanceRuns); } [Fact] public async Task Opening_RefusesANonBaseCurrencyAccount() { await using var db = CreateContext(); var gl = new OpeningGl { BankCurrency = "USD" }; var ex = await Assert.ThrowsAsync( () => OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1000-01", 100m)))); Assert.Equal(ErrorCodes.Validation, ex.Code); Assert.Empty(gl.Posted); } // ---------------------------------------------------------------- opening stock private static OpeningStockService StockSvc(ErpDbContext db, OpeningGl gl) { var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000")); var fifo = new FifoCostingService( new Repository(db), new Repository(db), new Repository(db), db, gl, glAccounts); return new OpeningStockService( new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), fifo, gl, glAccounts, new TestCurrentUser(), new PassThroughUnitOfWork(db)); } private static Item StockedItem(int id, Action? tweak = null) { var item = new Item { ItemId = id, Sku = $"SKU-{id}", Name = $"Item {id}", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None, Status = EntityStatus.Active }; tweak?.Invoke(item); return item; } private static PostOpeningStockRequest StockRequest(params (int Item, decimal Qty, decimal Cost)[] lines) => new() { CutoverDate = OpeningDate, Lines = lines.Select(l => new OpeningStockLineInput { ItemId = l.Item, WarehouseId = Warehouse1, Qty = l.Qty, UnitCost = l.Cost }).ToList() }; [Fact] public async Task OpeningStock_CreatesLayerAndLedgerRow_AndPostsExactlyOneJournal() { await using var db = CreateContext(); db.Items.AddRange(StockedItem(1), StockedItem(2)); await db.SaveChangesAsync(); var gl = new OpeningGl(); var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 24m, 12.5m), (2, 10m, 3m))); Assert.Equal(330m, run.TotalValue); Assert.Equal(2, run.LineCount); var layer = db.StockLayers.Single(l => l.ItemId == 1); Assert.Equal((24m, 24m, 12.5m), (layer.QtyReceived, layer.QtyRemaining, layer.UnitCost)); var row = db.StockLedger.Single(l => l.ItemId == 1); Assert.Equal(("OPN", Direction.In, 24m, 300m, 24m), (row.SourceDocType, row.Direction, row.QtyBase, row.Value, row.RunningBalance)); Assert.Equal(run.OpeningBalanceRunId, row.SourceDocId); // One journal in total — the regular stock path would have added a STOCK journal (Dr Inventory / Cr Clearing) per row. var entry = Assert.Single(gl.Posted); Assert.Equal(("OPENING", "OPENING_BALANCE"), (entry.SourceModule, entry.JournalType)); Assert.Collection(entry.Lines, l => Assert.Equal(("1100", 330m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)), l => Assert.Equal(("3000", 0m, 330m), (l.AccountCode, l.DebitAmount, l.CreditAmount))); Assert.Equal(2, db.JournalEntryStubs.Count(j => j.SourceDocType == "OPN")); } [Fact] public async Task OpeningStock_RefusesAnItemThatAlreadyHasMovements_InThatWarehouse() { await using var db = CreateContext(); db.Items.AddRange(StockedItem(1), StockedItem(2)); db.StockLedger.Add(new StockLedger { ItemId = 1, WarehouseId = Warehouse1, Direction = Direction.In, QtyBase = 5, UnitCost = 1, Value = 5, RunningBalance = 5, SourceDocType = "GRN", SourceDocId = 1 }); await db.SaveChangesAsync(); var gl = new OpeningGl(); var ex = await Assert.ThrowsAsync(() => StockSvc(db, gl).PostAsync(StockRequest((1, 3m, 2m), (2, 3m, 2m)))); Assert.Equal(409, ex.StatusCode); Assert.Contains("SKU-1", ex.Message); Assert.Empty(gl.Posted); Assert.Empty(db.OpeningBalanceRuns); Assert.Empty(db.StockLayers); // nothing at all was written, not even for the clean item } [Fact] public async Task OpeningStock_CanBeLoadedInSeveralRuns_ForDifferentItems() { await using var db = CreateContext(); db.Items.AddRange(StockedItem(1), StockedItem(2)); await db.SaveChangesAsync(); var gl = new OpeningGl(); var svc = StockSvc(db, gl); await svc.PostAsync(StockRequest((1, 5m, 2m))); await svc.PostAsync(StockRequest((2, 7m, 4m))); Assert.Equal(2, gl.Posted.Count); Assert.Equal(2, (await svc.ListAsync()).Count); // ...but the same item cannot be opened a second time. await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); } [Fact] public async Task OpeningStock_RefusesUnsuitableItemsAndBadNumbers() { await using var db = CreateContext(); db.Items.AddRange( StockedItem(1, i => i.TrackingMode = TrackingMode.Serial), StockedItem(2, i => i.TrackingMode = TrackingMode.Batch), StockedItem(3, i => i.StockNature = StockNature.Service), StockedItem(4, i => i.Status = EntityStatus.Inactive), StockedItem(5)); await db.SaveChangesAsync(); var gl = new OpeningGl(); var svc = StockSvc(db, gl); await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); // serial await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((2, 1m, 1m)))); // batch await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((3, 1m, 1m)))); // service await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((4, 1m, 1m)))); // inactive await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((99, 1m, 1m)))); // unknown await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((5, 0m, 1m)))); // zero qty await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((5, 1m, -1m)))); // negative cost await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((5, 1.00001m, 1m)))); // > 4 decimals await Assert.ThrowsAsync(() => svc.PostAsync(StockRequest((5, 1m, 1m), (5, 2m, 1m)))); // duplicate Assert.Empty(gl.Posted); Assert.Empty(db.StockLayers); } [Fact] public async Task OpeningStock_ZeroCostRun_PostsNoJournal() { await using var db = CreateContext(); db.Items.Add(StockedItem(1)); await db.SaveChangesAsync(); var gl = new OpeningGl(); var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 10m, 0m))); Assert.Equal(0m, run.TotalValue); Assert.Null(run.GlJournalNo); Assert.Empty(gl.Posted); Assert.Equal(10m, db.StockLayers.Single().QtyRemaining); } // ---------------------------------------------------------------- purchase-order rejection [Fact] public async Task Po_Reject_ShowsRejected_NotDraftOrPending_AndCanBeRevisedAndResubmitted() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db); await SeedVendorAndItemAsync(db); var svc = PoSvc(db); var po = (await svc.CreateAsync(PoRequest(0m))).Value; await svc.SubmitAsync(po.PoId); // Somebody else raised it: the approver can't be its creator. db.PurchaseOrders.Single().CreatedBy = 999; await db.SaveChangesAsync(); var rejected = await svc.RejectAsync(po.PoId, "Wrong price"); Assert.Equal(PurchaseOrderStatus.Rejected, rejected.Status); // A rejected order is reopened like a draft: it can be sent for approval again... var resubmitted = await svc.SubmitAsync(po.PoId); Assert.Equal(PurchaseOrderStatus.PendingApproval, resubmitted.Status); } [Fact] public async Task Po_Rejected_CanBeDeleted_ButAnApprovedOneCannot() { await using var db = CreateContext(); await SeedProcurementConfigAsync(db); await SeedVendorAndItemAsync(db); var svc = PoSvc(db); var po = (await svc.CreateAsync(PoRequest(0m))).Value; await svc.SubmitAsync(po.PoId); db.PurchaseOrders.Single().CreatedBy = 999; await db.SaveChangesAsync(); await svc.RejectAsync(po.PoId, null); await svc.DeleteAsync(po.PoId); Assert.Empty(db.PurchaseOrders); } // ---------------------------------------------------------------- General Ledger payload private sealed class CapturingGlClient(int status, string body) : IGeneralLedgerClient { public string? SentBody { get; private set; } public async Task SendAsync( HttpMethod method, string path, string? queryString, string? contentType, Stream? requestBody, CancellationToken ct) { if (requestBody is not null) { using var reader = new StreamReader(requestBody); SentBody = await reader.ReadToEndAsync(ct); } return new GeneralLedgerResponse { StatusCode = status, ContentType = "application/json", Body = body }; } } private static GlJournalEntryRequest GrnJournal() => new() { PeriodId = 1, EntryDate = new DateOnly(2026, 10, 18), SourceModule = "GRN", Reference = "GRN-1", Lines = [new GlJournalEntryLineRequest("1100", 10m, 0m, "x"), new GlJournalEntryLineRequest("2100", 0m, 10m)] }; [Fact] public async Task GlPost_NeverSendsNulls_BecauseGlTreatsANullJournalTypeAsMissing() { // GL's request DTO has a non-nullable JournalType; an explicit "journalType": null is a 400 from its model // validation. Only opening balances set one, so every other posting (GRN, sales, ...) must leave it out. var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}"); var result = await new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None); Assert.Equal("J-1", result.JournalNo); Assert.DoesNotContain("journalType", client.SentBody!); Assert.DoesNotContain("null", client.SentBody!); } [Fact] public async Task GlPost_KeepsAJournalTypeWhenOneIsSet() { var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}"); var request = GrnJournal(); request.JournalType = "OPENING_BALANCE"; await new GeneralLedgerService(client).PostJournalEntryAsync(request, CancellationToken.None); Assert.Contains("\"journalType\":\"OPENING_BALANCE\"", client.SentBody!); } [Fact] public async Task GlPost_AValidationFailureFromGl_IsReportedWithItsReason_NotAsAGenericRejection() { var problem = "{\"title\":\"One or more validation errors occurred.\",\"status\":400,\"errors\":{\"JournalType\":[\"The JournalType field is required.\"]}}"; var client = new CapturingGlClient(400, problem); var ex = await Assert.ThrowsAsync( () => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None)); Assert.Equal(ErrorCodes.GlRequestFailed, ex.Code); Assert.Contains("JournalType", ex.Message); Assert.Contains("required", ex.Message); } [Fact] public async Task GlPost_GlsOwnMessage_StillWins() { var client = new CapturingGlClient(400, "{\"StatusCode\":400,\"Success\":false,\"Message\":\"Period is locked\",\"Data\":null}"); var ex = await Assert.ThrowsAsync( () => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None)); Assert.Equal("Period is locked", ex.Message); } // ---------------------------------------------------------------- stock movement vs. document journal [Fact] public async Task StockLedgerPost_AddsTheGenericStockJournal_OnlyWhenTheDocumentHasNoJournalOfItsOwn() { await using var db = CreateContext(); var gl = new OpeningGl(); var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000")); var fifo = new FifoCostingService( new Repository(db), new Repository(db), new Repository(db), db, gl, glAccounts); var now = DateTime.UtcNow; // A GRN / sales invoice / adjustment posts its own balanced journal: the ledger row must not add a second one, // or the movement is counted twice in the General Ledger. await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 100m, 200m, 100m, "GRN", 1, now, CancellationToken.None, postGlJournal: false); await db.SaveChangesAsync(); Assert.Empty(gl.Posted); Assert.Single(db.StockLedger); // Production and transfers have no document journal, so they keep the generic one. await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 5m, 10m, 105m, "TRF", 1, now, CancellationToken.None); var journal = Assert.Single(gl.Posted); Assert.Equal("STOCK", journal.SourceModule); } }