Add GL account configuration and role mapping entities #107

Merged
ImanThiyanga merged 2 commits from Dev into production 2026-10-07 13:49:13 +05:30
7 changed files with 226 additions and 3 deletions
@@ -0,0 +1,71 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Role → GL account code mapping — a singleton row, same shape as <see cref="GrnConfig"/>.
/// <para>
/// This is master data, not deployment config. It used to live in <c>appsettings.json</c> as
/// per-module <c>Gl*Code</c> keys, which meant (a) only a redeploy could correct a mapping, and
/// (b) nothing checked the codes existed in GL until something tried to post. Held here it is
/// validated against GL's live chart of accounts on every save, so an unknown or non-postable
/// account cannot be stored in the first place — see <c>GlAccountConfigService.UpdateAsync</c>.
/// </para>
/// <para>
/// Codes are nullable: a fresh install has nothing mapped, and a posting flow that needs an
/// unmapped role fails with <c>GL_ACCOUNT_NOT_CONFIGURED</c> naming that role. Width matches
/// GL's own <c>accounts.account_code varchar(20)</c>.
/// </para>
/// </summary>
public class GlAccountConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
/// <summary>Matches GL's <c>accounts.account_code</c> column width.</summary>
public const int CodeMaxLength = 20;
public int ConfigId { get; set; }
/// <summary>Stock on hand (was <c>Grn|Adjustment|SalesDayEnd|Fifo:GlInventoryAccountCode</c>).</summary>
public string? InventoryAccountCode { get; set; }
/// <summary>Recoverable input VAT (was <c>Grn:GlVatRecoverableAccountCode</c>).</summary>
public string? VatRecoverableAccountCode { get; set; }
/// <summary>Trade debtors (was <c>SalesDayEnd|SalesInvoice:GlAccountsReceivableCode</c>).</summary>
public string? AccountsReceivableCode { get; set; }
/// <summary>Goods received not invoiced (was <c>Grn|PurchaseInvoice|Fifo:GlClearingAccountCode</c>).</summary>
public string? ClearingAccountCode { get; set; }
/// <summary>Output tax payable (was <c>SalesDayEnd:GlTaxPayableAccountCode</c>).</summary>
public string? TaxPayableAccountCode { get; set; }
/// <summary>Trade creditors (was <c>PurchaseInvoice:GlAccountsPayableCode</c>).</summary>
public string? AccountsPayableCode { get; set; }
/// <summary>Sales revenue (was <c>SalesDayEnd:GlSalesRevenueAccountCode</c>).</summary>
public string? SalesRevenueAccountCode { get; set; }
/// <summary>Sales discount given (was <c>SalesDayEnd:GlSalesDiscountAccountCode</c>).</summary>
public string? SalesDiscountAccountCode { get; set; }
/// <summary>Inventory gain on adjustment/count (was <c>Adjustment:GlGainAccountCode</c>).</summary>
public string? InventoryGainAccountCode { get; set; }
/// <summary>Cost of goods sold (was <c>SalesDayEnd:GlCogsAccountCode</c>).</summary>
public string? CogsAccountCode { get; set; }
/// <summary>Inventory loss on adjustment/count (was <c>Adjustment:GlLossAccountCode</c>).</summary>
public string? InventoryLossAccountCode { get; set; }
/// <summary>Cash takings at day-end (was <c>SalesDayEnd:GlCashAccountCode</c>).</summary>
public string? CashAccountCode { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source); bumped by ErpDbContext.BumpRowVersions.</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,51 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton GL account-mapping row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors <see cref="GrnConfigConfiguration"/>).
/// Backtick quoting is deliberate - MariaDB rejects the double-quoted form, which crash-looped
/// startup seeding once already (c97a7e9).
/// </summary>
public sealed class GlAccountConfigConfiguration : IEntityTypeConfiguration<GlAccountConfig>
{
public void Configure(EntityTypeBuilder<GlAccountConfig> builder)
{
builder.ToTable("gl_account_config", t =>
t.HasCheckConstraint("ck_gl_account_config_singleton", $"`ConfigId` = {GlAccountConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
foreach (var code in new[]
{
nameof(GlAccountConfig.InventoryAccountCode),
nameof(GlAccountConfig.VatRecoverableAccountCode),
nameof(GlAccountConfig.AccountsReceivableCode),
nameof(GlAccountConfig.ClearingAccountCode),
nameof(GlAccountConfig.TaxPayableAccountCode),
nameof(GlAccountConfig.AccountsPayableCode),
nameof(GlAccountConfig.SalesRevenueAccountCode),
nameof(GlAccountConfig.SalesDiscountAccountCode),
nameof(GlAccountConfig.InventoryGainAccountCode),
nameof(GlAccountConfig.CogsAccountCode),
nameof(GlAccountConfig.InventoryLossAccountCode),
nameof(GlAccountConfig.CashAccountCode)
})
{
builder.Property(code).HasMaxLength(GlAccountConfig.CodeMaxLength);
}
// RowVersion is made a concurrency token by the model-wide convention in ErpDbContext.
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -48,6 +48,8 @@ public class ErpDbContext : DbContext
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
/// <summary>Singleton row mapping each accounting role to a GL account code.</summary>
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
@@ -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);
}
@@ -76,6 +76,10 @@ public static class ErrorCodes
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
public const string GlRequestFailed = "GL_REQUEST_FAILED";
// A posting flow needs a GL account whose role has no account mapped in gl_account_config.
public const string GlAccountNotConfigured = "GL_ACCOUNT_NOT_CONFIGURED";
// A submitted GL account mapping names an account GL does not have, or cannot post to.
public const string GlAccountInvalid = "GL_ACCOUNT_INVALID";
// Sales invoice payments
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";