Add GL account configuration and role mapping entities

- Introduced GlAccountConfig entity for mapping accounting roles to GL account codes.
- Added GlAccountConfigConfiguration for database configuration of the singleton GL account mapping.
- Created GlAccountRole enum to define accounting roles used in GL postings.
- Enhanced error handling with new error codes for unconfigured and invalid GL accounts.
- Updated existing DbContext to include GlAccountConfig DbSet.
This commit is contained in:
2026-10-07 12:51:15 +05:30
parent 65976c5cd3
commit 614df3065d
7 changed files with 226 additions and 3 deletions
@@ -0,0 +1,83 @@
using ERPCore.System.Errors;
namespace ERPCore.Services.Gl;
/// <summary>
/// The accounting roles ERPCore posts to, independent of whatever code GL happens to use for each.
/// <para>
/// These replace the old <c>Grn:GlInventoryAccountCode</c>-style appsettings keys. Those pinned GL's
/// *codes* in ERPCore's config while GL *generates* codes positionally and refuses a requested one —
/// so nothing connected the two, and on 2026-10-07 eleven of the twelve configured codes turned out
/// never to have existed in GL. A role is the stable thing; the code it maps to is master data, held
/// in <see cref="ERPCore.Domain.Entities.GlAccountConfig"/> and validated against GL's live chart of
/// accounts whenever it is saved.
/// </para>
/// <para>
/// The old keys were per-module (`Grn:`, `Adjustment:`, `SalesDayEnd:`, `Fifo:`), but every module
/// carried the *same* value for a given role, so they collapse to one role each. See the report note
/// if a per-module split is ever wanted back.
/// </para>
/// </summary>
public enum GlAccountRole
{
/// <summary>Stock on hand. Debited on receipt, credited on issue.</summary>
Inventory,
/// <summary>Recoverable input VAT on purchases.</summary>
VatRecoverable,
/// <summary>Trade debtors — sales made on account.</summary>
AccountsReceivable,
/// <summary>Goods received not invoiced: the GRN↔purchase-invoice clearing account.</summary>
Clearing,
/// <summary>Output tax collected on sales and owed onward.</summary>
TaxPayable,
/// <summary>Trade creditors — supplier invoices awaiting payment.</summary>
AccountsPayable,
/// <summary>Sales revenue.</summary>
SalesRevenue,
/// <summary>Discounts given on sales (contra-revenue, carries a debit balance).</summary>
SalesDiscount,
/// <summary>Inventory written up by a stock adjustment or count.</summary>
InventoryGain,
/// <summary>Cost of goods sold.</summary>
Cogs,
/// <summary>Inventory written off by a stock adjustment or count.</summary>
InventoryLoss,
/// <summary>Cash takings at day-end close.</summary>
Cash
}
/// <summary>
/// A resolved role → GL account code map, as saved in <c>gl_account_config</c>.
/// <para>
/// Reading an unmapped role throws rather than returning null or an empty string. The old code
/// defaulted missing config to <c>string.Empty</c> and posted that to GL, which came back as an
/// opaque "Account not found" from inside a transaction. Naming the role up front is the whole
/// point of this type.
/// </para>
/// </summary>
public sealed class GlAccountCodes(IReadOnlyDictionary<GlAccountRole, string?> codes)
{
/// <summary>The code mapped to <paramref name="role"/>. Throws if nothing is mapped.</summary>
public string this[GlAccountRole role] =>
codes.TryGetValue(role, out var code) && !string.IsNullOrWhiteSpace(code)
? code
: throw new DomainException(
ErrorCodes.GlAccountNotConfigured,
$"No General Ledger account is mapped to '{role}'. Set it under Settings → GL Accounts before posting.",
409);
/// <summary>The code mapped to <paramref name="role"/>, or null. For display, never for posting.</summary>
public string? GetOrNull(GlAccountRole role) =>
codes.TryGetValue(role, out var code) && !string.IsNullOrWhiteSpace(code) ? code : null;
}
+10
View File
@@ -25,3 +25,13 @@ public sealed record GlPeriod(int PeriodId, int FiscalYearId);
public sealed record GlBankAccount(
string AccountType, long AccountId, string AccountName, string? BankName,
string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode);
/// <summary>
/// One row of GL's chart of accounts, as far as ERPCore cares: enough to populate the GL-account
/// mapping pickers and to validate a saved mapping. <see cref="IsPostable"/> matters because GL
/// rejects a journal line against a non-postable account (its JournalEntryService.BuildLineAsync),
/// so an existing-but-unpostable code is just as broken as a missing one — control/header accounts
/// such as the auto-created <c>1000</c> Bank/Cash parent are exactly that.
/// </summary>
public sealed record GlAccount(
string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
@@ -26,8 +26,10 @@ public interface IGeneralLedgerService
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
/// <summary>
/// Every account code in GL's chart of accounts. Used at startup to verify the configured
/// <c>Gl*Code</c> settings exist — see <see cref="ERPCore.Infra.Gl.GlAccountCodeValidator"/>.
/// GL's whole chart of accounts. Backs the GL-account mapping pickers, the validation that
/// rejects a mapping GL cannot post to (<c>GlAccountConfigService</c>), and the startup
/// verification in <see cref="ERPCore.Infra.Gl.GlAccountCodeValidator"/> — one source for all
/// three, so what a user can pick is exactly what passes validation.
/// </summary>
Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct);
Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct);
}