From f753cae82ddce2f967858ca0189753e251e1a856 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Wed, 7 Oct 2026 11:27:12 +0530 Subject: [PATCH] Refactor GL period and entry date handling to use BusinessClock for accurate date resolution; add GlAccountCodeValidator for startup validation of configured account codes. --- .../GlAccountCodeValidatorTests.cs | 158 ++++++++++++++++++ .../Infra/Gl/GlAccountCodeValidator.cs | 89 ++++++++++ Backend/ERPCore/Program.cs | 14 +- Backend/ERPCore/Services/AdjustmentService.cs | 5 +- Backend/ERPCore/Services/CountService.cs | 5 +- .../ERPCore/Services/GeneralLedgerService.cs | 23 +++ Backend/ERPCore/Services/GrnService.cs | 5 +- .../Interfaces/IGeneralLedgerService.cs | 6 + .../Services/PurchaseInvoicePaymentService.cs | 5 +- .../Services/PurchaseInvoiceService.cs | 5 +- .../ERPCore/Services/PurchaseReturnService.cs | 5 +- .../Services/SalesInvoicePaymentService.cs | 11 +- .../ERPCore/Services/SalesPostingService.cs | 5 +- .../ERPCore/Services/SalesReturnService.cs | 5 +- .../Services/Stock/FifoCostingService.cs | 5 +- Backend/ERPCore/System/Time/BusinessClock.cs | 66 ++++++++ Backend/ERPCore/appsettings.json | 2 +- 17 files changed, 389 insertions(+), 25 deletions(-) create mode 100644 Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs create mode 100644 Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs create mode 100644 Backend/ERPCore/System/Time/BusinessClock.cs diff --git a/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs b/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs new file mode 100644 index 0000000..739d843 --- /dev/null +++ b/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs @@ -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; + +/// +/// 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). +/// +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( + () => validator.StartAsync(CancellationToken.None)); + + Assert.Contains("1000-01", ex.Message); + Assert.Contains("5100", ex.Message); + Assert.DoesNotContain("1100,", ex.Message); + } + + /// + /// 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. + /// + [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); + } + + /// Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL. + [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); + } + + /// Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally. + [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 settings, IGeneralLedgerService gl) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider(); + return new GlAccountCodeValidator(services, configuration, NullLogger.Instance); + } + + private sealed class StubGl(HashSet? codes = null, Exception? throws = null) : IGeneralLedgerService + { + private readonly HashSet _codes = codes ?? []; + private readonly Exception? _throws = throws; + + public StubGl(Exception throws) : this(null, throws) { } + + public int Calls { get; private set; } + + public Task> ListAccountCodesAsync(CancellationToken ct) + { + Calls++; + return _throws is not null ? Task.FromException>(_throws) : Task.FromResult(_codes); + } + + public Task ForwardAsync( + HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct) + => throw new NotSupportedException(); + + public Task PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct) + => throw new NotSupportedException(); + + public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct) + => throw new NotSupportedException(); + + public Task> ListBankAccountsAsync(CancellationToken ct) + => throw new NotSupportedException(); + } +} diff --git a/Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs b/Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs new file mode 100644 index 0000000..74340bf --- /dev/null +++ b/Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs @@ -0,0 +1,89 @@ +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; + +namespace ERPCore.Infra.Gl; + +/// +/// Startup guard for the GL chart of accounts. Every Gl*Code 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. +/// +/// Deliberately asymmetric about failure: a confirmed missing code is fatal (the config is +/// wrong and no amount of waiting fixes it), but an unreachable 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. +/// +/// +public sealed class GlAccountCodeValidator( + IServiceProvider services, + IConfiguration configuration, + ILogger logger) : IHostedService +{ + /// Configuration sections that carry GL account codes — see appsettings.json. + private static readonly string[] Sections = + ["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"]; + + /// Startup must not stall on a hung GL — HttpClient's own default is 100s. + 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 present; + try + { + using var scope = services.CreateScope(); + var gl = scope.ServiceProvider.GetRequiredService(); + + 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; +} diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index bb9724c..388c542 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -77,12 +77,22 @@ builder.Services.AddScoped(); // General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md) builder.Services.AddHttpClient(c => { - var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"] - ?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured."); + var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]; + 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); }); builder.Services.AddScoped(); +// 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(); + // 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`. builder.Services.AddHttpContextAccessor(); diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 3c99193..6746d9e 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; 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); 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(); if (gain > 0) { @@ -89,7 +90,7 @@ public sealed class AdjustmentService : IAdjustmentService var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "ADJUSTMENT", Reference = docNo, Narration = $"Stock adjustment {docNo}", diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 19ab126..1bb688d 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; 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); 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(); if (gain > 0) { @@ -96,7 +97,7 @@ public sealed class CountService : ICountService var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "ADJUSTMENT", Reference = docNo, Narration = $"Stock adjustment {docNo}", diff --git a/Backend/ERPCore/Services/GeneralLedgerService.cs b/Backend/ERPCore/Services/GeneralLedgerService.cs index a3e7333..a177270 100644 --- a/Backend/ERPCore/Services/GeneralLedgerService.cs +++ b/Backend/ERPCore/Services/GeneralLedgerService.cs @@ -49,6 +49,19 @@ public sealed class GeneralLedgerService : IGeneralLedgerService a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList(); } + public async Task> 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(response); + return data.Items + .Select(a => a.AccountCode) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToHashSet(StringComparer.Ordinal); + } + /// /// Parses GL's ApiResponse envelope (case-insensitively, since GL's success bodies are /// 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; } } + private sealed class GlAccountListData + { + public List Items { get; set; } = []; + } + + private sealed class GlAccountData + { + public string AccountCode { get; set; } = string.Empty; + } + private sealed class GlBankAccountData { public string AccountType { get; set; } = string.Empty; diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 1fcf1dd..00b549c 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -310,7 +311,7 @@ public sealed class GrnService : IGrnService // with it, so inventory and the ledger never diverge (user-approved "fails // atomically" behavior; residual risk if GL posts but the local commit still // 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(); decimal totalPayable = 0m; 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 { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "GRN", Reference = grn.DocNo, Narration = $"Goods received - GRN {grn.DocNo}", diff --git a/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs b/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs index 55f6024..1aba698 100644 --- a/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs +++ b/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs @@ -24,4 +24,10 @@ public interface IGeneralLedgerService /// Lists GL's cash and bank accounts (default: both types). Task> ListBankAccountsAsync(CancellationToken ct); + + /// + /// Every account code in GL's chart of accounts. Used at startup to verify the configured + /// Gl*Code settings exist — see . + /// + Task> ListAccountCodesAsync(CancellationToken ct); } diff --git a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs index cd5c1b6..2534451 100644 --- a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs @@ -7,6 +7,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -61,11 +62,11 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi 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 { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "PURCHASE_INVOICE_PAYMENT", Reference = invoice.DocNo, Narration = $"Payment against invoice {invoice.DocNo}", diff --git a/Backend/ERPCore/Services/PurchaseInvoiceService.cs b/Backend/ERPCore/Services/PurchaseInvoiceService.cs index ccb54ef..bf539f6 100644 --- a/Backend/ERPCore/Services/PurchaseInvoiceService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoiceService.cs @@ -11,6 +11,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -185,11 +186,11 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService // Reclassify the GRN clearing balance to Accounts Payable — the mirror image of // SalesInvoicePaymentService's AR credit — before committing, so a GL rejection // 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 { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "PURCHASE_INVOICE", Reference = invoice.DocNo, Narration = $"Vendor invoice recognized - {invoice.DocNo}", diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index 03c834a..28f145d 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -220,11 +221,11 @@ public sealed class PurchaseReturnService : IPurchaseReturnService var totalValue = refs.Sum(r => r.Value); 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 { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "PURCHASE-RETURN", Reference = docNo, Narration = $"Purchase return {docNo} — stock returned to vendor", diff --git a/Backend/ERPCore/Services/SalesInvoicePaymentService.cs b/Backend/ERPCore/Services/SalesInvoicePaymentService.cs index 9744328..4267629 100644 --- a/Backend/ERPCore/Services/SalesInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/SalesInvoicePaymentService.cs @@ -7,6 +7,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -220,13 +221,15 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService /// /// 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 - /// for users east of it. Today keeps the actual time of entry; a back-dated payment is stamped at - /// that day's start. + /// today — the business day (), not the UTC one, or a payment + /// taken between midnight and 05:30 local would post to the previous day and, on the 1st, to the + /// 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. /// 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; if (day > today.AddDays(1)) throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400); diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs index 904038b..6e61a3f 100644 --- a/Backend/ERPCore/Services/SalesPostingService.cs +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -9,6 +9,7 @@ using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.Services.Stock; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -184,7 +185,7 @@ public sealed class SalesPostingService : ISalesPostingService if (invoice.NetPayable <= 0 && cogs <= 0) return; 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(); if (invoice.NetPayable > 0) { @@ -204,7 +205,7 @@ public sealed class SalesPostingService : ISalesPostingService var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "SALES_INVOICE", Reference = invoice.InvoiceNo, Narration = $"Sales invoice {invoice.InvoiceNo} posted", diff --git a/Backend/ERPCore/Services/SalesReturnService.cs b/Backend/ERPCore/Services/SalesReturnService.cs index 890af7e..7515939 100644 --- a/Backend/ERPCore/Services/SalesReturnService.cs +++ b/Backend/ERPCore/Services/SalesReturnService.cs @@ -9,6 +9,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services; @@ -179,11 +180,11 @@ public sealed class SalesReturnService : ISalesReturnService var totalValue = refs.Sum(r => r.Value); 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 { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(now), + EntryDate = BusinessClock.ToBusinessDate(now), SourceModule = "SALES-RETURN", Reference = docNo, Narration = $"Sales return {docNo} — stock restocked", diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs index 4e2ecda..1545522 100644 --- a/Backend/ERPCore/Services/Stock/FifoCostingService.cs +++ b/Backend/ERPCore/Services/Stock/FifoCostingService.cs @@ -6,6 +6,7 @@ using ERPCore.Repositories.Interfaces; using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.System.Errors; +using ERPCore.System.Time; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services.Stock; @@ -289,11 +290,11 @@ public sealed class FifoCostingService : IFifoCostingService 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 { PeriodId = period.PeriodId, - EntryDate = DateOnly.FromDateTime(createdAt), + EntryDate = BusinessClock.ToBusinessDate(createdAt), SourceModule = "STOCK", Reference = $"{sourceDocType}#{sourceDocId}", Narration = $"{sourceDocType} {sourceDocId} — {direction} {qtyBase} @ {unitCost}", diff --git a/Backend/ERPCore/System/Time/BusinessClock.cs b/Backend/ERPCore/System/Time/BusinessClock.cs new file mode 100644 index 0000000..21db664 --- /dev/null +++ b/Backend/ERPCore/System/Time/BusinessClock.cs @@ -0,0 +1,66 @@ +namespace ERPCore.System.Time; + +/// +/// The company's local wall clock (Asia/Colombo, UTC+05:30) — the single source of truth for +/// which business day a posting belongs to. +/// +/// Every GL journal entry carries an EntryDate and a PeriodId resolved from a +/// calendar date. Deriving that date from 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 accounting period. GL validates only that the period +/// exists and is unlocked, not that EntryDate falls inside it, so that mismatch is +/// accepted silently. +/// +/// +/// This type is only about which day a posting belongs to. Persisted timestamps +/// (CreatedAt, PaymentDate, ClosedAt, …) stay in UTC — MariaDB/Npgsql +/// comparisons and every existing row depend on that. +/// +/// +public static class BusinessClock +{ + /// Sri Lanka has observed no DST since 2006, so a fixed +05:30 offset is a faithful fallback. + private static readonly TimeSpan ColomboOffset = TimeSpan.FromMinutes(330); + + /// + /// Resolved once, tolerantly: slim Linux container images frequently ship without `tzdata`, + /// and a throwing static initialiser would surface as a TypeInitializationException on + /// the first posting rather than at boot. The custom zone keeps behaviour identical. + /// + 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"); + } + + /// Current local wall-clock time ( — it is not a UTC instant). + public static DateTime Now => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Zone); + + /// Today's business day. + public static DateOnly Today => DateOnly.FromDateTime(Now); + + /// + /// The business day a UTC instant falls on. Prefer this over when a + /// flow already captured a now/createdAt: it keeps the period lookup and the + /// journal's EntryDate derived from one instant, so the two cannot straddle midnight. + /// + 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)); + } +} diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index 8a63040..b5c8f51 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -24,7 +24,7 @@ "MaxSizeBytes": 10485760 }, "GeneralLedgerService": { - "BaseUrl": "http://localhost:6704", + "BaseUrl": "http://localhost:6704/api/v1/", "ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D" }, "Grn": {