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,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));
}
}