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.Infra.Gl;
using ERPCore.Services.Gl; using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.System.Time; using ERPCore.System.Time;
using ERPCore.Tests.TestSupport;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace ERPCore.Tests; namespace ERPCore.Tests;
/// <summary> /// <summary>
/// The validator's whole value is in *which* failures it treats as fatal: a missing account code /// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host /// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
/// and may still be starting — hard-failing there turns that into a deploy crash-loop). /// 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> /// </summary>
public class GlAccountCodeValidatorTests public class GlAccountCodeValidatorTests
{ {
[Fact] [Fact]
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl() public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
{ {
var validator = CreateValidator( var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" }, new(),
new StubGl(["1100", "2000", "4000"])); new StubGeneralLedgerService([
StubGeneralLedgerService.Postable("1100"),
StubGeneralLedgerService.Postable("2000")
]),
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
await validator.StartAsync(CancellationToken.None); 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] [Fact]
public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault() public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
{ {
var validator = CreateValidator( var validator = CreateValidator(
new() new(),
{ new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
["Grn:GlInventoryAccountCode"] = "1100", (GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
["Adjustment:GlLossAccountCode"] = "5100"
},
new StubGl(["1000-01"]));
await validator.StartAsync(CancellationToken.None); await validator.StartAsync(CancellationToken.None);
} }
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail() public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
{ {
var validator = CreateValidator( var validator = CreateValidator(
new() new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
{ new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
["GeneralLedgerService:FailOnMissingAccountCodes"] = "true", (GlAccountRole.Inventory, "1100"),
["Grn:GlInventoryAccountCode"] = "1100", (GlAccountRole.Cash, "1000-01"),
["SalesDayEnd:GlCashAccountCode"] = "1000-01", (GlAccountRole.InventoryLoss, "5100"));
["Adjustment:GlLossAccountCode"] = "5100"
},
new StubGl(["1100"]));
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => validator.StartAsync(CancellationToken.None)); () => validator.StartAsync(CancellationToken.None));
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
Assert.Contains("1000-01", ex.Message); Assert.Contains("1000-01", ex.Message);
Assert.Contains("5100", ex.Message); Assert.Contains("5100", ex.Message);
Assert.Contains("2 of 3", ex.Message); Assert.Contains("2 of 3", ex.Message);
Assert.DoesNotContain("1100,", ex.Message);
} }
/// <summary> /// <summary>
/// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive /// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
/// validator would call every configured code missing and refuse to start. /// "everything is missing" would refuse to start on a contract change.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll() public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
{ {
var validator = CreateValidator( var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" }, new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGl([])); new StubGeneralLedgerService([]),
(GlAccountRole.Inventory, "1100"));
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)));
await validator.StartAsync(CancellationToken.None); await validator.StartAsync(CancellationToken.None);
} }
/// <summary> /// <summary>
/// The earlier catch filter only matched DomainException/OperationCanceledException, so anything /// The catch filter once matched only DomainException/OperationCanceledException, so anything
/// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host. /// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
/// A diagnostic must not be able to crash the thing it is diagnosing. /// mapping — escaped a hosted service and aborted the host.
/// </summary> /// </summary>
[Theory] [Theory]
[MemberData(nameof(UnexpectedFailures))] [MemberData(nameof(UnexpectedFailures))]
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure) public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
{ {
var validator = CreateValidator( var validator = CreateValidator(
new() { ["Grn:GlInventoryAccountCode"] = "1100" }, new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
new StubGl(failure)); new StubGeneralLedgerService(failure),
(GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None); await validator.StartAsync(CancellationToken.None);
} }
public static TheoryData<Exception> UnexpectedFailures() => public static TheoryData<Exception> UnexpectedFailures() =>
[ [
StubGeneralLedgerService.Unreachable(),
new NullReferenceException("items was null"), new NullReferenceException("items was null"),
new global::System.Text.Json.JsonException("unexpected shape"), new global::System.Text.Json.JsonException("unexpected shape"),
new InvalidOperationException("something else entirely"), new InvalidOperationException("something else entirely"),
new HttpRequestException("connection reset"), new HttpRequestException("connection reset"),
new OperationCanceledException(),
]; ];
[Fact] [Fact]
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled() public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
{ {
var validator = CreateValidator( var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
new StubGl(new OperationCanceledException()));
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); var validator = CreateValidator(new(), gl);
await validator.StartAsync(CancellationToken.None); await validator.StartAsync(CancellationToken.None);
Assert.Equal(0, gl.Calls); 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> /// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
[Fact] [Fact]
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay() 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))); 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 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); 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; using ERPCore.Services.Interfaces;
namespace ERPCore.Infra.Gl; namespace ERPCore.Infra.Gl;
/// <summary> /// <summary>
/// Startup guard for the GL chart of accounts. Every <c>Gl*Code</c> in configuration names an /// Startup guard for the GL chart of accounts. Every account mapped in <c>gl_account_config</c>
/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced /// must exist in GL; without this, an un-seeded account only surfaced as a 404 when some user
/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one /// happened to trigger that one flow, mid-transaction — one flow, one discovery, repeat. This
/// discovery, repeat. This asserts the whole set once, at boot. /// 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> /// <para>
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning /// <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 /// 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, IConfiguration configuration,
ILogger<GlAccountCodeValidator> logger) : IHostedService 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> /// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
public async Task StartAsync(CancellationToken ct) public async Task StartAsync(CancellationToken ct)
{ {
var expected = Sections string[] expected;
.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;
}
HashSet<string> present; 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 try
{ {
using var scope = services.CreateScope(); using var scope = services.CreateScope();
var mapping = scope.ServiceProvider.GetRequiredService<IGlAccountConfigService>();
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>(); 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); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout); 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 // 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, // 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) catch (Exception e) when (!ct.IsCancellationRequested)
{ {
logger.LogWarning(e, logger.LogWarning(e,
"Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.", "Could not verify the GL account mapping at startup; it is left unchecked.");
expected.Length);
return; return;
} }
@@ -78,7 +85,7 @@ public sealed class GlAccountCodeValidator(
if (present.Count == 0) if (present.Count == 0)
{ {
logger.LogWarning( 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.", "If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
expected.Length); expected.Length);
return; return;
@@ -88,10 +95,10 @@ public sealed class GlAccountCodeValidator(
if (missing.Length > 0) if (missing.Length > 0)
{ {
var message = 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 " + $"{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 " + "will fail against GL until the accounts are created in GL, or the mapping is corrected under " +
"corrected to match the codes GL actually uses."; "Settings → GL Accounts.";
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false)) if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
throw new InvalidOperationException(message); throw new InvalidOperationException(message);
@@ -100,7 +107,7 @@ public sealed class GlAccountCodeValidator(
return; 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; public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
@@ -47,7 +47,8 @@ public static class DataSeeder
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production), ("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 // 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 // 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 SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct); dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct); dirty |= await SeedGrnConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct); dirty |= await SeedNotificationConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct); if (dirty) await db.SaveChangesAsync(ct);
@@ -199,6 +201,49 @@ public static class DataSeeder
return true; 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) private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{ {
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false; 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); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{ {
b.Property<int>("GrnId") b.Property<int>("GrnId")
@@ -7117,6 +7190,16 @@ namespace ERPCore.Migrations
b.Navigation("SalaryComponent"); 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 => modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") 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<IBranchConfigService, BranchConfigService>();
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>(); builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>(); builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>(); builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>(); builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>(); builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
@@ -241,7 +242,7 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
try try
{ {
await DataSeeder.SeedAsync(db); await DataSeeder.SeedAsync(db, builder.Configuration);
} }
catch (Exception ex) catch (Exception ex)
{ {
+8 -11
View File
@@ -37,15 +37,13 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glGainAccountCode;
private readonly string _glLossAccountCode;
public AdjustmentService( public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator, IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration) IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_adjustments = adjustments; _adjustments = adjustments;
_warehouses = warehouses; _warehouses = warehouses;
@@ -59,9 +57,7 @@ public sealed class AdjustmentService : IAdjustmentService
_notifications = notifications; _notifications = notifications;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
} }
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement. /// <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); var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
if (gain <= 0 && loss <= 0) return null; if (gain <= 0 && loss <= 0) return null;
var glCodes = await _glAccounts.GetCodesAsync(ct);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
var lines = new List<GlJournalEntryLineRequest>(); var lines = new List<GlJournalEntryLineRequest>();
if (gain > 0) if (gain > 0)
{ {
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
} }
if (loss > 0) if (loss > 0)
{ {
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
} }
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest 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<StockLayer> _stockLayers;
private readonly IRepository<Batch> _batches; private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials; private readonly IRepository<Serial> _serials;
private readonly string _glInventoryAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glGainAccountCode;
private readonly string _glLossAccountCode;
public CountService( public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator, IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches, IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, IConfiguration configuration) IRepository<Serial> serials, IGlAccountConfigService glAccounts)
{ {
_counts = counts; _counts = counts;
_adjustments = adjustments; _adjustments = adjustments;
@@ -67,9 +65,7 @@ public sealed class CountService : ICountService
_stockLayers = stockLayers; _stockLayers = stockLayers;
_batches = batches; _batches = batches;
_serials = serials; _serials = serials;
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
} }
/// <summary>Same logic as <c>AdjustmentService</c>'s private method of the same name — see there for why /// <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); var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
if (gain <= 0 && loss <= 0) return null; if (gain <= 0 && loss <= 0) return null;
var glCodes = await _glAccounts.GetCodesAsync(ct);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
var lines = new List<GlJournalEntryLineRequest>(); var lines = new List<GlJournalEntryLineRequest>();
if (gain > 0) if (gain > 0)
{ {
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
} }
if (loss > 0) if (loss > 0)
{ {
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
} }
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest 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(); 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 // 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 — // 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. // `"items": null` deserializes over the property initialiser, so the null check is load-bearing.
return (data.Items ?? []) return (data.Items ?? [])
.Where(a => a is not null && !string.IsNullOrWhiteSpace(a.AccountCode)) .Where(a => a is not null && !string.IsNullOrWhiteSpace(a.AccountCode))
.Select(a => a.AccountCode) .Select(a => new GlAccount(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive))
.ToHashSet(StringComparer.Ordinal); .ToList();
} }
/// <summary> /// <summary>
@@ -118,6 +118,10 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
private sealed class GlAccountData private sealed class GlAccountData
{ {
public string AccountCode { get; set; } = string.Empty; 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 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 IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly string _glInventoryAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glVatRecoverableAccountCode;
private readonly string _glClearingAccountCode;
public GrnService( public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines, 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<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates, IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig, 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; _grns = grns;
_pos = pos; _pos = pos;
@@ -76,9 +74,7 @@ public sealed class GrnService : IGrnService
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_notifications = notifications; _notifications = notifications;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
} }
public async Task<PagedResponse<GrnSummaryDto>> ListAsync( 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 // with it, so inventory and the ledger never diverge (user-approved "fails
// atomically" behavior; residual risk if GL posts but the local commit still // atomically" behavior; residual risk if GL posts but the local commit still
// fails afterward is accepted for this phase, see the integration plan). // 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 period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var glLines = new List<GlJournalEntryLineRequest>(); var glLines = new List<GlJournalEntryLineRequest>();
decimal totalPayable = 0m; decimal totalPayable = 0m;
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId)) 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) 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; 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 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 IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly string _glAccountsPayableCode; private readonly IGlAccountConfigService _glAccounts;
public PurchaseInvoicePaymentService( public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl, IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration) ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
{ {
_invoices = invoices; _invoices = invoices;
_payments = payments; _payments = payments;
_gl = gl; _gl = gl;
_currentUser = currentUser; _currentUser = currentUser;
_uow = uow; _uow = uow;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty; _glAccounts = glAccounts;
} }
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default) 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 payment = await _uow.ExecuteInTransactionAsync(async token =>
{ {
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{ {
@@ -72,7 +73,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
Narration = $"Payment against invoice {invoice.DocNo}", Narration = $"Payment against invoice {invoice.DocNo}",
Lines = 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}") new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
] ]
}, token); }, token);
@@ -35,13 +35,12 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glClearingAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glAccountsPayableCode;
public PurchaseInvoiceService( public PurchaseInvoiceService(
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns, IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration) IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_invoices = invoices; _invoices = invoices;
_vendors = vendors; _vendors = vendors;
@@ -50,8 +49,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
_numbers = numbers; _numbers = numbers;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
} }
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default) 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 // Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection // SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync). // 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 period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{ {
@@ -196,8 +195,8 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
Narration = $"Vendor invoice recognized - {invoice.DocNo}", Narration = $"Vendor invoice recognized - {invoice.DocNo}",
Lines = Lines =
[ [
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"), new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}") new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
] ]
}, token); }, token);
@@ -43,15 +43,14 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glClearingAccountCode;
public PurchaseReturnService( public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses, IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines, IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo, IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration) IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_returns = returns; _returns = returns;
_vendors = vendors; _vendors = vendors;
@@ -68,8 +67,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
_notifications = notifications; _notifications = notifications;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
} }
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync( public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
@@ -221,6 +219,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
var totalValue = refs.Sum(r => r.Value); var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0) if (totalValue > 0)
{ {
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{ {
@@ -231,8 +230,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
Narration = $"Purchase return {docNo} — stock returned to vendor", Narration = $"Purchase return {docNo} — stock returned to vendor",
Lines = new List<GlJournalEntryLineRequest> Lines = new List<GlJournalEntryLineRequest>
{ {
new(_glClearingAccountCode, totalValue, 0m, $"Purchase return {docNo}"), new(glCodes[GlAccountRole.Clearing], totalValue, 0m, $"Purchase return {docNo}"),
new(_glInventoryAccountCode, 0m, totalValue, $"Purchase return {docNo} — inventory reduction") new(glCodes[GlAccountRole.Inventory], 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
} }
}, token); }, token);
ret.GlJournalNo = glResult.JournalNo; ret.GlJournalNo = glResult.JournalNo;
+11 -22
View File
@@ -43,18 +43,12 @@ public sealed class SalesDayEndService : ISalesDayEndService
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glCashAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glSalesRevenueAccountCode;
private readonly string _glSalesDiscountAccountCode;
private readonly string _glTaxPayableAccountCode;
private readonly string _glCogsAccountCode;
private readonly string _glInventoryAccountCode;
private readonly string _glAccountsReceivableCode;
public SalesDayEndService( public SalesDayEndService(
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles, IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers, 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; _dayEnds = dayEnds;
_slips = slips; _slips = slips;
@@ -66,13 +60,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
_currentUser = currentUser; _currentUser = currentUser;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glCashAccountCode = configuration["SalesDayEnd:GlCashAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_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;
} }
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default) 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) if (grandTotal > 0 || cogs > 0)
{ {
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(businessDate, token); var period = await _gl.GetPeriodByDateAsync(businessDate, token);
var glLines = new List<GlJournalEntryLineRequest>(); var glLines = new List<GlJournalEntryLineRequest>();
if (grandTotal > 0) if (grandTotal > 0)
{ {
if (paidTotal > 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) 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) if (discountTotal > 0)
glLines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, discountTotal, 0m, "Sales discount")); glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], discountTotal, 0m, "Sales discount"));
glLines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, subtotal, "Sales revenue")); glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, subtotal, "Sales revenue"));
if (taxTotal > 0) 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) if (cogs > 0)
{ {
glLines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold")); glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction")); glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
} }
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
@@ -29,11 +29,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly string _glAccountsReceivableCode; private readonly IGlAccountConfigService _glAccounts;
public SalesInvoicePaymentService( public SalesInvoicePaymentService(
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig, 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; _invoices = invoices;
_payments = payments; _payments = payments;
@@ -41,7 +41,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
_gl = gl; _gl = gl;
_currentUser = currentUser; _currentUser = currentUser;
_uow = uow; _uow = uow;
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty; _glAccounts = glAccounts;
} }
public async Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default) 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 // Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
// this transaction, before anything is committed — a GL rejection/timeout rolls // this transaction, before anything is committed — a GL rejection/timeout rolls
// the whole payment back with no partial local state. // the whole payment back with no partial local state.
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(paymentDay, token); var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{ {
@@ -79,7 +80,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
Lines = Lines =
[ [
new GlJournalEntryLineRequest(account.GlAccountCode, request.Amount, 0m, $"Payment against invoice {invoice.InvoiceNo}"), 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); }, 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 // 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. // 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 period = await _gl.GetPeriodByDateAsync(paymentDay, token);
var glLines = lines var glLines = lines
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration)) .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(); .ToList();
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest 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 ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glAccountsReceivableCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glSalesRevenueAccountCode;
private readonly string _glSalesDiscountAccountCode;
private readonly string _glTaxPayableAccountCode;
private readonly string _glCogsAccountCode;
private readonly string _glInventoryAccountCode;
public SalesPostingService( public SalesPostingService(
IRepository<SalesInvoice> invoices, IRepository<SalesInvoice> invoices,
@@ -44,7 +39,7 @@ public sealed class SalesPostingService : ISalesPostingService
ICurrentUser currentUser, ICurrentUser currentUser,
IUnitOfWork uow, IUnitOfWork uow,
IGeneralLedgerService gl, IGeneralLedgerService gl,
IConfiguration configuration) IGlAccountConfigService glAccounts)
{ {
_invoices = invoices; _invoices = invoices;
_slips = slips; _slips = slips;
@@ -56,14 +51,9 @@ public sealed class SalesPostingService : ISalesPostingService
_currentUser = currentUser; _currentUser = currentUser;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty; _glAccounts = glAccounts;
// Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice // 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. // 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) 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; if (invoice.NetPayable <= 0 && cogs <= 0) return;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var glCodes = await _glAccounts.GetCodesAsync(ct);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
var lines = new List<GlJournalEntryLineRequest>(); var lines = new List<GlJournalEntryLineRequest>();
if (invoice.NetPayable > 0) 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) if (invoice.DiscountTotal > 0)
lines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, invoice.DiscountTotal, 0m, "Sales discount")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], invoice.DiscountTotal, 0m, "Sales discount"));
lines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, invoice.Subtotal, "Sales revenue")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, invoice.Subtotal, "Sales revenue"));
if (invoice.TaxTotal > 0) 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) if (cogs > 0)
{ {
lines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction")); lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
} }
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
@@ -39,15 +39,14 @@ public sealed class SalesReturnService : ISalesReturnService
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glCogsAccountCode;
public SalesReturnService( public SalesReturnService(
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses, IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines, IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator, IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration) IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_returns = returns; _returns = returns;
_customers = customers; _customers = customers;
@@ -62,8 +61,7 @@ public sealed class SalesReturnService : ISalesReturnService
_currentUser = currentUser; _currentUser = currentUser;
_uow = uow; _uow = uow;
_gl = gl; _gl = gl;
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
} }
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync( public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
@@ -180,6 +178,7 @@ public sealed class SalesReturnService : ISalesReturnService
var totalValue = refs.Sum(r => r.Value); var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0) if (totalValue > 0)
{ {
var glCodes = await _glAccounts.GetCodesAsync(token);
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{ {
@@ -190,8 +189,8 @@ public sealed class SalesReturnService : ISalesReturnService
Narration = $"Sales return {docNo} — stock restocked", Narration = $"Sales return {docNo} — stock restocked",
Lines = new List<GlJournalEntryLineRequest> Lines = new List<GlJournalEntryLineRequest>
{ {
new(_glInventoryAccountCode, totalValue, 0m, $"Sales return {docNo}"), new(glCodes[GlAccountRole.Inventory], totalValue, 0m, $"Sales return {docNo}"),
new(_glCogsAccountCode, 0m, totalValue, $"Sales return {docNo} — COGS reversal") new(glCodes[GlAccountRole.Cogs], 0m, totalValue, $"Sales return {docNo} — COGS reversal")
} }
}, token); }, token);
ret.GlJournalNo = glResult.JournalNo; ret.GlJournalNo = glResult.JournalNo;
@@ -26,21 +26,19 @@ public sealed class FifoCostingService : IFifoCostingService
private readonly IRepository<JournalEntryStub> _journal; private readonly IRepository<JournalEntryStub> _journal;
private readonly ErpDbContext _db; private readonly ErpDbContext _db;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly string _glInventoryAccountCode; private readonly IGlAccountConfigService _glAccounts;
private readonly string _glClearingAccountCode;
public FifoCostingService( public FifoCostingService(
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<JournalEntryStub> journal, ErpDbContext db, IRepository<JournalEntryStub> journal, ErpDbContext db,
IGeneralLedgerService gl, IConfiguration configuration) IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_layers = layers; _layers = layers;
_ledger = ledger; _ledger = ledger;
_journal = journal; _journal = journal;
_db = db; _db = db;
_gl = gl; _gl = gl;
_glInventoryAccountCode = configuration["Fifo:GlInventoryAccountCode"] ?? string.Empty; _glAccounts = glAccounts;
_glClearingAccountCode = configuration["Fifo:GlClearingAccountCode"] ?? string.Empty;
} }
public async Task<StockLayer> CreateInboundLayerAsync( public async Task<StockLayer> CreateInboundLayerAsync(
@@ -273,10 +271,12 @@ public sealed class FifoCostingService : IFifoCostingService
}; };
await _ledger.AddAsync(entry, ct); await _ledger.AddAsync(entry, ct);
var glCodes = await _glAccounts.GetCodesAsync(ct);
// Inbound debits Inventory / credits Clearing; outbound reverses. // Inbound debits Inventory / credits Clearing; outbound reverses.
var (debit, credit) = direction == Direction.In var (debit, credit) = direction == Direction.In
? (_glInventoryAccountCode, _glClearingAccountCode) ? (glCodes[GlAccountRole.Inventory], glCodes[GlAccountRole.Clearing])
: (_glClearingAccountCode, _glInventoryAccountCode); : (glCodes[GlAccountRole.Clearing], glCodes[GlAccountRole.Inventory]);
// Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13). // Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13).
await _journal.AddAsync(new JournalEntryStub await _journal.AddAsync(new JournalEntryStub