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
@@ -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}",