e7b1f270a5
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
841 lines
37 KiB
C#
841 lines
37 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<ProcurementConfig>(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
|
|
|
private static VendorService VendorSvc(ErpDbContext db) =>
|
|
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
|
|
|
|
private static CreateVendorRequest NewVendor(string code, Action<CreateVendorRequest>? 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<int> userIds, string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task<NotificationFeedDto> 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<DomainException>(
|
|
() => 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<DomainException>(
|
|
() => 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<DomainException>(
|
|
() => 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<DomainException>(
|
|
() => 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<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V9", r => r.Terms = "NET30")));
|
|
var taxReg = await Assert.ThrowsAsync<DomainException>(() => 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<PurchaseOrder>(db), new Repository<Vendor>(db), new Repository<Requisition>(db),
|
|
new Repository<Item>(db), new Repository<Uom>(db), new Repository<Warehouse>(db),
|
|
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
|
|
ConfigService(db), new PassThroughUnitOfWork(db));
|
|
|
|
private static async Task<int> 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<DomainException>(() => 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
|
|
|
|
/// <summary>Three POs from two vendors, created on known business days; returns their ids in creation order.</summary>
|
|
private static async Task<int[]> 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<List<int>> 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
|
|
|
|
/// <summary>A GL that knows two LKR cash/bank accounts, optionally the equity account, and can create it.</summary>
|
|
private sealed class OpeningGl : IGeneralLedgerService
|
|
{
|
|
public List<GlJournalEntryRequest> Posted { get; } = new();
|
|
public int AccountCreates { get; private set; }
|
|
public bool EquityExists { get; init; } = true;
|
|
public string BankCurrency { get; init; } = "LKR";
|
|
|
|
public Task<GeneralLedgerResponse> 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<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
|
{
|
|
Posted.Add(request);
|
|
return Task.FromResult(new GlJournalEntryResult($"JV-2026-{Posted.Count:000000}", true));
|
|
}
|
|
|
|
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
|
|
|
|
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<GlBankAccount>>(
|
|
[
|
|
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<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<GlAccount>>(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<OpeningBalanceRun>(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<DomainException>(
|
|
() => 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<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("9999", 10m))));
|
|
// more than 2 decimals
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 10.005m))));
|
|
// nothing but zeros
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 0m))));
|
|
// the same account twice
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 5m), ("1400-01-01", 6m))));
|
|
// in the future
|
|
await Assert.ThrowsAsync<DomainException>(() => 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<DomainException>(
|
|
() => 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<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
|
|
return new OpeningStockService(
|
|
new Repository<OpeningBalanceRun>(db), new Repository<Item>(db), new Repository<Warehouse>(db),
|
|
new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), fifo, gl, glAccounts,
|
|
new TestCurrentUser(), new PassThroughUnitOfWork(db));
|
|
}
|
|
|
|
private static Item StockedItem(int id, Action<Item>? 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<DomainException>(() => 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<DomainException>(() => 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<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); // serial
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((2, 1m, 1m)))); // batch
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((3, 1m, 1m)))); // service
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((4, 1m, 1m)))); // inactive
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((99, 1m, 1m)))); // unknown
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 0m, 1m)))); // zero qty
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, -1m)))); // negative cost
|
|
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1.00001m, 1m)))); // > 4 decimals
|
|
await Assert.ThrowsAsync<DomainException>(() => 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<GeneralLedgerResponse> 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<DomainException>(
|
|
() => 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<DomainException>(
|
|
() => 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<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(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);
|
|
}
|
|
}
|