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

This commit is contained in:
2026-10-07 11:27:12 +05:30
parent ea8c8210e5
commit f753cae82d
17 changed files with 389 additions and 25 deletions
@@ -0,0 +1,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;
}
+12 -2
View File
@@ -77,12 +77,22 @@ builder.Services.AddScoped<IAuthAltService, AuthAltService>();
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(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<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
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
builder.Services.AddHttpContextAccessor();
@@ -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<GlJournalEntryLineRequest>();
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}",
+3 -2
View File
@@ -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<GlJournalEntryLineRequest>();
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}",
@@ -49,6 +49,19 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
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>
/// 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
@@ -96,6 +109,16 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
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
{
public string AccountType { get; set; } = string.Empty;
+3 -2
View File
@@ -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<GlJournalEntryLineRequest>();
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}",
@@ -24,4 +24,10 @@ public interface IGeneralLedgerService
/// <summary>Lists GL's cash and bank accounts (default: both types).</summary>
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.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}",
@@ -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}",
@@ -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",
@@ -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
/// <summary>
/// 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 <em>business</em> day (<see cref="BusinessClock"/>), 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.
/// </summary>
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);
@@ -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<GlJournalEntryLineRequest>();
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",
@@ -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",
@@ -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}",
@@ -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));
}
}
+1 -1
View File
@@ -24,7 +24,7 @@
"MaxSizeBytes": 10485760
},
"GeneralLedgerService": {
"BaseUrl": "http://localhost:6704",
"BaseUrl": "http://localhost:6704/api/v1/",
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
},
"Grn": {