Refactor GL period and entry date handling to use BusinessClock for accurate date resolution; add GlAccountCodeValidator for startup validation of configured account codes.

This commit is contained in:
2026-10-07 11:27:12 +05:30
parent ea8c8210e5
commit f753cae82d
17 changed files with 389 additions and 25 deletions
@@ -0,0 +1,89 @@
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Infra.Gl;
/// <summary>
/// Startup guard for the GL chart of accounts. Every <c>Gl*Code</c> in configuration names an
/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced
/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one
/// discovery, repeat. This asserts the whole set once, at boot.
/// <para>
/// Deliberately asymmetric about failure: a <em>confirmed missing</em> code is fatal (the config is
/// wrong and no amount of waiting fixes it), but an <em>unreachable</em> GL only logs a warning.
/// GL runs on the same host as ERPCore, so on a host reboot it may simply not be listening yet —
/// hard-failing there would turn a transient ordering issue into a deploy crash-loop.
/// </para>
/// </summary>
public sealed class GlAccountCodeValidator(
IServiceProvider services,
IConfiguration configuration,
ILogger<GlAccountCodeValidator> logger) : IHostedService
{
/// <summary>Configuration sections that carry GL account codes — see appsettings.json.</summary>
private static readonly string[] Sections =
["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"];
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
public async Task StartAsync(CancellationToken ct)
{
var expected = Sections
.SelectMany(s => configuration.GetSection(s).GetChildren())
.Where(c => c.Key.StartsWith("Gl", StringComparison.Ordinal) && c.Key.EndsWith("Code", StringComparison.Ordinal))
.Select(c => c.Value)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v!)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (expected.Length == 0)
{
logger.LogWarning("No GL account codes are configured — skipping chart-of-accounts verification.");
return;
}
HashSet<string> present;
try
{
using var scope = services.CreateScope();
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
present = await gl.ListAccountCodesAsync(timeout.Token);
}
catch (Exception e) when (e is DomainException or OperationCanceledException && !ct.IsCancellationRequested)
{
// Unreachable, timed out, or rejected the read — can't verify, so don't block startup.
// The per-flow 404 remains the backstop it has always been.
logger.LogWarning(e,
"Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.",
expected.Length);
return;
}
// A GL that answers 2xx but yields no codes at all is far more likely a response shape this
// reader doesn't understand than a genuinely empty chart of accounts — and treating it as
// "everything is missing" would take the whole app down on a contract change. Warn, don't fail.
if (present.Count == 0)
{
logger.LogWarning(
"GL returned no account codes; {Count} configured account code(s) left unverified. " +
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
expected.Length);
return;
}
var missing = expected.Where(c => !present.Contains(c)).ToArray();
if (missing.Length > 0)
throw new InvalidOperationException(
$"General Ledger is missing {missing.Length} configured account code(s): {string.Join(", ", missing)}. " +
"Seed them in GL's chart of accounts, or correct the Gl*Code settings in configuration.");
logger.LogInformation("GL account codes verified: {Count} present.", expected.Length);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}