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);
}
}