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:
2026-10-07 13:04:49 +05:30
parent 614df3065d
commit c41aed9a80
25 changed files with 10003 additions and 256 deletions
@@ -1,8 +1,8 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.System.Time;
using ERPCore.Tests.TestSupport;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace ERPCore.Tests;
/// <summary>
/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
/// </summary>
public class GlAccountCodeValidatorTests
{
[Fact]
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
new StubGl(["1100", "2000", "4000"]));
new(),
new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100"),
StubGeneralLedgerService.Postable("2000")
]),
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
await validator.StartAsync(CancellationToken.None);
}
/// <summary>
/// The default must not block startup. Throwing here cost an outage on 2026-10-07 — a config
/// mismatch that had existed all along became a core-dump the moment the check shipped.
/// </summary>
[Fact]
public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault()
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
{
var validator = CreateValidator(
new()
{
["Grn:GlInventoryAccountCode"] = "1100",
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
["Adjustment:GlLossAccountCode"] = "5100"
},
new StubGl(["1000-01"]));
new(),
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
await validator.StartAsync(CancellationToken.None);
}
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
{
var validator = CreateValidator(
new()
{
["GeneralLedgerService:FailOnMissingAccountCodes"] = "true",
["Grn:GlInventoryAccountCode"] = "1100",
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
["Adjustment:GlLossAccountCode"] = "5100"
},
new StubGl(["1100"]));
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
(GlAccountRole.Inventory, "1100"),
(GlAccountRole.Cash, "1000-01"),
(GlAccountRole.InventoryLoss, "5100"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => validator.StartAsync(CancellationToken.None));
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
Assert.Contains("1000-01", ex.Message);
Assert.Contains("5100", ex.Message);
Assert.Contains("2 of 3", ex.Message);
Assert.DoesNotContain("1100,", ex.Message);
}
/// <summary>
/// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
/// validator would call every configured code missing and refuse to start.
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
/// "everything is missing" would refuse to start on a contract change.
/// </summary>
[Fact]
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
new StubGl([]));
await validator.StartAsync(CancellationToken.None);
}
[Fact]
public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService([]),
(GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
/// <summary>
/// The earlier catch filter only matched DomainException/OperationCanceledException, so anything
/// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host.
/// A diagnostic must not be able to crash the thing it is diagnosing.
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
/// mapping — escaped a hosted service and aborted the host.
/// </summary>
[Theory]
[MemberData(nameof(UnexpectedFailures))]
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
new StubGl(failure));
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGeneralLedgerService(failure),
(GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
public static TheoryData<Exception> UnexpectedFailures() =>
[
StubGeneralLedgerService.Unreachable(),
new NullReferenceException("items was null"),
new global::System.Text.Json.JsonException("unexpected shape"),
new InvalidOperationException("something else entirely"),
new HttpRequestException("connection reset"),
new OperationCanceledException(),
];
[Fact]
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
{
var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
new StubGl(new OperationCanceledException()));
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
await validator.StartAsync(CancellationToken.None);
}
/// <summary>Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.</summary>
[Fact]
public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
{
var gl = new StubGl(["1100"]);
var validator = CreateValidator(
new()
{
["Grn:GlInventoryAccountCode"] = "1100",
["Grn:GlPostingEnabled"] = "true",
["Grn:SomeOtherCode"] = "not-an-account"
},
gl);
await validator.StartAsync(CancellationToken.None);
Assert.Equal(1, gl.Calls);
}
[Fact]
public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
{
var gl = new StubGl(["1100"]);
var validator = CreateValidator(new(), gl);
await validator.StartAsync(CancellationToken.None);
Assert.Equal(0, gl.Calls);
}
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
[Fact]
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
{
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var validator = CreateValidator(
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
gl,
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
await validator.StartAsync(CancellationToken.None);
Assert.Equal(1, gl.Calls);
}
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
[Fact]
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
@@ -165,39 +138,14 @@ public class GlAccountCodeValidatorTests
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
}
private static GlAccountCodeValidator CreateValidator(Dictionary<string, string?> settings, IGeneralLedgerService gl)
private static GlAccountCodeValidator CreateValidator(
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
var services = new ServiceCollection()
.AddScoped<IGeneralLedgerService>(_ => gl)
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
.BuildServiceProvider();
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
}
private sealed class StubGl(HashSet<string>? codes = null, Exception? throws = null) : IGeneralLedgerService
{
private readonly HashSet<string> _codes = codes ?? [];
private readonly Exception? _throws = throws;
public StubGl(Exception throws) : this(null, throws) { }
public int Calls { get; private set; }
public Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
{
Calls++;
return _throws is not null ? Task.FromException<HashSet<string>>(_throws) : Task.FromResult(_codes);
}
public Task<ERPCore.Infra.Gl.GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
=> throw new NotSupportedException();
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,234 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
namespace ERPCore.Tests;
/// <summary>
/// The point of this service is that a mapping GL cannot post to never reaches the database.
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
/// existed in GL at all.
/// </summary>
public class GlAccountConfigServiceTests
{
[Fact]
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.Postable("2000", "GRNI")
]);
var service = CreateService(db, gl);
var before = (await service.GetAsync()).RowVersion;
var result = await service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
Assert.True(result.RowVersion > before);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
}
[Fact]
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(422, ex.StatusCode);
Assert.Contains("9999", ex.Message);
Assert.Contains("Clearing", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>
/// GL rejects a journal line against a non-postable account, so storing one only defers the
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
/// </summary>
[Fact]
public async Task UpdateAsync_RejectsANonPostableAccount()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Contains("not postable", ex.Message);
await AssertNothingStoredAsync(db);
}
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
[Fact]
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
await AssertNothingStoredAsync(db);
}
[Fact]
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var rowVersion = (await service.GetAsync()).RowVersion;
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
Assert.Equal(503, ex.StatusCode);
}
[Fact]
public async Task UpdateAsync_RejectsAStaleIfMatch()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
Assert.Equal(412, ex.StatusCode);
}
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
[Fact]
public async Task UpdateAsync_TreatsBlankAsCleared()
{
await using var db = await CreateSeededContextAsync();
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
var service = CreateService(db, gl);
var rowVersion = (await service.GetAsync()).RowVersion;
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
var codes = await CreateService(db, gl).GetCodesAsync();
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
}
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
[Fact]
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var codes = await service.GetCodesAsync();
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
Assert.Contains("Cogs", ex.Message);
}
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
[Fact]
public async Task GetCodesAsync_ReadsOncePerScope()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([]));
var first = await service.GetCodesAsync();
var second = await service.GetCodesAsync();
Assert.Same(first, second);
}
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
[Fact]
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
{
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
var result = await service.GetAsync();
Assert.True(result.Value.GlUnavailable);
Assert.Empty(result.Value.Options);
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
Assert.Equal("1100", inventory.AccountCode);
}
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
[Fact]
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
{
await using var db = await CreateSeededContextAsync(c =>
{
c.InventoryAccountCode = "1100";
c.CashAccountCode = "1000-01";
});
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
]));
var result = await service.GetAsync();
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
}
/// <summary>The picker must only offer what validation will accept.</summary>
[Fact]
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
{
await using var db = await CreateSeededContextAsync();
var service = CreateService(db, new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100", "Inventory"),
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
new GlAccount("1200", "Retired", 1, true, false)
]));
var options = (await service.GetAsync()).Value.Options;
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
}
private static async Task AssertNothingStoredAsync(ErpDbContext db)
{
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
Assert.Null(row!.InventoryAccountCode);
Assert.Null(row.ClearingAccountCode);
Assert.Null(row.CashAccountCode);
}
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
{
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
configure?.Invoke(config);
db.GlAccountConfig.Add(config);
await db.SaveChangesAsync();
return db;
}
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
}
@@ -0,0 +1,66 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Tests.TestSupport;
/// <summary>
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
/// doing so fails loudly rather than against a silently empty fake.
/// </summary>
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
{
private readonly IReadOnlyList<GlAccount> _accounts;
private readonly Exception? _throws;
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
public int Calls { get; private set; }
/// <summary>A postable, active account — the ordinary case.</summary>
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
{
Calls++;
return _throws is not null
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
: Task.FromResult(_accounts);
}
public Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
=> throw new NotSupportedException();
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
=> throw new NotSupportedException();
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
=> throw new NotSupportedException();
public static DomainException Unreachable() =>
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
}
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
{
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
=> throw new NotSupportedException();
}
@@ -0,0 +1,53 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Role → GL account mapping endpoints.
/// <para>
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
/// and the server-side validation work from the same list.
/// </para>
/// </summary>
[Route("api/v1/gl-account-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class GlAccountConfigController : ApiControllerBase
{
private readonly IGlAccountConfigService _config;
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
/// not be reached, since a mapping that could not be verified is not stored.
/// </summary>
[HttpPut]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<GlAccountConfigDto>> Update(
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -0,0 +1,50 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Entities;
namespace ERPCore.Dtos.Config;
/// <summary>One selectable GL account, for the mapping pickers.</summary>
public sealed record GlAccountOptionDto(string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
/// <summary>
/// One role's current mapping. <see cref="ExistsInGl"/> is false when the saved code is not in GL's
/// chart of accounts — which is the state 11 of these were found in on 2026-10-07. The screen shows
/// those as flagged rather than blank, so the mapping that is actually stored stays visible.
/// </summary>
public sealed record GlAccountMappingDto(
string Role, string? AccountCode, string? AccountName, bool ExistsInGl, bool IsPostable);
/// <summary>
/// GL account mapping resource. Carries the options alongside the mappings so the picker and the
/// server's validation list are the same data — a code the user can choose is a code that will save.
/// <para>
/// <see cref="GlUnavailable"/> is true when GL could not be reached: mappings still render from the
/// database, <see cref="Options"/> is empty, and saving will be refused until GL answers.
/// </para>
/// </summary>
public sealed record GlAccountConfigDto(
IReadOnlyList<GlAccountMappingDto> Mappings,
IReadOnlyList<GlAccountOptionDto> Options,
bool GlUnavailable,
DateTime? UpdatedAt,
int? UpdatedBy);
/// <summary>
/// Full replacement of the mapping. Every role is sent on every save; null or empty clears a role.
/// <c>UpdatedBy</c> is derived from the token, never posted.
/// </summary>
public sealed class UpdateGlAccountConfigRequest
{
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? VatRecoverableAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsReceivableCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? ClearingAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? TaxPayableAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsPayableCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesRevenueAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesDiscountAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryGainAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
}
@@ -1,12 +1,18 @@
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
namespace ERPCore.Infra.Gl;
/// <summary>
/// Startup guard for the GL chart of accounts. Every <c>Gl*Code</c> in configuration names an
/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced
/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one
/// discovery, repeat. This asserts the whole set once, at boot.
/// Startup guard for the GL chart of accounts. Every account mapped in <c>gl_account_config</c>
/// must exist in GL; without this, an un-seeded account only surfaced as a 404 when some user
/// happened to trigger that one flow, mid-transaction — one flow, one discovery, repeat. This
/// asserts the whole set once, at boot.
/// <para>
/// Advisory only. Enforcement lives at the point of entry — <c>GlAccountConfigService.UpdateAsync</c>
/// refuses to store a code GL does not have or cannot post to — so this is a net for accounts that
/// disappeared from GL *after* being mapped.
/// </para>
/// <para>
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
@@ -26,39 +32,41 @@ public sealed class GlAccountCodeValidator(
IConfiguration configuration,
ILogger<GlAccountCodeValidator> logger) : IHostedService
{
/// <summary>Configuration sections that carry GL account codes — see appsettings.json.</summary>
private static readonly string[] Sections =
["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"];
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
public async Task StartAsync(CancellationToken ct)
{
var expected = Sections
.SelectMany(s => configuration.GetSection(s).GetChildren())
.Where(c => c.Key.StartsWith("Gl", StringComparison.Ordinal) && c.Key.EndsWith("Code", StringComparison.Ordinal))
.Select(c => c.Value)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v!)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (expected.Length == 0)
{
logger.LogWarning("No GL account codes are configured — skipping chart-of-accounts verification.");
return;
}
string[] expected;
HashSet<string> present;
// The database read sits inside the catch-all too: a DB hiccup here would otherwise escape
// a hosted service and reopen the startup-abort hole this class already caused once.
try
{
using var scope = services.CreateScope();
var mapping = scope.ServiceProvider.GetRequiredService<IGlAccountConfigService>();
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
var codes = await mapping.GetCodesAsync(ct);
expected = Enum.GetValues<GlAccountRole>()
.Select(codes.GetOrNull)
.Where(c => c is not null)
.Select(c => c!)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (expected.Length == 0)
{
logger.LogWarning(
"No GL accounts are mapped — skipping chart-of-accounts verification. "
+ "Map them under Settings → GL Accounts; posting flows will fail until then.");
return;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
present = await gl.ListAccountCodesAsync(timeout.Token);
present = (await gl.ListAccountsAsync(timeout.Token)).Select(a => a.AccountCode).ToHashSet(StringComparer.Ordinal);
}
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
@@ -67,8 +75,7 @@ public sealed class GlAccountCodeValidator(
catch (Exception e) when (!ct.IsCancellationRequested)
{
logger.LogWarning(e,
"Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.",
expected.Length);
"Could not verify the GL account mapping at startup; it is left unchecked.");
return;
}
@@ -78,7 +85,7 @@ public sealed class GlAccountCodeValidator(
if (present.Count == 0)
{
logger.LogWarning(
"GL returned no account codes; {Count} configured account code(s) left unverified. " +
"GL returned no account codes; {Count} mapped account code(s) left unverified. " +
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
expected.Length);
return;
@@ -88,10 +95,10 @@ public sealed class GlAccountCodeValidator(
if (missing.Length > 0)
{
var message =
$"General Ledger is missing {missing.Length} of {expected.Length} configured account code(s): " +
$"General Ledger is missing {missing.Length} of {expected.Length} mapped account code(s): " +
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
"will fail against GL until they are seeded in GL's chart of accounts, or the Gl*Code settings are " +
"corrected to match the codes GL actually uses.";
"will fail against GL until the accounts are created in GL, or the mapping is corrected under " +
"Settings → GL Accounts.";
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
throw new InvalidOperationException(message);
@@ -100,7 +107,7 @@ public sealed class GlAccountCodeValidator(
return;
}
logger.LogInformation("GL account codes verified: {Count} present.", expected.Length);
logger.LogInformation("GL account mapping verified: {Count} account code(s) present.", expected.Length);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
@@ -47,7 +47,8 @@ public static class DataSeeder
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
public static async Task SeedAsync(
ErpDbContext db, IConfiguration? configuration = null, CancellationToken ct = default)
{
// Must come first, and must save on its own: every branch-scoped entity has a
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
@@ -60,6 +61,7 @@ public static class DataSeeder
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
@@ -199,6 +201,49 @@ public static class DataSeeder
return true;
}
/// <summary>
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
/// values once so an existing deployment keeps the mapping it has been running with.
/// <para>
/// Those keys are <b>seed-only</b>: nothing reads them at runtime any more. Leaving a runtime
/// fallback in place would restore the two-sources-of-truth problem this table exists to end —
/// after seeding, <c>gl_account_config</c> is authoritative and is edited from
/// Settings → GL Accounts, not by redeploying.
/// </para>
/// <para>
/// Deliberately does not call GL: seeding runs inside the startup block that aborts the app on
/// failure (see Program.cs), so it must not depend on another service being up. The codes are
/// verified separately — advisory at boot by <c>GlAccountCodeValidator</c>, and enforced on
/// every save by <c>GlAccountConfigService.UpdateAsync</c>.
/// </para>
/// </summary>
private static async Task<bool> SeedGlAccountConfigAsync(
ErpDbContext db, IConfiguration? configuration, CancellationToken ct)
{
if (await db.GlAccountConfig.AnyAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)) return false;
string? Legacy(string key) =>
string.IsNullOrWhiteSpace(configuration?[key]) ? null : configuration[key]!.Trim();
db.GlAccountConfig.Add(new GlAccountConfig
{
ConfigId = GlAccountConfig.SingletonId,
InventoryAccountCode = Legacy("Grn:GlInventoryAccountCode"),
VatRecoverableAccountCode = Legacy("Grn:GlVatRecoverableAccountCode"),
AccountsReceivableCode = Legacy("SalesInvoice:GlAccountsReceivableCode"),
ClearingAccountCode = Legacy("Grn:GlClearingAccountCode"),
TaxPayableAccountCode = Legacy("SalesDayEnd:GlTaxPayableAccountCode"),
AccountsPayableCode = Legacy("PurchaseInvoice:GlAccountsPayableCode"),
SalesRevenueAccountCode = Legacy("SalesDayEnd:GlSalesRevenueAccountCode"),
SalesDiscountAccountCode = Legacy("SalesDayEnd:GlSalesDiscountAccountCode"),
InventoryGainAccountCode = Legacy("Adjustment:GlGainAccountCode"),
CogsAccountCode = Legacy("SalesDayEnd:GlCogsAccountCode"),
InventoryLossAccountCode = Legacy("Adjustment:GlLossAccountCode"),
CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode")
});
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddGlAccountConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "gl_account_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
InventoryAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
VatRecoverableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
AccountsReceivableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
ClearingAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
TaxPayableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
AccountsPayableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
SalesRevenueAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
SalesDiscountAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
InventoryGainAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
CogsAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
InventoryLossAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
CashAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_gl_account_config", x => x.ConfigId);
table.CheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_gl_account_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_gl_account_config_UpdatedBy",
table: "gl_account_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gl_account_config");
}
}
}
@@ -1421,6 +1421,79 @@ namespace ERPCore.Migrations
b.ToTable("hr_employment_types", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<string>("AccountsPayableCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("AccountsReceivableCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("CashAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("ClearingAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("CogsAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("InventoryAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("InventoryGainAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("InventoryLossAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<string>("SalesDiscountAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SalesRevenueAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("TaxPayableAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.Property<string>("VatRecoverableAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("gl_account_config", null, t =>
{
t.HasCheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.Property<int>("GrnId")
@@ -7117,6 +7190,16 @@ namespace ERPCore.Migrations
b.Navigation("SalaryComponent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
+2 -1
View File
@@ -119,6 +119,7 @@ builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
@@ -241,7 +242,7 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync();
try
{
await DataSeeder.SeedAsync(db);
await DataSeeder.SeedAsync(db, builder.Configuration);
}
catch (Exception ex)
{
+8 -11
View File
@@ -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
+8 -11
View File
@@ -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);
}
}
+7 -10
View File
@@ -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;
+11 -22
View File
@@ -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
{
+10 -19
View File
@@ -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