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:
@@ -0,0 +1,158 @@
|
|||||||
|
using ERPCore.Infra.Gl;
|
||||||
|
using ERPCore.Services.Gl;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
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).
|
||||||
|
/// </summary>
|
||||||
|
public class GlAccountCodeValidatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
|
||||||
|
{
|
||||||
|
var validator = CreateValidator(
|
||||||
|
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
||||||
|
new StubGl(["1100", "2000", "4000"]));
|
||||||
|
|
||||||
|
await validator.StartAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_Throws_AndNamesEveryMissingCode()
|
||||||
|
{
|
||||||
|
var validator = CreateValidator(
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
["Grn:GlInventoryAccountCode"] = "1100",
|
||||||
|
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
||||||
|
["Adjustment:GlLossAccountCode"] = "5100"
|
||||||
|
},
|
||||||
|
new StubGl(["1100"]));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => validator.StartAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Contains("1000-01", ex.Message);
|
||||||
|
Assert.Contains("5100", 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.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
|
||||||
|
{
|
||||||
|
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)));
|
||||||
|
|
||||||
|
await validator.StartAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
|
||||||
|
{
|
||||||
|
var validator = CreateValidator(
|
||||||
|
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
||||||
|
new StubGl(new OperationCanceledException()));
|
||||||
|
|
||||||
|
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>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
||||||
|
{
|
||||||
|
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 20, 0, 0, DateTimeKind.Utc)));
|
||||||
|
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 0, 0, DateTimeKind.Utc)));
|
||||||
|
Assert.Equal(new DateOnly(2026, 10, 31), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 29, 59, DateTimeKind.Utc)));
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||||
|
var services = new ServiceCollection().AddScoped(_ => gl).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,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;
|
||||||
|
}
|
||||||
@@ -77,12 +77,22 @@ builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
|||||||
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
||||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
||||||
{
|
{
|
||||||
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
|
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"];
|
||||||
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
||||||
|
|
||||||
|
// GeneralLedgerClient builds *relative* URIs ("journal-entries", "accounts", …), so the base
|
||||||
|
// address must keep its full path — including /api/v1/ — and end in '/'. Without the trailing
|
||||||
|
// slash, Uri resolution replaces the last segment and every call lands on /api/journal-entries.
|
||||||
|
if (!baseUrl.EndsWith('/')) baseUrl += "/";
|
||||||
c.BaseAddress = new Uri(baseUrl);
|
c.BaseAddress = new Uri(baseUrl);
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
||||||
|
|
||||||
|
// Fail fast on a misconfigured chart of accounts: every Gl*Code in configuration must exist in GL,
|
||||||
|
// otherwise the first user to touch that flow eats a 404 mid-transaction.
|
||||||
|
builder.Services.AddHostedService<GlAccountCodeValidator>();
|
||||||
|
|
||||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||||
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -73,7 +74,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||||
if (gain <= 0 && loss <= 0) return null;
|
if (gain <= 0 && loss <= 0) return null;
|
||||||
|
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||||
var lines = new List<GlJournalEntryLineRequest>();
|
var lines = new List<GlJournalEntryLineRequest>();
|
||||||
if (gain > 0)
|
if (gain > 0)
|
||||||
{
|
{
|
||||||
@@ -89,7 +90,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
|||||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "ADJUSTMENT",
|
SourceModule = "ADJUSTMENT",
|
||||||
Reference = docNo,
|
Reference = docNo,
|
||||||
Narration = $"Stock adjustment {docNo}",
|
Narration = $"Stock adjustment {docNo}",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -80,7 +81,7 @@ public sealed class CountService : ICountService
|
|||||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||||
if (gain <= 0 && loss <= 0) return null;
|
if (gain <= 0 && loss <= 0) return null;
|
||||||
|
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||||
var lines = new List<GlJournalEntryLineRequest>();
|
var lines = new List<GlJournalEntryLineRequest>();
|
||||||
if (gain > 0)
|
if (gain > 0)
|
||||||
{
|
{
|
||||||
@@ -96,7 +97,7 @@ public sealed class CountService : ICountService
|
|||||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "ADJUSTMENT",
|
SourceModule = "ADJUSTMENT",
|
||||||
Reference = docNo,
|
Reference = docNo,
|
||||||
Narration = $"Stock adjustment {docNo}",
|
Narration = $"Stock adjustment {docNo}",
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
|||||||
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// No page/pageSize: GL pages this endpoint only when both are supplied, so this is the
|
||||||
|
// full, flat chart of accounts (child codes like "1000-01" are rows of their own —
|
||||||
|
// `parentAccountId` is a field, not a nested `children` array).
|
||||||
|
var response = await _client.SendAsync(HttpMethod.Get, "accounts", null, null, null, ct);
|
||||||
|
var data = ParseSuccess<GlAccountListData>(response);
|
||||||
|
return data.Items
|
||||||
|
.Select(a => a.AccountCode)
|
||||||
|
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses GL's <c>ApiResponse</c> envelope (case-insensitively, since GL's success bodies are
|
/// Parses GL's <c>ApiResponse</c> envelope (case-insensitively, since GL's success bodies are
|
||||||
/// camelCase and its error bodies are PascalCase — docs/12 §4) and throws
|
/// camelCase and its error bodies are PascalCase — docs/12 §4) and throws
|
||||||
@@ -96,6 +109,16 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
|||||||
public int FiscalYearId { get; set; }
|
public int FiscalYearId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class GlAccountListData
|
||||||
|
{
|
||||||
|
public List<GlAccountData> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GlAccountData
|
||||||
|
{
|
||||||
|
public string AccountCode { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class GlBankAccountData
|
private sealed class GlBankAccountData
|
||||||
{
|
{
|
||||||
public string AccountType { get; set; } = string.Empty;
|
public string AccountType { get; set; } = string.Empty;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -310,7 +311,7 @@ public sealed class GrnService : IGrnService
|
|||||||
// with it, so inventory and the ledger never diverge (user-approved "fails
|
// with it, so inventory and the ledger never diverge (user-approved "fails
|
||||||
// atomically" behavior; residual risk if GL posts but the local commit still
|
// atomically" behavior; residual risk if GL posts but the local commit still
|
||||||
// fails afterward is accepted for this phase, see the integration plan).
|
// fails afterward is accepted for this phase, see the integration plan).
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var glLines = new List<GlJournalEntryLineRequest>();
|
var glLines = new List<GlJournalEntryLineRequest>();
|
||||||
decimal totalPayable = 0m;
|
decimal totalPayable = 0m;
|
||||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||||
@@ -325,7 +326,7 @@ public sealed class GrnService : IGrnService
|
|||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "GRN",
|
SourceModule = "GRN",
|
||||||
Reference = grn.DocNo,
|
Reference = grn.DocNo,
|
||||||
Narration = $"Goods received - GRN {grn.DocNo}",
|
Narration = $"Goods received - GRN {grn.DocNo}",
|
||||||
|
|||||||
@@ -24,4 +24,10 @@ public interface IGeneralLedgerService
|
|||||||
|
|
||||||
/// <summary>Lists GL's cash and bank accounts (default: both types).</summary>
|
/// <summary>Lists GL's cash and bank accounts (default: both types).</summary>
|
||||||
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
|
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every account code in GL's chart of accounts. Used at startup to verify the configured
|
||||||
|
/// <c>Gl*Code</c> settings exist — see <see cref="ERPCore.Infra.Gl.GlAccountCodeValidator"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -61,11 +62,11 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
|||||||
|
|
||||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||||
{
|
{
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "PURCHASE_INVOICE_PAYMENT",
|
SourceModule = "PURCHASE_INVOICE_PAYMENT",
|
||||||
Reference = invoice.DocNo,
|
Reference = invoice.DocNo,
|
||||||
Narration = $"Payment against invoice {invoice.DocNo}",
|
Narration = $"Payment against invoice {invoice.DocNo}",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -185,11 +186,11 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
|||||||
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
|
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
|
||||||
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
|
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
|
||||||
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
|
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "PURCHASE_INVOICE",
|
SourceModule = "PURCHASE_INVOICE",
|
||||||
Reference = invoice.DocNo,
|
Reference = invoice.DocNo,
|
||||||
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
|
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -220,11 +221,11 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
|||||||
var totalValue = refs.Sum(r => r.Value);
|
var totalValue = refs.Sum(r => r.Value);
|
||||||
if (totalValue > 0)
|
if (totalValue > 0)
|
||||||
{
|
{
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "PURCHASE-RETURN",
|
SourceModule = "PURCHASE-RETURN",
|
||||||
Reference = docNo,
|
Reference = docNo,
|
||||||
Narration = $"Purchase return {docNo} — stock returned to vendor",
|
Narration = $"Purchase return {docNo} — stock returned to vendor",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -220,13 +221,15 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
|
/// The payment's calendar day (also the GL entry date) and the timestamp stored on it. Omitted ⇒
|
||||||
/// today. One day of slack on "future": the client sends its local date, which runs ahead of UTC
|
/// today — the <em>business</em> day (<see cref="BusinessClock"/>), not the UTC one, or a payment
|
||||||
/// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at
|
/// taken between midnight and 05:30 local would post to the previous day and, on the 1st, to the
|
||||||
/// that day's start.
|
/// previous accounting period. The day of slack on "future" is kept as a tolerance for clients
|
||||||
|
/// whose own clock runs ahead. Today keeps the actual time of entry; a back-dated payment is
|
||||||
|
/// stamped at that day's start. The returned stamp stays UTC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now)
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(now);
|
var today = BusinessClock.ToBusinessDate(now);
|
||||||
var day = requested ?? today;
|
var day = requested ?? today;
|
||||||
if (day > today.AddDays(1))
|
if (day > today.AddDays(1))
|
||||||
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ERPCore.Services.Gl;
|
|||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.Services.Stock;
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -184,7 +185,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
if (invoice.NetPayable <= 0 && cogs <= 0) return;
|
if (invoice.NetPayable <= 0 && cogs <= 0) return;
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), ct);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||||
var lines = new List<GlJournalEntryLineRequest>();
|
var lines = new List<GlJournalEntryLineRequest>();
|
||||||
if (invoice.NetPayable > 0)
|
if (invoice.NetPayable > 0)
|
||||||
{
|
{
|
||||||
@@ -204,7 +205,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "SALES_INVOICE",
|
SourceModule = "SALES_INVOICE",
|
||||||
Reference = invoice.InvoiceNo,
|
Reference = invoice.InvoiceNo,
|
||||||
Narration = $"Sales invoice {invoice.InvoiceNo} posted",
|
Narration = $"Sales invoice {invoice.InvoiceNo} posted",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services;
|
namespace ERPCore.Services;
|
||||||
@@ -179,11 +180,11 @@ public sealed class SalesReturnService : ISalesReturnService
|
|||||||
var totalValue = refs.Sum(r => r.Value);
|
var totalValue = refs.Sum(r => r.Value);
|
||||||
if (totalValue > 0)
|
if (totalValue > 0)
|
||||||
{
|
{
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(now),
|
EntryDate = BusinessClock.ToBusinessDate(now),
|
||||||
SourceModule = "SALES-RETURN",
|
SourceModule = "SALES-RETURN",
|
||||||
Reference = docNo,
|
Reference = docNo,
|
||||||
Narration = $"Sales return {docNo} — stock restocked",
|
Narration = $"Sales return {docNo} — stock restocked",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using ERPCore.Repositories.Interfaces;
|
|||||||
using ERPCore.Services.Gl;
|
using ERPCore.Services.Gl;
|
||||||
using ERPCore.Services.Interfaces;
|
using ERPCore.Services.Interfaces;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
|
using ERPCore.System.Time;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ERPCore.Services.Stock;
|
namespace ERPCore.Services.Stock;
|
||||||
@@ -289,11 +290,11 @@ public sealed class FifoCostingService : IFifoCostingService
|
|||||||
|
|
||||||
if (entry.Value != 0)
|
if (entry.Value != 0)
|
||||||
{
|
{
|
||||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(createdAt), ct);
|
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(createdAt), ct);
|
||||||
await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||||
{
|
{
|
||||||
PeriodId = period.PeriodId,
|
PeriodId = period.PeriodId,
|
||||||
EntryDate = DateOnly.FromDateTime(createdAt),
|
EntryDate = BusinessClock.ToBusinessDate(createdAt),
|
||||||
SourceModule = "STOCK",
|
SourceModule = "STOCK",
|
||||||
Reference = $"{sourceDocType}#{sourceDocId}",
|
Reference = $"{sourceDocType}#{sourceDocId}",
|
||||||
Narration = $"{sourceDocType} {sourceDocId} — {direction} {qtyBase} @ {unitCost}",
|
Narration = $"{sourceDocType} {sourceDocId} — {direction} {qtyBase} @ {unitCost}",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
namespace ERPCore.System.Time;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The company's local wall clock (Asia/Colombo, UTC+05:30) — the single source of truth for
|
||||||
|
/// which <em>business day</em> a posting belongs to.
|
||||||
|
/// <para>
|
||||||
|
/// Every GL journal entry carries an <c>EntryDate</c> and a <c>PeriodId</c> resolved from a
|
||||||
|
/// calendar date. Deriving that date from <see cref="DateTime.UtcNow"/> is wrong east of
|
||||||
|
/// Greenwich: between 00:00 and 05:30 local, UTC is still on the previous day, so a payment
|
||||||
|
/// made just after midnight posts to the previous business day — and just after midnight on
|
||||||
|
/// the 1st, to the previous <em>accounting period</em>. GL validates only that the period
|
||||||
|
/// exists and is unlocked, not that <c>EntryDate</c> falls inside it, so that mismatch is
|
||||||
|
/// accepted silently.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This type is only about <em>which day a posting belongs to</em>. Persisted timestamps
|
||||||
|
/// (<c>CreatedAt</c>, <c>PaymentDate</c>, <c>ClosedAt</c>, …) stay in UTC — MariaDB/Npgsql
|
||||||
|
/// comparisons and every existing row depend on that.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class BusinessClock
|
||||||
|
{
|
||||||
|
/// <summary>Sri Lanka has observed no DST since 2006, so a fixed +05:30 offset is a faithful fallback.</summary>
|
||||||
|
private static readonly TimeSpan ColomboOffset = TimeSpan.FromMinutes(330);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolved once, tolerantly: slim Linux container images frequently ship without `tzdata`,
|
||||||
|
/// and a throwing static initialiser would surface as a <c>TypeInitializationException</c> on
|
||||||
|
/// the first posting rather than at boot. The custom zone keeps behaviour identical.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeZoneInfo Zone = ResolveZone();
|
||||||
|
|
||||||
|
private static TimeZoneInfo ResolveZone()
|
||||||
|
{
|
||||||
|
foreach (var id in new[] { "Asia/Colombo", "Sri Lanka Standard Time" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
// Try the next id, then fall through to the fixed-offset zone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeZoneInfo.CreateCustomTimeZone("Asia/Colombo (fixed)", ColomboOffset, "Sri Lanka Time", "Sri Lanka Time");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current local wall-clock time (<see cref="DateTimeKind.Unspecified"/> — it is not a UTC instant).</summary>
|
||||||
|
public static DateTime Now => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Zone);
|
||||||
|
|
||||||
|
/// <summary>Today's business day.</summary>
|
||||||
|
public static DateOnly Today => DateOnly.FromDateTime(Now);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The business day a UTC instant falls on. Prefer this over <see cref="Today"/> when a
|
||||||
|
/// flow already captured a <c>now</c>/<c>createdAt</c>: it keeps the period lookup and the
|
||||||
|
/// journal's <c>EntryDate</c> derived from one instant, so the two cannot straddle midnight.
|
||||||
|
/// </summary>
|
||||||
|
public static DateOnly ToBusinessDate(DateTime utc)
|
||||||
|
{
|
||||||
|
var asUtc = utc.Kind == DateTimeKind.Utc ? utc : DateTime.SpecifyKind(utc, DateTimeKind.Utc);
|
||||||
|
return DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(asUtc, Zone));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"MaxSizeBytes": 10485760
|
"MaxSizeBytes": 10485760
|
||||||
},
|
},
|
||||||
"GeneralLedgerService": {
|
"GeneralLedgerService": {
|
||||||
"BaseUrl": "http://localhost:6704",
|
"BaseUrl": "http://localhost:6704/api/v1/",
|
||||||
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||||
},
|
},
|
||||||
"Grn": {
|
"Grn": {
|
||||||
|
|||||||
Reference in New Issue
Block a user