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