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:
2026-10-07 13:04:49 +05:30
parent 614df3065d
commit c41aed9a80
25 changed files with 10003 additions and 256 deletions
@@ -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();
}