c41aed9a80
The per-module Gl*Code appsettings keys pinned GL's account *codes* in ERPCore's config, while GL generates codes positionally and refuses a requested one. Nothing connected the two, so "1100 means Inventory" held only because someone once created accounts in a particular order - and on 2026-10-07 eleven of the twelve configured codes turned out never to have existed in GL. Every GRN, adjustment, day-end, invoice and FIFO posting had been failing with "Account <code> not found". The mapping is now master data in gl_account_config, keyed by accounting role rather than by module, and GlAccountConfigService validates every submitted code against GL's live chart of accounts before storing it. A code GL does not have, or cannot post to, is refused with the role named - so the mismatch is unrepresentable rather than merely detected. Correcting a mapping no longer needs a redeploy. Notes: - Fails closed on save (503 if GL is unreachable), deliberately opposite to the startup validator, which must still let the app serve. - Non-postable accounts are rejected too: GL rejects a journal line against one, so storing it only defers the failure. The auto-created 1000 Bank/Cash header is exactly that trap. - The settings endpoint also serves the selectable accounts, because GeneralLedgerController is gated on `ledgers` while this is on `settings` - and it keeps the picker and the validation list the same data. - Reading an unmapped role throws naming the role, instead of posting "" and letting GL return an opaque 404 from inside a transaction. - The appsettings keys are now seed-only: DataSeeder copies them in once so existing deployments keep their current mapping. No runtime fallback, or there would be two sources of truth again. - The 32 read sites across 11 services collapsed to 12 roles, since every module carried the same value for a given role. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
9.9 KiB
C#
235 lines
9.9 KiB
C#
using ERPCore.Domain.Entities;
|
|
using ERPCore.Dtos.Config;
|
|
using ERPCore.Infra.Persistence;
|
|
using ERPCore.Repositories;
|
|
using ERPCore.Services;
|
|
using ERPCore.Services.Gl;
|
|
using ERPCore.Services.Interfaces;
|
|
using ERPCore.System.Errors;
|
|
using ERPCore.Tests.TestSupport;
|
|
|
|
namespace ERPCore.Tests;
|
|
|
|
/// <summary>
|
|
/// The point of this service is that a mapping GL cannot post to never reaches the database.
|
|
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
|
|
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
|
|
/// existed in GL at all.
|
|
/// </summary>
|
|
public class GlAccountConfigServiceTests
|
|
{
|
|
[Fact]
|
|
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var gl = new StubGeneralLedgerService([
|
|
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
|
StubGeneralLedgerService.Postable("2000", "GRNI")
|
|
]);
|
|
var service = CreateService(db, gl);
|
|
var before = (await service.GetAsync()).RowVersion;
|
|
|
|
var result = await service.UpdateAsync(
|
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
|
|
|
|
Assert.True(result.RowVersion > before);
|
|
var codes = await CreateService(db, gl).GetCodesAsync();
|
|
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
|
|
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
|
|
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
|
|
|
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
|
Assert.Equal(422, ex.StatusCode);
|
|
Assert.Contains("9999", ex.Message);
|
|
Assert.Contains("Clearing", ex.Message);
|
|
await AssertNothingStoredAsync(db);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GL rejects a journal line against a non-postable account, so storing one only defers the
|
|
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task UpdateAsync_RejectsANonPostableAccount()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService([
|
|
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
|
|
]));
|
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
|
|
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
|
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
|
|
|
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
|
Assert.Contains("not postable", ex.Message);
|
|
await AssertNothingStoredAsync(db);
|
|
}
|
|
|
|
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
|
|
[Fact]
|
|
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
|
|
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
|
|
|
|
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
|
|
await AssertNothingStoredAsync(db);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
|
|
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
|
|
|
|
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
|
Assert.Equal(503, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAsync_RejectsAStaleIfMatch()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
|
|
|
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
|
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
|
|
|
|
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
|
Assert.Equal(412, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
|
|
[Fact]
|
|
public async Task UpdateAsync_TreatsBlankAsCleared()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
|
var service = CreateService(db, gl);
|
|
var rowVersion = (await service.GetAsync()).RowVersion;
|
|
|
|
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
|
|
|
|
var codes = await CreateService(db, gl).GetCodesAsync();
|
|
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
|
|
}
|
|
|
|
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
|
|
[Fact]
|
|
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
|
|
|
var codes = await service.GetCodesAsync();
|
|
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
|
|
|
|
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
|
|
Assert.Contains("Cogs", ex.Message);
|
|
}
|
|
|
|
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
|
|
[Fact]
|
|
public async Task GetCodesAsync_ReadsOncePerScope()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService([]));
|
|
|
|
var first = await service.GetCodesAsync();
|
|
var second = await service.GetCodesAsync();
|
|
|
|
Assert.Same(first, second);
|
|
}
|
|
|
|
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
|
|
[Fact]
|
|
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
|
|
{
|
|
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
|
|
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
|
|
|
var result = await service.GetAsync();
|
|
|
|
Assert.True(result.Value.GlUnavailable);
|
|
Assert.Empty(result.Value.Options);
|
|
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
|
|
Assert.Equal("1100", inventory.AccountCode);
|
|
}
|
|
|
|
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
|
|
[Fact]
|
|
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
|
|
{
|
|
await using var db = await CreateSeededContextAsync(c =>
|
|
{
|
|
c.InventoryAccountCode = "1100";
|
|
c.CashAccountCode = "1000-01";
|
|
});
|
|
var service = CreateService(db, new StubGeneralLedgerService([
|
|
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
|
|
]));
|
|
|
|
var result = await service.GetAsync();
|
|
|
|
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
|
|
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
|
|
}
|
|
|
|
/// <summary>The picker must only offer what validation will accept.</summary>
|
|
[Fact]
|
|
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
|
|
{
|
|
await using var db = await CreateSeededContextAsync();
|
|
var service = CreateService(db, new StubGeneralLedgerService([
|
|
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
|
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
|
|
new GlAccount("1200", "Retired", 1, true, false)
|
|
]));
|
|
|
|
var options = (await service.GetAsync()).Value.Options;
|
|
|
|
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
|
|
}
|
|
|
|
private static async Task AssertNothingStoredAsync(ErpDbContext db)
|
|
{
|
|
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
|
|
Assert.Null(row!.InventoryAccountCode);
|
|
Assert.Null(row.ClearingAccountCode);
|
|
Assert.Null(row.CashAccountCode);
|
|
}
|
|
|
|
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
|
|
{
|
|
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
|
|
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
|
|
|
|
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
|
|
configure?.Invoke(config);
|
|
db.GlAccountConfig.Add(config);
|
|
await db.SaveChangesAsync();
|
|
return db;
|
|
}
|
|
|
|
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
|
|
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
|
|
}
|