Move the GL account mapping out of appsettings into validated master data
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>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
|
||||
/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
|
||||
/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
|
||||
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
|
||||
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
|
||||
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
|
||||
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
|
||||
/// </summary>
|
||||
public class GlAccountCodeValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
|
||||
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
||||
new StubGl(["1100", "2000", "4000"]));
|
||||
new(),
|
||||
new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1100"),
|
||||
StubGeneralLedgerService.Postable("2000")
|
||||
]),
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The default must not block startup. Throwing here cost an outage on 2026-10-07 — a config
|
||||
/// mismatch that had existed all along became a core-dump the moment the check shipped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault()
|
||||
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new()
|
||||
{
|
||||
["Grn:GlInventoryAccountCode"] = "1100",
|
||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
||||
["Adjustment:GlLossAccountCode"] = "5100"
|
||||
},
|
||||
new StubGl(["1000-01"]));
|
||||
new(),
|
||||
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
|
||||
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new()
|
||||
{
|
||||
["GeneralLedgerService:FailOnMissingAccountCodes"] = "true",
|
||||
["Grn:GlInventoryAccountCode"] = "1100",
|
||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
||||
["Adjustment:GlLossAccountCode"] = "5100"
|
||||
},
|
||||
new StubGl(["1100"]));
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
|
||||
(GlAccountRole.Inventory, "1100"),
|
||||
(GlAccountRole.Cash, "1000-01"),
|
||||
(GlAccountRole.InventoryLoss, "5100"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
|
||||
Assert.Contains("1000-01", ex.Message);
|
||||
Assert.Contains("5100", ex.Message);
|
||||
Assert.Contains("2 of 3", ex.Message);
|
||||
Assert.DoesNotContain("1100,", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
|
||||
/// validator would call every configured code missing and refuse to start.
|
||||
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
|
||||
/// "everything is missing" would refuse to start on a contract change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
|
||||
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
||||
new StubGl([]));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
||||
new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService([]),
|
||||
(GlAccountRole.Inventory, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The earlier catch filter only matched DomainException/OperationCanceledException, so anything
|
||||
/// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host.
|
||||
/// A diagnostic must not be able to crash the thing it is diagnosing.
|
||||
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
|
||||
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
|
||||
/// mapping — escaped a hosted service and aborted the host.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(UnexpectedFailures))]
|
||||
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
||||
new StubGl(failure));
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService(failure),
|
||||
(GlAccountRole.Inventory, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public static TheoryData<Exception> UnexpectedFailures() =>
|
||||
[
|
||||
StubGeneralLedgerService.Unreachable(),
|
||||
new NullReferenceException("items was null"),
|
||||
new global::System.Text.Json.JsonException("unexpected shape"),
|
||||
new InvalidOperationException("something else entirely"),
|
||||
new HttpRequestException("connection reset"),
|
||||
new OperationCanceledException(),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
|
||||
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
||||
new StubGl(new OperationCanceledException()));
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.</summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
|
||||
{
|
||||
var gl = new StubGl(["1100"]);
|
||||
var validator = CreateValidator(
|
||||
new()
|
||||
{
|
||||
["Grn:GlInventoryAccountCode"] = "1100",
|
||||
["Grn:GlPostingEnabled"] = "true",
|
||||
["Grn:SomeOtherCode"] = "not-an-account"
|
||||
},
|
||||
gl);
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, gl.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
|
||||
{
|
||||
var gl = new StubGl(["1100"]);
|
||||
var validator = CreateValidator(new(), gl);
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, gl.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
|
||||
{
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
|
||||
var validator = CreateValidator(
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
gl,
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
Assert.Equal(1, gl.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
||||
[Fact]
|
||||
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
||||
@@ -165,39 +138,14 @@ public class GlAccountCodeValidatorTests
|
||||
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
private static GlAccountCodeValidator CreateValidator(Dictionary<string, string?> settings, IGeneralLedgerService gl)
|
||||
private static GlAccountCodeValidator CreateValidator(
|
||||
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
|
||||
var services = new ServiceCollection()
|
||||
.AddScoped<IGeneralLedgerService>(_ => gl)
|
||||
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
|
||||
.BuildServiceProvider();
|
||||
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
|
||||
}
|
||||
|
||||
private sealed class StubGl(HashSet<string>? codes = null, Exception? throws = null) : IGeneralLedgerService
|
||||
{
|
||||
private readonly HashSet<string> _codes = codes ?? [];
|
||||
private readonly Exception? _throws = throws;
|
||||
|
||||
public StubGl(Exception throws) : this(null, throws) { }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return _throws is not null ? Task.FromException<HashSet<string>>(_throws) : Task.FromResult(_codes);
|
||||
}
|
||||
|
||||
public Task<ERPCore.Infra.Gl.GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <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));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
|
||||
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
|
||||
/// doing so fails loudly rather than against a silently empty fake.
|
||||
/// </summary>
|
||||
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private readonly IReadOnlyList<GlAccount> _accounts;
|
||||
private readonly Exception? _throws;
|
||||
|
||||
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
|
||||
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>A postable, active account — the ordinary case.</summary>
|
||||
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
|
||||
|
||||
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
|
||||
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
|
||||
|
||||
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return _throws is not null
|
||||
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
|
||||
: Task.FromResult(_accounts);
|
||||
}
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public static DomainException Unreachable() =>
|
||||
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||
}
|
||||
|
||||
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
|
||||
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
|
||||
{
|
||||
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
|
||||
|
||||
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
|
||||
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
Reference in New Issue
Block a user