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:
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user