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": {