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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user