From 614df3065d972bef452a4c53aeb31e8449bd70a1 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Wed, 7 Oct 2026 12:51:15 +0530 Subject: [PATCH] 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. --- .../Domain/Entities/GlAccountConfig.cs | 71 ++++++++++++++++ .../GlAccountConfigConfiguration.cs | 51 ++++++++++++ .../ERPCore/Infra/Persistence/ErpDbContext.cs | 2 + Backend/ERPCore/Services/Gl/GlAccountRole.cs | 83 +++++++++++++++++++ Backend/ERPCore/Services/Gl/GlModels.cs | 10 +++ .../Interfaces/IGeneralLedgerService.cs | 8 +- Backend/ERPCore/System/Errors/ErrorCodes.cs | 4 + 7 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 Backend/ERPCore/Domain/Entities/GlAccountConfig.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/GlAccountConfigConfiguration.cs create mode 100644 Backend/ERPCore/Services/Gl/GlAccountRole.cs diff --git a/Backend/ERPCore/Domain/Entities/GlAccountConfig.cs b/Backend/ERPCore/Domain/Entities/GlAccountConfig.cs new file mode 100644 index 0000000..125859c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/GlAccountConfig.cs @@ -0,0 +1,71 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Role → GL account code mapping — a singleton row, same shape as . +/// +/// This is master data, not deployment config. It used to live in appsettings.json as +/// per-module Gl*Code 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 GlAccountConfigService.UpdateAsync. +/// +/// +/// Codes are nullable: a fresh install has nothing mapped, and a posting flow that needs an +/// unmapped role fails with GL_ACCOUNT_NOT_CONFIGURED naming that role. Width matches +/// GL's own accounts.account_code varchar(20). +/// +/// +public class GlAccountConfig +{ + /// Always 1 - the singleton row's id. + public const int SingletonId = 1; + + /// Matches GL's accounts.account_code column width. + public const int CodeMaxLength = 20; + + public int ConfigId { get; set; } + + /// Stock on hand (was Grn|Adjustment|SalesDayEnd|Fifo:GlInventoryAccountCode). + public string? InventoryAccountCode { get; set; } + + /// Recoverable input VAT (was Grn:GlVatRecoverableAccountCode). + public string? VatRecoverableAccountCode { get; set; } + + /// Trade debtors (was SalesDayEnd|SalesInvoice:GlAccountsReceivableCode). + public string? AccountsReceivableCode { get; set; } + + /// Goods received not invoiced (was Grn|PurchaseInvoice|Fifo:GlClearingAccountCode). + public string? ClearingAccountCode { get; set; } + + /// Output tax payable (was SalesDayEnd:GlTaxPayableAccountCode). + public string? TaxPayableAccountCode { get; set; } + + /// Trade creditors (was PurchaseInvoice:GlAccountsPayableCode). + public string? AccountsPayableCode { get; set; } + + /// Sales revenue (was SalesDayEnd:GlSalesRevenueAccountCode). + public string? SalesRevenueAccountCode { get; set; } + + /// Sales discount given (was SalesDayEnd:GlSalesDiscountAccountCode). + public string? SalesDiscountAccountCode { get; set; } + + /// Inventory gain on adjustment/count (was Adjustment:GlGainAccountCode). + public string? InventoryGainAccountCode { get; set; } + + /// Cost of goods sold (was SalesDayEnd:GlCogsAccountCode). + public string? CogsAccountCode { get; set; } + + /// Inventory loss on adjustment/count (was Adjustment:GlLossAccountCode). + public string? InventoryLossAccountCode { get; set; } + + /// Cash takings at day-end (was SalesDayEnd:GlCashAccountCode). + public string? CashAccountCode { get; set; } + + public DateTime? UpdatedAt { get; set; } + + public int? UpdatedBy { get; set; } + public User? UpdatedByUser { get; set; } + + /// Optimistic concurrency token (ETag source); bumped by ErpDbContext.BumpRowVersions. + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GlAccountConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GlAccountConfigConfiguration.cs new file mode 100644 index 0000000..67576fe --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GlAccountConfigConfiguration.cs @@ -0,0 +1,51 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +/// +/// Configures the singleton GL account-mapping row. The check constraint is what makes +/// "singleton" a database guarantee rather than a convention (mirrors ). +/// Backtick quoting is deliberate - MariaDB rejects the double-quoted form, which crash-looped +/// startup seeding once already (c97a7e9). +/// +public sealed class GlAccountConfigConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index 45d6d7f..fef8e6e 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -48,6 +48,8 @@ public class ErpDbContext : DbContext public DbSet BranchConfig => Set(); public DbSet SalesConfig => Set(); public DbSet GrnConfig => Set(); + /// Singleton row mapping each accounting role to a GL account code. + public DbSet GlAccountConfig => Set(); /// Singleton row for notification delivery preferences. public DbSet NotificationConfig => Set(); public DbSet NotificationConfigModules => Set(); diff --git a/Backend/ERPCore/Services/Gl/GlAccountRole.cs b/Backend/ERPCore/Services/Gl/GlAccountRole.cs new file mode 100644 index 0000000..207429c --- /dev/null +++ b/Backend/ERPCore/Services/Gl/GlAccountRole.cs @@ -0,0 +1,83 @@ +using ERPCore.System.Errors; + +namespace ERPCore.Services.Gl; + +/// +/// The accounting roles ERPCore posts to, independent of whatever code GL happens to use for each. +/// +/// These replace the old Grn:GlInventoryAccountCode-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 and validated against GL's live chart of +/// accounts whenever it is saved. +/// +/// +/// 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. +/// +/// +public enum GlAccountRole +{ + /// Stock on hand. Debited on receipt, credited on issue. + Inventory, + + /// Recoverable input VAT on purchases. + VatRecoverable, + + /// Trade debtors — sales made on account. + AccountsReceivable, + + /// Goods received not invoiced: the GRN↔purchase-invoice clearing account. + Clearing, + + /// Output tax collected on sales and owed onward. + TaxPayable, + + /// Trade creditors — supplier invoices awaiting payment. + AccountsPayable, + + /// Sales revenue. + SalesRevenue, + + /// Discounts given on sales (contra-revenue, carries a debit balance). + SalesDiscount, + + /// Inventory written up by a stock adjustment or count. + InventoryGain, + + /// Cost of goods sold. + Cogs, + + /// Inventory written off by a stock adjustment or count. + InventoryLoss, + + /// Cash takings at day-end close. + Cash +} + +/// +/// A resolved role → GL account code map, as saved in gl_account_config. +/// +/// Reading an unmapped role throws rather than returning null or an empty string. The old code +/// defaulted missing config to string.Empty 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. +/// +/// +public sealed class GlAccountCodes(IReadOnlyDictionary codes) +{ + /// The code mapped to . Throws if nothing is mapped. + 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); + + /// The code mapped to , or null. For display, never for posting. + public string? GetOrNull(GlAccountRole role) => + codes.TryGetValue(role, out var code) && !string.IsNullOrWhiteSpace(code) ? code : null; +} diff --git a/Backend/ERPCore/Services/Gl/GlModels.cs b/Backend/ERPCore/Services/Gl/GlModels.cs index 7876d0e..c5f0175 100644 --- a/Backend/ERPCore/Services/Gl/GlModels.cs +++ b/Backend/ERPCore/Services/Gl/GlModels.cs @@ -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); + +/// +/// 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. 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 1000 Bank/Cash parent are exactly that. +/// +public sealed record GlAccount( + string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive); diff --git a/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs b/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs index 1aba698..94c44db 100644 --- a/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs +++ b/Backend/ERPCore/Services/Interfaces/IGeneralLedgerService.cs @@ -26,8 +26,10 @@ public interface IGeneralLedgerService Task> ListBankAccountsAsync(CancellationToken ct); /// - /// Every account code in GL's chart of accounts. Used at startup to verify the configured - /// Gl*Code settings exist — see . + /// GL's whole chart of accounts. Backs the GL-account mapping pickers, the validation that + /// rejects a mapping GL cannot post to (GlAccountConfigService), and the startup + /// verification in — one source for all + /// three, so what a user can pick is exactly what passes validation. /// - Task> ListAccountCodesAsync(CancellationToken ct); + Task> ListAccountsAsync(CancellationToken ct); } diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index d09c3ad..2557cec 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -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";