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,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;
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
"MaxSizeBytes": 10485760
|
||||
},
|
||||
"GeneralLedgerService": {
|
||||
"BaseUrl": "http://localhost:6704",
|
||||
"BaseUrl": "http://localhost:6704/api/v1/",
|
||||
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||
},
|
||||
"Grn": {
|
||||
|
||||
Reference in New Issue
Block a user