Move the GL account mapping out of appsettings into validated master data

The per-module Gl*Code appsettings keys pinned GL's account *codes* in
ERPCore's config, while GL generates codes positionally and refuses a
requested one. Nothing connected the two, so "1100 means Inventory" held
only because someone once created accounts in a particular order - and on
2026-10-07 eleven of the twelve configured codes turned out never to have
existed in GL. Every GRN, adjustment, day-end, invoice and FIFO posting had
been failing with "Account <code> not found".

The mapping is now master data in gl_account_config, keyed by accounting
role rather than by module, and GlAccountConfigService validates every
submitted code against GL's live chart of accounts before storing it. A
code GL does not have, or cannot post to, is refused with the role named -
so the mismatch is unrepresentable rather than merely detected. Correcting
a mapping no longer needs a redeploy.

Notes:
- Fails closed on save (503 if GL is unreachable), deliberately opposite to
  the startup validator, which must still let the app serve.
- Non-postable accounts are rejected too: GL rejects a journal line against
  one, so storing it only defers the failure. The auto-created 1000
  Bank/Cash header is exactly that trap.
- The settings endpoint also serves the selectable accounts, because
  GeneralLedgerController is gated on `ledgers` while this is on `settings`
  - and it keeps the picker and the validation list the same data.
- Reading an unmapped role throws naming the role, instead of posting ""
  and letting GL return an opaque 404 from inside a transaction.
- The appsettings keys are now seed-only: DataSeeder copies them in once so
  existing deployments keep their current mapping. No runtime fallback, or
  there would be two sources of truth again.
- The 32 read sites across 11 services collapsed to 12 roles, since every
  module carried the same value for a given role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:04:49 +05:30
parent 614df3065d
commit c41aed9a80
25 changed files with 10003 additions and 256 deletions
+8 -11
View File
@@ -37,15 +37,13 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glGainAccountCode;
private readonly string _glLossAccountCode;
private readonly IGlAccountConfigService _glAccounts;
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_adjustments = adjustments;
_warehouses = warehouses;
@@ -59,9 +57,7 @@ public sealed class AdjustmentService : IAdjustmentService
_notifications = notifications;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
@@ -74,17 +70,18 @@ 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 glCodes = await _glAccounts.GetCodesAsync(ct);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
var lines = new List<GlJournalEntryLineRequest>();
if (gain > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
}
if (loss > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
}
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
+8 -11
View File
@@ -41,16 +41,14 @@ public sealed class CountService : ICountService
private readonly IRepository<StockLayer> _stockLayers;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly string _glInventoryAccountCode;
private readonly string _glGainAccountCode;
private readonly string _glLossAccountCode;
private readonly IGlAccountConfigService _glAccounts;
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, IConfiguration configuration)
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
{
_counts = counts;
_adjustments = adjustments;
@@ -67,9 +65,7 @@ public sealed class CountService : ICountService
_stockLayers = stockLayers;
_batches = batches;
_serials = serials;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
/// <summary>Same logic as <c>AdjustmentService</c>'s private method of the same name — see there for why
@@ -81,17 +77,18 @@ 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 glCodes = await _glAccounts.GetCodesAsync(ct);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
var lines = new List<GlJournalEntryLineRequest>();
if (gain > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
}
if (loss > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
}
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
@@ -49,7 +49,7 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
}
public async Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
public async Task<IReadOnlyList<GlAccount>> ListAccountsAsync(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 —
@@ -59,8 +59,8 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
// `"items": null` deserializes over the property initialiser, so the null check is load-bearing.
return (data.Items ?? [])
.Where(a => a is not null && !string.IsNullOrWhiteSpace(a.AccountCode))
.Select(a => a.AccountCode)
.ToHashSet(StringComparer.Ordinal);
.Select(a => new GlAccount(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive))
.ToList();
}
/// <summary>
@@ -118,6 +118,10 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
private sealed class GlAccountData
{
public string AccountCode { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public short AccountTypeId { get; set; }
public bool IsPostable { get; set; }
public bool IsActive { get; set; }
}
private sealed class GlBankAccountData
@@ -0,0 +1,195 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <inheritdoc cref="IGlAccountConfigService"/>
public sealed class GlAccountConfigService : IGlAccountConfigService
{
private readonly IRepository<GlAccountConfig> _config;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
/// <summary>Scope-lifetime cache — see <see cref="IGlAccountConfigService.GetCodesAsync"/>.</summary>
private GlAccountCodes? _cached;
public GlAccountConfigService(
IRepository<GlAccountConfig> config, IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)
?? throw new NotFoundException("GL account configuration has not been seeded.");
// Read-only screen data: GL being down must not stop the saved mapping from rendering.
IReadOnlyList<GlAccount> accounts = [];
var glUnavailable = false;
try
{
accounts = await _gl.ListAccountsAsync(ct);
}
catch (DomainException)
{
glUnavailable = true;
}
return new ETagged<GlAccountConfigDto>(Map(config, accounts, glUnavailable), config.RowVersion);
}
public async Task<ETagged<GlAccountConfigDto>> UpdateAsync(
UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(GlAccountConfig.SingletonId, ct)
?? throw new NotFoundException("GL account configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
var submitted = Submitted(request);
// Fails closed, unlike GlAccountCodeValidator at startup: there we cannot verify and must
// still serve, here we cannot verify and must not store. Letting an unchecked mapping
// through is how the appsettings version went unnoticed for months.
var accounts = await _gl.ListAccountsAsync(ct);
if (accounts.Count == 0)
throw new DomainException(
ErrorCodes.GlAccountInvalid,
"GL returned an empty chart of accounts, so the mapping cannot be verified. Try again once GL has accounts.",
503);
var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal);
var problems = new List<string>();
foreach (var (role, code) in submitted)
{
if (code is null) continue;
if (!byCode.TryGetValue(code, out var account))
problems.Add($"{role}: account '{code}' does not exist in GL");
else if (!account.IsPostable)
// GL rejects a journal line against a non-postable account, so storing one just
// moves the failure to posting time. Control/header accounts are the usual trap.
problems.Add($"{role}: account '{code}' ({account.AccountName}) is not postable");
}
if (problems.Count > 0)
throw new DomainException(
ErrorCodes.GlAccountInvalid,
"The GL account mapping was rejected — " + string.Join("; ", problems) + ".",
422);
config.InventoryAccountCode = submitted[GlAccountRole.Inventory];
config.VatRecoverableAccountCode = submitted[GlAccountRole.VatRecoverable];
config.AccountsReceivableCode = submitted[GlAccountRole.AccountsReceivable];
config.ClearingAccountCode = submitted[GlAccountRole.Clearing];
config.TaxPayableAccountCode = submitted[GlAccountRole.TaxPayable];
config.AccountsPayableCode = submitted[GlAccountRole.AccountsPayable];
config.SalesRevenueAccountCode = submitted[GlAccountRole.SalesRevenue];
config.SalesDiscountAccountCode = submitted[GlAccountRole.SalesDiscount];
config.InventoryGainAccountCode = submitted[GlAccountRole.InventoryGain];
config.CogsAccountCode = submitted[GlAccountRole.Cogs];
config.InventoryLossAccountCode = submitted[GlAccountRole.InventoryLoss];
config.CashAccountCode = submitted[GlAccountRole.Cash];
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
_cached = null;
return new ETagged<GlAccountConfigDto>(Map(config, accounts, glUnavailable: false), config.RowVersion);
}
public async Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default)
{
if (_cached is not null) return _cached;
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)
?? throw new NotFoundException("GL account configuration has not been seeded.");
return _cached = new GlAccountCodes(Current(config));
}
/// <summary>Role → stored code, normalising blanks to null.</summary>
private static Dictionary<GlAccountRole, string?> Current(GlAccountConfig c) => new()
{
[GlAccountRole.Inventory] = Trim(c.InventoryAccountCode),
[GlAccountRole.VatRecoverable] = Trim(c.VatRecoverableAccountCode),
[GlAccountRole.AccountsReceivable] = Trim(c.AccountsReceivableCode),
[GlAccountRole.Clearing] = Trim(c.ClearingAccountCode),
[GlAccountRole.TaxPayable] = Trim(c.TaxPayableAccountCode),
[GlAccountRole.AccountsPayable] = Trim(c.AccountsPayableCode),
[GlAccountRole.SalesRevenue] = Trim(c.SalesRevenueAccountCode),
[GlAccountRole.SalesDiscount] = Trim(c.SalesDiscountAccountCode),
[GlAccountRole.InventoryGain] = Trim(c.InventoryGainAccountCode),
[GlAccountRole.Cogs] = Trim(c.CogsAccountCode),
[GlAccountRole.InventoryLoss] = Trim(c.InventoryLossAccountCode),
[GlAccountRole.Cash] = Trim(c.CashAccountCode)
};
private static Dictionary<GlAccountRole, string?> Submitted(UpdateGlAccountConfigRequest r) => new()
{
[GlAccountRole.Inventory] = Trim(r.InventoryAccountCode),
[GlAccountRole.VatRecoverable] = Trim(r.VatRecoverableAccountCode),
[GlAccountRole.AccountsReceivable] = Trim(r.AccountsReceivableCode),
[GlAccountRole.Clearing] = Trim(r.ClearingAccountCode),
[GlAccountRole.TaxPayable] = Trim(r.TaxPayableAccountCode),
[GlAccountRole.AccountsPayable] = Trim(r.AccountsPayableCode),
[GlAccountRole.SalesRevenue] = Trim(r.SalesRevenueAccountCode),
[GlAccountRole.SalesDiscount] = Trim(r.SalesDiscountAccountCode),
[GlAccountRole.InventoryGain] = Trim(r.InventoryGainAccountCode),
[GlAccountRole.Cogs] = Trim(r.CogsAccountCode),
[GlAccountRole.InventoryLoss] = Trim(r.InventoryLossAccountCode),
[GlAccountRole.Cash] = Trim(r.CashAccountCode)
};
private static string? Trim(string? code) => string.IsNullOrWhiteSpace(code) ? null : code.Trim();
private static GlAccountConfigDto Map(
GlAccountConfig config, IReadOnlyList<GlAccount> accounts, bool glUnavailable)
{
var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal);
var mappings = Current(config).Select(kv =>
{
var found = kv.Value is not null && byCode.TryGetValue(kv.Value, out var a) ? a : null;
return new GlAccountMappingDto(
kv.Key.ToString(),
kv.Value,
found?.AccountName,
// Unknown while GL is down, rather than falsely "missing".
ExistsInGl: glUnavailable || found is not null,
IsPostable: glUnavailable || found?.IsPostable == true);
}).ToList();
var options = accounts
.Where(a => a.IsPostable && a.IsActive)
.OrderBy(a => a.AccountCode, StringComparer.Ordinal)
.Select(a => new GlAccountOptionDto(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive))
.ToList();
return new GlAccountConfigDto(mappings, options, glUnavailable, config.UpdatedAt, config.UpdatedBy);
}
}
+7 -10
View File
@@ -45,9 +45,7 @@ public sealed class GrnService : IGrnService
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly INotificationService _notifications;
private readonly string _glInventoryAccountCode;
private readonly string _glVatRecoverableAccountCode;
private readonly string _glClearingAccountCode;
private readonly IGlAccountConfigService _glAccounts;
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
@@ -55,7 +53,7 @@ public sealed class GrnService : IGrnService
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts)
{
_grns = grns;
_pos = pos;
@@ -76,9 +74,7 @@ public sealed class GrnService : IGrnService
_uow = uow;
_gl = gl;
_notifications = notifications;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
@@ -311,17 +307,18 @@ 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 glCodes = await _glAccounts.GetCodesAsync(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))
{
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
if (line.VatAmount > 0)
glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.VatRecoverable], line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
totalPayable += line.LineTotal;
}
glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], 0m, totalPayable, $"GRN {grn.DocNo} received"));
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -0,0 +1,30 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
using ERPCore.Services.Gl;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// The role → GL account code mapping: master data, held in <c>gl_account_config</c> and validated
/// against GL's live chart of accounts on save. Replaces the per-module <c>Gl*Code</c> appsettings
/// keys, which could only be checked at boot and only after a redeploy could be corrected.
/// </summary>
public interface IGlAccountConfigService
{
/// <summary>Current mapping plus the selectable accounts, for the settings screen.</summary>
Task<ETagged<GlAccountConfigDto>> GetAsync(CancellationToken ct = default);
/// <summary>
/// Replaces the mapping. Fails closed: rejects any code GL does not have or cannot post to, and
/// rejects the save outright if GL cannot be reached — an unverified mapping is not a valid one.
/// </summary>
Task<ETagged<GlAccountConfigDto>> UpdateAsync(
UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>
/// The mapping as posting flows consume it. Cached for the lifetime of the scope — a single
/// request can post through several services (GrnService → FifoCostingService) and they should
/// not each re-read the row.
/// </summary>
Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default);
}
@@ -28,18 +28,18 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsPayableCode;
private readonly IGlAccountConfigService _glAccounts;
public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
{
_invoices = invoices;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
@@ -62,6 +62,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -72,7 +73,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
Narration = $"Payment against invoice {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
]
}, token);
@@ -35,13 +35,12 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glClearingAccountCode;
private readonly string _glAccountsPayableCode;
private readonly IGlAccountConfigService _glAccounts;
public PurchaseInvoiceService(
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_invoices = invoices;
_vendors = vendors;
@@ -50,8 +49,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
_numbers = numbers;
_uow = uow;
_gl = gl;
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
@@ -186,6 +184,7 @@ 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 glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -196,8 +195,8 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
]
}, token);
@@ -43,15 +43,14 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glClearingAccountCode;
private readonly IGlAccountConfigService _glAccounts;
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_returns = returns;
_vendors = vendors;
@@ -68,8 +67,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_notifications = notifications;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
@@ -221,6 +219,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0)
{
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -231,8 +230,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
Narration = $"Purchase return {docNo} — stock returned to vendor",
Lines = new List<GlJournalEntryLineRequest>
{
new(_glClearingAccountCode, totalValue, 0m, $"Purchase return {docNo}"),
new(_glInventoryAccountCode, 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
new(glCodes[GlAccountRole.Clearing], totalValue, 0m, $"Purchase return {docNo}"),
new(glCodes[GlAccountRole.Inventory], 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
}
}, token);
ret.GlJournalNo = glResult.JournalNo;
+11 -22
View File
@@ -43,18 +43,12 @@ public sealed class SalesDayEndService : ISalesDayEndService
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glCashAccountCode;
private readonly string _glSalesRevenueAccountCode;
private readonly string _glSalesDiscountAccountCode;
private readonly string _glTaxPayableAccountCode;
private readonly string _glCogsAccountCode;
private readonly string _glInventoryAccountCode;
private readonly string _glAccountsReceivableCode;
private readonly IGlAccountConfigService _glAccounts;
public SalesDayEndService(
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_dayEnds = dayEnds;
_slips = slips;
@@ -66,13 +60,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glCashAccountCode = configuration["SalesDayEnd:GlCashAccountCode"] ?? string.Empty;
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
_glAccountsReceivableCode = configuration["SalesDayEnd:GlAccountsReceivableCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
@@ -206,24 +194,25 @@ public sealed class SalesDayEndService : ISalesDayEndService
if (grandTotal > 0 || cogs > 0)
{
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(businessDate, token);
var glLines = new List<GlJournalEntryLineRequest>();
if (grandTotal > 0)
{
if (paidTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glCashAccountCode, paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cash], paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
if (balanceTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account"));
if (discountTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, discountTotal, 0m, "Sales discount"));
glLines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, subtotal, "Sales revenue"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], discountTotal, 0m, "Sales discount"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, subtotal, "Sales revenue"));
if (taxTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, taxTotal, "Output tax payable"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, taxTotal, "Output tax payable"));
}
if (cogs > 0)
{
glLines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
}
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
@@ -29,11 +29,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsReceivableCode;
private readonly IGlAccountConfigService _glAccounts;
public SalesInvoicePaymentService(
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
{
_invoices = invoices;
_payments = payments;
@@ -41,7 +41,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default)
@@ -68,6 +68,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
// this transaction, before anything is committed — a GL rejection/timeout rolls
// the whole payment back with no partial local state.
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -79,7 +80,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
Lines =
[
new GlJournalEntryLineRequest(account.GlAccountCode, request.Amount, 0m, $"Payment against invoice {invoice.InvoiceNo}"),
new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}")
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}")
]
}, token);
@@ -178,10 +179,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
{
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var glLines = lines
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
.Append(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, total, narration))
.ToList();
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
+10 -19
View File
@@ -26,12 +26,7 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glAccountsReceivableCode;
private readonly string _glSalesRevenueAccountCode;
private readonly string _glSalesDiscountAccountCode;
private readonly string _glTaxPayableAccountCode;
private readonly string _glCogsAccountCode;
private readonly string _glInventoryAccountCode;
private readonly IGlAccountConfigService _glAccounts;
public SalesPostingService(
IRepository<SalesInvoice> invoices,
@@ -44,7 +39,7 @@ public sealed class SalesPostingService : ISalesPostingService
ICurrentUser currentUser,
IUnitOfWork uow,
IGeneralLedgerService gl,
IConfiguration configuration)
IGlAccountConfigService glAccounts)
{
_invoices = invoices;
_slips = slips;
@@ -56,14 +51,9 @@ public sealed class SalesPostingService : ISalesPostingService
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
_glAccounts = glAccounts;
// Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice
// just recognizes them immediately on Post instead of waiting for the day's close.
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
}
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
@@ -185,21 +175,22 @@ public sealed class SalesPostingService : ISalesPostingService
if (invoice.NetPayable <= 0 && cogs <= 0) return;
var now = DateTime.UtcNow;
var glCodes = await _glAccounts.GetCodesAsync(ct);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
var lines = new List<GlJournalEntryLineRequest>();
if (invoice.NetPayable > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
if (invoice.DiscountTotal > 0)
lines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, invoice.DiscountTotal, 0m, "Sales discount"));
lines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, invoice.Subtotal, "Sales revenue"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], invoice.DiscountTotal, 0m, "Sales discount"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, invoice.Subtotal, "Sales revenue"));
if (invoice.TaxTotal > 0)
lines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, invoice.TaxTotal, "Output tax payable"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, invoice.TaxTotal, "Output tax payable"));
}
if (cogs > 0)
{
lines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
}
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
@@ -39,15 +39,14 @@ public sealed class SalesReturnService : ISalesReturnService
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glCogsAccountCode;
private readonly IGlAccountConfigService _glAccounts;
public SalesReturnService(
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_returns = returns;
_customers = customers;
@@ -62,8 +61,7 @@ public sealed class SalesReturnService : ISalesReturnService
_currentUser = currentUser;
_uow = uow;
_gl = gl;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
@@ -180,6 +178,7 @@ public sealed class SalesReturnService : ISalesReturnService
var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0)
{
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -190,8 +189,8 @@ public sealed class SalesReturnService : ISalesReturnService
Narration = $"Sales return {docNo} — stock restocked",
Lines = new List<GlJournalEntryLineRequest>
{
new(_glInventoryAccountCode, totalValue, 0m, $"Sales return {docNo}"),
new(_glCogsAccountCode, 0m, totalValue, $"Sales return {docNo} — COGS reversal")
new(glCodes[GlAccountRole.Inventory], totalValue, 0m, $"Sales return {docNo}"),
new(glCodes[GlAccountRole.Cogs], 0m, totalValue, $"Sales return {docNo} — COGS reversal")
}
}, token);
ret.GlJournalNo = glResult.JournalNo;
@@ -26,21 +26,19 @@ public sealed class FifoCostingService : IFifoCostingService
private readonly IRepository<JournalEntryStub> _journal;
private readonly ErpDbContext _db;
private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode;
private readonly string _glClearingAccountCode;
private readonly IGlAccountConfigService _glAccounts;
public FifoCostingService(
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<JournalEntryStub> journal, ErpDbContext db,
IGeneralLedgerService gl, IConfiguration configuration)
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_layers = layers;
_ledger = ledger;
_journal = journal;
_db = db;
_gl = gl;
_glInventoryAccountCode = configuration["Fifo:GlInventoryAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Fifo:GlClearingAccountCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<StockLayer> CreateInboundLayerAsync(
@@ -273,10 +271,12 @@ public sealed class FifoCostingService : IFifoCostingService
};
await _ledger.AddAsync(entry, ct);
var glCodes = await _glAccounts.GetCodesAsync(ct);
// Inbound debits Inventory / credits Clearing; outbound reverses.
var (debit, credit) = direction == Direction.In
? (_glInventoryAccountCode, _glClearingAccountCode)
: (_glClearingAccountCode, _glInventoryAccountCode);
? (glCodes[GlAccountRole.Inventory], glCodes[GlAccountRole.Clearing])
: (glCodes[GlAccountRole.Clearing], glCodes[GlAccountRole.Inventory]);
// Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13).
await _journal.AddAsync(new JournalEntryStub