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";