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:
@@ -1,8 +1,8 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.System.Time;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
|
||||
/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
|
||||
/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
|
||||
/// The validator is advisory: enforcement lives in <see cref="ERPCore.Services.GlAccountConfigService"/>,
|
||||
/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
|
||||
/// it <b>cannot abort startup</b>. An earlier revision threw on a missing code and core-dumped
|
||||
/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
|
||||
/// </summary>
|
||||
public class GlAccountCodeValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
|
||||
public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
||||
new StubGl(["1100", "2000", "4000"]));
|
||||
new(),
|
||||
new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1100"),
|
||||
StubGeneralLedgerService.Postable("2000")
|
||||
]),
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The default must not block startup. Throwing here cost an outage on 2026-10-07 — a config
|
||||
/// mismatch that had existed all along became a core-dump the moment the check shipped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault()
|
||||
public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new()
|
||||
{
|
||||
["Grn:GlInventoryAccountCode"] = "1100",
|
||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
||||
["Adjustment:GlLossAccountCode"] = "5100"
|
||||
},
|
||||
new StubGl(["1000-01"]));
|
||||
new(),
|
||||
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
|
||||
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new()
|
||||
{
|
||||
["GeneralLedgerService:FailOnMissingAccountCodes"] = "true",
|
||||
["Grn:GlInventoryAccountCode"] = "1100",
|
||||
["SalesDayEnd:GlCashAccountCode"] = "1000-01",
|
||||
["Adjustment:GlLossAccountCode"] = "5100"
|
||||
},
|
||||
new StubGl(["1100"]));
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
|
||||
(GlAccountRole.Inventory, "1100"),
|
||||
(GlAccountRole.Cash, "1000-01"),
|
||||
(GlAccountRole.InventoryLoss, "5100"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
|
||||
Assert.Contains("1000-01", ex.Message);
|
||||
Assert.Contains("5100", ex.Message);
|
||||
Assert.Contains("2 of 3", ex.Message);
|
||||
Assert.DoesNotContain("1100,", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
|
||||
/// validator would call every configured code missing and refuse to start.
|
||||
/// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
|
||||
/// "everything is missing" would refuse to start on a contract change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
|
||||
public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
|
||||
new StubGl([]));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
||||
new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService([]),
|
||||
(GlAccountRole.Inventory, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The earlier catch filter only matched DomainException/OperationCanceledException, so anything
|
||||
/// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host.
|
||||
/// A diagnostic must not be able to crash the thing it is diagnosing.
|
||||
/// The catch filter once matched only DomainException/OperationCanceledException, so anything
|
||||
/// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
|
||||
/// mapping — escaped a hosted service and aborted the host.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(UnexpectedFailures))]
|
||||
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
||||
new StubGl(failure));
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
new StubGeneralLedgerService(failure),
|
||||
(GlAccountRole.Inventory, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public static TheoryData<Exception> UnexpectedFailures() =>
|
||||
[
|
||||
StubGeneralLedgerService.Unreachable(),
|
||||
new NullReferenceException("items was null"),
|
||||
new global::System.Text.Json.JsonException("unexpected shape"),
|
||||
new InvalidOperationException("something else entirely"),
|
||||
new HttpRequestException("connection reset"),
|
||||
new OperationCanceledException(),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
|
||||
public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
|
||||
{
|
||||
var validator = CreateValidator(
|
||||
new() { ["Grn:GlInventoryAccountCode"] = "1100" },
|
||||
new StubGl(new OperationCanceledException()));
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.</summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
|
||||
{
|
||||
var gl = new StubGl(["1100"]);
|
||||
var validator = CreateValidator(
|
||||
new()
|
||||
{
|
||||
["Grn:GlInventoryAccountCode"] = "1100",
|
||||
["Grn:GlPostingEnabled"] = "true",
|
||||
["Grn:SomeOtherCode"] = "not-an-account"
|
||||
},
|
||||
gl);
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, gl.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
|
||||
{
|
||||
var gl = new StubGl(["1100"]);
|
||||
var validator = CreateValidator(new(), gl);
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, gl.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Duplicate codes across roles are one account to verify, not several.</summary>
|
||||
[Fact]
|
||||
public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
|
||||
{
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
|
||||
var validator = CreateValidator(
|
||||
new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
|
||||
gl,
|
||||
(GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
|
||||
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
Assert.Equal(1, gl.Calls);
|
||||
}
|
||||
|
||||
/// <summary>Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.</summary>
|
||||
[Fact]
|
||||
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
|
||||
@@ -165,39 +138,14 @@ public class GlAccountCodeValidatorTests
|
||||
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
private static GlAccountCodeValidator CreateValidator(Dictionary<string, string?> settings, IGeneralLedgerService gl)
|
||||
private static GlAccountCodeValidator CreateValidator(
|
||||
Dictionary<string, string?> settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
|
||||
var services = new ServiceCollection()
|
||||
.AddScoped<IGeneralLedgerService>(_ => gl)
|
||||
.AddScoped<IGlAccountConfigService>(_ => new StubGlAccountConfigService(mapping))
|
||||
.BuildServiceProvider();
|
||||
return new GlAccountCodeValidator(services, configuration, NullLogger<GlAccountCodeValidator>.Instance);
|
||||
}
|
||||
|
||||
private sealed class StubGl(HashSet<string>? codes = null, Exception? throws = null) : IGeneralLedgerService
|
||||
{
|
||||
private readonly HashSet<string> _codes = codes ?? [];
|
||||
private readonly Exception? _throws = throws;
|
||||
|
||||
public StubGl(Exception throws) : this(null, throws) { }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return _throws is not null ? Task.FromException<HashSet<string>>(_throws) : Task.FromResult(_codes);
|
||||
}
|
||||
|
||||
public Task<ERPCore.Infra.Gl.GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The point of this service is that a mapping GL cannot post to never reaches the database.
|
||||
/// Validation at the point of entry is what the old appsettings approach could not do: those codes
|
||||
/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
|
||||
/// existed in GL at all.
|
||||
/// </summary>
|
||||
public class GlAccountConfigServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var gl = new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||
StubGeneralLedgerService.Postable("2000", "GRNI")
|
||||
]);
|
||||
var service = CreateService(db, gl);
|
||||
var before = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var result = await service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
|
||||
|
||||
Assert.True(result.RowVersion > before);
|
||||
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||
Assert.Equal("1100", codes[GlAccountRole.Inventory]);
|
||||
Assert.Equal("2000", codes[GlAccountRole.Clearing]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||
Assert.Equal(422, ex.StatusCode);
|
||||
Assert.Contains("9999", ex.Message);
|
||||
Assert.Contains("Clearing", ex.Message);
|
||||
await AssertNothingStoredAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GL rejects a journal line against a non-postable account, so storing one only defers the
|
||||
/// failure to posting time. The auto-created <c>1000</c> Bank/Cash header is exactly this trap.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RejectsANonPostableAccount()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
|
||||
]));
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||
Assert.Contains("not postable", ex.Message);
|
||||
await AssertNothingStoredAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
|
||||
await AssertNothingStoredAsync(db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
|
||||
Assert.Equal(503, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_RejectsAStaleIfMatch()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => service.UpdateAsync(
|
||||
new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
|
||||
Assert.Equal(412, ex.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>An empty string clears a role rather than storing "" and posting it to GL.</summary>
|
||||
[Fact]
|
||||
public async Task UpdateAsync_TreatsBlankAsCleared()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
|
||||
var service = CreateService(db, gl);
|
||||
var rowVersion = (await service.GetAsync()).RowVersion;
|
||||
|
||||
await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
|
||||
|
||||
var codes = await CreateService(db, gl).GetCodesAsync();
|
||||
Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
|
||||
}
|
||||
|
||||
/// <summary>Reading an unmapped role names the role, instead of posting "" and letting GL 404.</summary>
|
||||
[Fact]
|
||||
public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||
|
||||
var codes = await service.GetCodesAsync();
|
||||
var ex = Assert.Throws<DomainException>(() => codes[GlAccountRole.Cogs]);
|
||||
|
||||
Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
|
||||
Assert.Contains("Cogs", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>One request can post through several services; they should not each re-read the row.</summary>
|
||||
[Fact]
|
||||
public async Task GetCodesAsync_ReadsOncePerScope()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([]));
|
||||
|
||||
var first = await service.GetCodesAsync();
|
||||
var second = await service.GetCodesAsync();
|
||||
|
||||
Assert.Same(first, second);
|
||||
}
|
||||
|
||||
/// <summary>GL being down must not stop the saved mapping from rendering on the settings screen.</summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
|
||||
var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
|
||||
|
||||
var result = await service.GetAsync();
|
||||
|
||||
Assert.True(result.Value.GlUnavailable);
|
||||
Assert.Empty(result.Value.Options);
|
||||
var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
|
||||
Assert.Equal("1100", inventory.AccountCode);
|
||||
}
|
||||
|
||||
/// <summary>The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.</summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync(c =>
|
||||
{
|
||||
c.InventoryAccountCode = "1100";
|
||||
c.CashAccountCode = "1000-01";
|
||||
});
|
||||
var service = CreateService(db, new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
|
||||
]));
|
||||
|
||||
var result = await service.GetAsync();
|
||||
|
||||
Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
|
||||
Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
|
||||
}
|
||||
|
||||
/// <summary>The picker must only offer what validation will accept.</summary>
|
||||
[Fact]
|
||||
public async Task GetAsync_OffersOnlyPostableActiveAccounts()
|
||||
{
|
||||
await using var db = await CreateSeededContextAsync();
|
||||
var service = CreateService(db, new StubGeneralLedgerService([
|
||||
StubGeneralLedgerService.Postable("1100", "Inventory"),
|
||||
StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
|
||||
new GlAccount("1200", "Retired", 1, true, false)
|
||||
]));
|
||||
|
||||
var options = (await service.GetAsync()).Value.Options;
|
||||
|
||||
Assert.Equal(["1100"], options.Select(o => o.AccountCode));
|
||||
}
|
||||
|
||||
private static async Task AssertNothingStoredAsync(ErpDbContext db)
|
||||
{
|
||||
var row = await new Repository<GlAccountConfig>(db).GetByIdAsync(GlAccountConfig.SingletonId);
|
||||
Assert.Null(row!.InventoryAccountCode);
|
||||
Assert.Null(row.ClearingAccountCode);
|
||||
Assert.Null(row.CashAccountCode);
|
||||
}
|
||||
|
||||
private static async Task<ErpDbContext> CreateSeededContextAsync(Action<GlAccountConfig>? configure = null)
|
||||
{
|
||||
var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
|
||||
db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
|
||||
|
||||
var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
|
||||
configure?.Invoke(config);
|
||||
db.GlAccountConfig.Add(config);
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
|
||||
new(new Repository<GlAccountConfig>(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for the external GL service. Only <see cref="ListAccountsAsync"/> is implemented —
|
||||
/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
|
||||
/// doing so fails loudly rather than against a silently empty fake.
|
||||
/// </summary>
|
||||
internal sealed class StubGeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private readonly IReadOnlyList<GlAccount> _accounts;
|
||||
private readonly Exception? _throws;
|
||||
|
||||
public StubGeneralLedgerService(IReadOnlyList<GlAccount> accounts) => _accounts = accounts;
|
||||
public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>A postable, active account — the ordinary case.</summary>
|
||||
public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
|
||||
|
||||
/// <summary>A header/control account. GL rejects journal lines against these.</summary>
|
||||
public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
|
||||
|
||||
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return _throws is not null
|
||||
? Task.FromException<IReadOnlyList<GlAccount>>(_throws)
|
||||
: Task.FromResult(_accounts);
|
||||
}
|
||||
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public static DomainException Unreachable() =>
|
||||
new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
|
||||
}
|
||||
|
||||
/// <summary>Returns a fixed mapping, for exercising the startup validator without a database.</summary>
|
||||
internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
|
||||
{
|
||||
public Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default) =>
|
||||
Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
|
||||
|
||||
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ERPCore.Common.Http.ETagged<ERPCore.Dtos.Config.GlAccountConfigDto>> UpdateAsync(
|
||||
ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Role → GL account mapping endpoints.
|
||||
/// <para>
|
||||
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
|
||||
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
|
||||
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
|
||||
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
|
||||
/// and the server-side validation work from the same list.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Route("api/v1/gl-account-config")]
|
||||
[Authorize(Policy = NavPolicies.Prefix + "settings")]
|
||||
public sealed class GlAccountConfigController : ApiControllerBase
|
||||
{
|
||||
private readonly IGlAccountConfigService _config;
|
||||
|
||||
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
|
||||
{
|
||||
var result = await _config.GetAsync(ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
|
||||
/// not be reached, since a mapping that could not be verified is not stored.
|
||||
/// </summary>
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<ActionResult<GlAccountConfigDto>> Update(
|
||||
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var expected = RequireIfMatch();
|
||||
var result = await _config.UpdateAsync(request, expected, ct);
|
||||
SetETag(result.RowVersion);
|
||||
return Ok(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>One selectable GL account, for the mapping pickers.</summary>
|
||||
public sealed record GlAccountOptionDto(string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
|
||||
|
||||
/// <summary>
|
||||
/// One role's current mapping. <see cref="ExistsInGl"/> is false when the saved code is not in GL's
|
||||
/// chart of accounts — which is the state 11 of these were found in on 2026-10-07. The screen shows
|
||||
/// those as flagged rather than blank, so the mapping that is actually stored stays visible.
|
||||
/// </summary>
|
||||
public sealed record GlAccountMappingDto(
|
||||
string Role, string? AccountCode, string? AccountName, bool ExistsInGl, bool IsPostable);
|
||||
|
||||
/// <summary>
|
||||
/// GL account mapping resource. Carries the options alongside the mappings so the picker and the
|
||||
/// server's validation list are the same data — a code the user can choose is a code that will save.
|
||||
/// <para>
|
||||
/// <see cref="GlUnavailable"/> is true when GL could not be reached: mappings still render from the
|
||||
/// database, <see cref="Options"/> is empty, and saving will be refused until GL answers.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record GlAccountConfigDto(
|
||||
IReadOnlyList<GlAccountMappingDto> Mappings,
|
||||
IReadOnlyList<GlAccountOptionDto> Options,
|
||||
bool GlUnavailable,
|
||||
DateTime? UpdatedAt,
|
||||
int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the mapping. Every role is sent on every save; null or empty clears a role.
|
||||
/// <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// </summary>
|
||||
public sealed class UpdateGlAccountConfigRequest
|
||||
{
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? VatRecoverableAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsReceivableCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? ClearingAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? TaxPayableAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsPayableCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesRevenueAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesDiscountAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryGainAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Startup guard for the GL chart of accounts. Every <c>Gl*Code</c> in configuration names an
|
||||
/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced
|
||||
/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one
|
||||
/// discovery, repeat. This asserts the whole set once, at boot.
|
||||
/// Startup guard for the GL chart of accounts. Every account mapped in <c>gl_account_config</c>
|
||||
/// must exist in GL; without this, an un-seeded account only surfaced as a 404 when some user
|
||||
/// happened to trigger that one flow, mid-transaction — one flow, one discovery, repeat. This
|
||||
/// asserts the whole set once, at boot.
|
||||
/// <para>
|
||||
/// Advisory only. Enforcement lives at the point of entry — <c>GlAccountConfigService.UpdateAsync</c>
|
||||
/// refuses to store a code GL does not have or cannot post to — so this is a net for accounts that
|
||||
/// disappeared from GL *after* being mapped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning
|
||||
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
|
||||
@@ -26,39 +32,41 @@ public sealed class GlAccountCodeValidator(
|
||||
IConfiguration configuration,
|
||||
ILogger<GlAccountCodeValidator> logger) : IHostedService
|
||||
{
|
||||
/// <summary>Configuration sections that carry GL account codes — see appsettings.json.</summary>
|
||||
private static readonly string[] Sections =
|
||||
["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"];
|
||||
|
||||
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
var expected = Sections
|
||||
.SelectMany(s => configuration.GetSection(s).GetChildren())
|
||||
.Where(c => c.Key.StartsWith("Gl", StringComparison.Ordinal) && c.Key.EndsWith("Code", StringComparison.Ordinal))
|
||||
.Select(c => c.Value)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (expected.Length == 0)
|
||||
{
|
||||
logger.LogWarning("No GL account codes are configured — skipping chart-of-accounts verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] expected;
|
||||
HashSet<string> present;
|
||||
|
||||
// The database read sits inside the catch-all too: a DB hiccup here would otherwise escape
|
||||
// a hosted service and reopen the startup-abort hole this class already caused once.
|
||||
try
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var mapping = scope.ServiceProvider.GetRequiredService<IGlAccountConfigService>();
|
||||
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
|
||||
|
||||
var codes = await mapping.GetCodesAsync(ct);
|
||||
expected = Enum.GetValues<GlAccountRole>()
|
||||
.Select(codes.GetOrNull)
|
||||
.Where(c => c is not null)
|
||||
.Select(c => c!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (expected.Length == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"No GL accounts are mapped — skipping chart-of-accounts verification. "
|
||||
+ "Map them under Settings → GL Accounts; posting flows will fail until then.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
present = await gl.ListAccountCodesAsync(timeout.Token);
|
||||
present = (await gl.ListAccountsAsync(timeout.Token)).Select(a => a.AccountCode).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
|
||||
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
|
||||
@@ -67,8 +75,7 @@ public sealed class GlAccountCodeValidator(
|
||||
catch (Exception e) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(e,
|
||||
"Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.",
|
||||
expected.Length);
|
||||
"Could not verify the GL account mapping at startup; it is left unchecked.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +85,7 @@ public sealed class GlAccountCodeValidator(
|
||||
if (present.Count == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"GL returned no account codes; {Count} configured account code(s) left unverified. " +
|
||||
"GL returned no account codes; {Count} mapped account code(s) left unverified. " +
|
||||
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
|
||||
expected.Length);
|
||||
return;
|
||||
@@ -88,10 +95,10 @@ public sealed class GlAccountCodeValidator(
|
||||
if (missing.Length > 0)
|
||||
{
|
||||
var message =
|
||||
$"General Ledger is missing {missing.Length} of {expected.Length} configured account code(s): " +
|
||||
$"General Ledger is missing {missing.Length} of {expected.Length} mapped account code(s): " +
|
||||
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
|
||||
"will fail against GL until they are seeded in GL's chart of accounts, or the Gl*Code settings are " +
|
||||
"corrected to match the codes GL actually uses.";
|
||||
"will fail against GL until the accounts are created in GL, or the mapping is corrected under " +
|
||||
"Settings → GL Accounts.";
|
||||
|
||||
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
|
||||
throw new InvalidOperationException(message);
|
||||
@@ -100,7 +107,7 @@ public sealed class GlAccountCodeValidator(
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("GL account codes verified: {Count} present.", expected.Length);
|
||||
logger.LogInformation("GL account mapping verified: {Count} account code(s) present.", expected.Length);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
@@ -47,7 +47,8 @@ public static class DataSeeder
|
||||
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
||||
];
|
||||
|
||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
||||
public static async Task SeedAsync(
|
||||
ErpDbContext db, IConfiguration? configuration = null, CancellationToken ct = default)
|
||||
{
|
||||
// Must come first, and must save on its own: every branch-scoped entity has a
|
||||
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
|
||||
@@ -60,6 +61,7 @@ public static class DataSeeder
|
||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||
|
||||
if (dirty) await db.SaveChangesAsync(ct);
|
||||
@@ -199,6 +201,49 @@ public static class DataSeeder
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
|
||||
/// values once so an existing deployment keeps the mapping it has been running with.
|
||||
/// <para>
|
||||
/// Those keys are <b>seed-only</b>: nothing reads them at runtime any more. Leaving a runtime
|
||||
/// fallback in place would restore the two-sources-of-truth problem this table exists to end —
|
||||
/// after seeding, <c>gl_account_config</c> is authoritative and is edited from
|
||||
/// Settings → GL Accounts, not by redeploying.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately does not call GL: seeding runs inside the startup block that aborts the app on
|
||||
/// failure (see Program.cs), so it must not depend on another service being up. The codes are
|
||||
/// verified separately — advisory at boot by <c>GlAccountCodeValidator</c>, and enforced on
|
||||
/// every save by <c>GlAccountConfigService.UpdateAsync</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static async Task<bool> SeedGlAccountConfigAsync(
|
||||
ErpDbContext db, IConfiguration? configuration, CancellationToken ct)
|
||||
{
|
||||
if (await db.GlAccountConfig.AnyAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)) return false;
|
||||
|
||||
string? Legacy(string key) =>
|
||||
string.IsNullOrWhiteSpace(configuration?[key]) ? null : configuration[key]!.Trim();
|
||||
|
||||
db.GlAccountConfig.Add(new GlAccountConfig
|
||||
{
|
||||
ConfigId = GlAccountConfig.SingletonId,
|
||||
InventoryAccountCode = Legacy("Grn:GlInventoryAccountCode"),
|
||||
VatRecoverableAccountCode = Legacy("Grn:GlVatRecoverableAccountCode"),
|
||||
AccountsReceivableCode = Legacy("SalesInvoice:GlAccountsReceivableCode"),
|
||||
ClearingAccountCode = Legacy("Grn:GlClearingAccountCode"),
|
||||
TaxPayableAccountCode = Legacy("SalesDayEnd:GlTaxPayableAccountCode"),
|
||||
AccountsPayableCode = Legacy("PurchaseInvoice:GlAccountsPayableCode"),
|
||||
SalesRevenueAccountCode = Legacy("SalesDayEnd:GlSalesRevenueAccountCode"),
|
||||
SalesDiscountAccountCode = Legacy("SalesDayEnd:GlSalesDiscountAccountCode"),
|
||||
InventoryGainAccountCode = Legacy("Adjustment:GlGainAccountCode"),
|
||||
CogsAccountCode = Legacy("SalesDayEnd:GlCogsAccountCode"),
|
||||
InventoryLossAccountCode = Legacy("Adjustment:GlLossAccountCode"),
|
||||
CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode")
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGlAccountConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "gl_account_config",
|
||||
columns: table => new
|
||||
{
|
||||
ConfigId = table.Column<int>(type: "int", nullable: false),
|
||||
InventoryAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
VatRecoverableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
AccountsReceivableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
ClearingAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
TaxPayableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
AccountsPayableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
SalesRevenueAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
SalesDiscountAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
InventoryGainAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
CogsAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
InventoryLossAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
CashAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
UpdatedBy = table.Column<int>(type: "int", nullable: true),
|
||||
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_gl_account_config", x => x.ConfigId);
|
||||
table.CheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_gl_account_config_users_UpdatedBy",
|
||||
column: x => x.UpdatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_gl_account_config_UpdatedBy",
|
||||
table: "gl_account_config",
|
||||
column: "UpdatedBy");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "gl_account_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1421,6 +1421,79 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("hr_employment_types", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
|
||||
{
|
||||
b.Property<int>("ConfigId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("AccountsPayableCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("AccountsReceivableCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("CashAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("ClearingAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("CogsAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("InventoryAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("InventoryGainAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("InventoryLossAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("int unsigned");
|
||||
|
||||
b.Property<string>("SalesDiscountAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("SalesRevenueAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<string>("TaxPayableAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("UpdatedBy")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("VatRecoverableAccountCode")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("varchar(20)");
|
||||
|
||||
b.HasKey("ConfigId");
|
||||
|
||||
b.HasIndex("UpdatedBy");
|
||||
|
||||
b.ToTable("gl_account_config", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||
{
|
||||
b.Property<int>("GrnId")
|
||||
@@ -7117,6 +7190,16 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("SalaryComponent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("UpdatedBy")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("UpdatedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")
|
||||
|
||||
@@ -119,6 +119,7 @@ builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||||
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||||
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
|
||||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||||
@@ -241,7 +242,7 @@ using (var scope = app.Services.CreateScope())
|
||||
await db.Database.MigrateAsync();
|
||||
try
|
||||
{
|
||||
await DataSeeder.SeedAsync(db);
|
||||
await DataSeeder.SeedAsync(db, builder.Configuration);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -37,15 +37,13 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glGainAccountCode;
|
||||
private readonly string _glLossAccountCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public AdjustmentService(
|
||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
@@ -59,9 +57,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
_notifications = notifications;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
|
||||
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
/// <summary>Builds and posts the GL journal for a <see cref="StockAdjustment"/>'s stock movement.
|
||||
@@ -74,17 +70,18 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||
if (gain <= 0 && loss <= 0) return null;
|
||||
|
||||
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||
var lines = new List<GlJournalEntryLineRequest>();
|
||||
if (gain > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
}
|
||||
if (loss > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
}
|
||||
|
||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
|
||||
@@ -41,16 +41,14 @@ public sealed class CountService : ICountService
|
||||
private readonly IRepository<StockLayer> _stockLayers;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glGainAccountCode;
|
||||
private readonly string _glLossAccountCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public CountService(
|
||||
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
|
||||
IRepository<Serial> serials, IConfiguration configuration)
|
||||
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_counts = counts;
|
||||
_adjustments = adjustments;
|
||||
@@ -67,9 +65,7 @@ public sealed class CountService : ICountService
|
||||
_stockLayers = stockLayers;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty;
|
||||
_glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
/// <summary>Same logic as <c>AdjustmentService</c>'s private method of the same name — see there for why
|
||||
@@ -81,17 +77,18 @@ public sealed class CountService : ICountService
|
||||
var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value);
|
||||
if (gain <= 0 && loss <= 0) return null;
|
||||
|
||||
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||
var lines = new List<GlJournalEntryLineRequest>();
|
||||
if (gain > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain"));
|
||||
}
|
||||
if (loss > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease"));
|
||||
}
|
||||
|
||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
||||
}
|
||||
|
||||
public async Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct)
|
||||
public async Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct)
|
||||
{
|
||||
// No page/pageSize: GL pages this endpoint only when both are supplied, so this is the
|
||||
// full, flat chart of accounts (child codes like "1000-01" are rows of their own —
|
||||
@@ -59,8 +59,8 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
// `"items": null` deserializes over the property initialiser, so the null check is load-bearing.
|
||||
return (data.Items ?? [])
|
||||
.Where(a => a is not null && !string.IsNullOrWhiteSpace(a.AccountCode))
|
||||
.Select(a => a.AccountCode)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
.Select(a => new GlAccount(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -118,6 +118,10 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
private sealed class GlAccountData
|
||||
{
|
||||
public string AccountCode { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public short AccountTypeId { get; set; }
|
||||
public bool IsPostable { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlBankAccountData
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <inheritdoc cref="IGlAccountConfigService"/>
|
||||
public sealed class GlAccountConfigService : IGlAccountConfigService
|
||||
{
|
||||
private readonly IRepository<GlAccountConfig> _config;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
/// <summary>Scope-lifetime cache — see <see cref="IGlAccountConfigService.GetCodesAsync"/>.</summary>
|
||||
private GlAccountCodes? _cached;
|
||||
|
||||
public GlAccountConfigService(
|
||||
IRepository<GlAccountConfig> config, IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<GlAccountConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("GL account configuration has not been seeded.");
|
||||
|
||||
// Read-only screen data: GL being down must not stop the saved mapping from rendering.
|
||||
IReadOnlyList<GlAccount> accounts = [];
|
||||
var glUnavailable = false;
|
||||
try
|
||||
{
|
||||
accounts = await _gl.ListAccountsAsync(ct);
|
||||
}
|
||||
catch (DomainException)
|
||||
{
|
||||
glUnavailable = true;
|
||||
}
|
||||
|
||||
return new ETagged<GlAccountConfigDto>(Map(config, accounts, glUnavailable), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<GlAccountConfigDto>> UpdateAsync(
|
||||
UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(GlAccountConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("GL account configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
var submitted = Submitted(request);
|
||||
|
||||
// Fails closed, unlike GlAccountCodeValidator at startup: there we cannot verify and must
|
||||
// still serve, here we cannot verify and must not store. Letting an unchecked mapping
|
||||
// through is how the appsettings version went unnoticed for months.
|
||||
var accounts = await _gl.ListAccountsAsync(ct);
|
||||
if (accounts.Count == 0)
|
||||
throw new DomainException(
|
||||
ErrorCodes.GlAccountInvalid,
|
||||
"GL returned an empty chart of accounts, so the mapping cannot be verified. Try again once GL has accounts.",
|
||||
503);
|
||||
|
||||
var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal);
|
||||
var problems = new List<string>();
|
||||
foreach (var (role, code) in submitted)
|
||||
{
|
||||
if (code is null) continue;
|
||||
|
||||
if (!byCode.TryGetValue(code, out var account))
|
||||
problems.Add($"{role}: account '{code}' does not exist in GL");
|
||||
else if (!account.IsPostable)
|
||||
// GL rejects a journal line against a non-postable account, so storing one just
|
||||
// moves the failure to posting time. Control/header accounts are the usual trap.
|
||||
problems.Add($"{role}: account '{code}' ({account.AccountName}) is not postable");
|
||||
}
|
||||
|
||||
if (problems.Count > 0)
|
||||
throw new DomainException(
|
||||
ErrorCodes.GlAccountInvalid,
|
||||
"The GL account mapping was rejected — " + string.Join("; ", problems) + ".",
|
||||
422);
|
||||
|
||||
config.InventoryAccountCode = submitted[GlAccountRole.Inventory];
|
||||
config.VatRecoverableAccountCode = submitted[GlAccountRole.VatRecoverable];
|
||||
config.AccountsReceivableCode = submitted[GlAccountRole.AccountsReceivable];
|
||||
config.ClearingAccountCode = submitted[GlAccountRole.Clearing];
|
||||
config.TaxPayableAccountCode = submitted[GlAccountRole.TaxPayable];
|
||||
config.AccountsPayableCode = submitted[GlAccountRole.AccountsPayable];
|
||||
config.SalesRevenueAccountCode = submitted[GlAccountRole.SalesRevenue];
|
||||
config.SalesDiscountAccountCode = submitted[GlAccountRole.SalesDiscount];
|
||||
config.InventoryGainAccountCode = submitted[GlAccountRole.InventoryGain];
|
||||
config.CogsAccountCode = submitted[GlAccountRole.Cogs];
|
||||
config.InventoryLossAccountCode = submitted[GlAccountRole.InventoryLoss];
|
||||
config.CashAccountCode = submitted[GlAccountRole.Cash];
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
}
|
||||
|
||||
_cached = null;
|
||||
return new ETagged<GlAccountConfigDto>(Map(config, accounts, glUnavailable: false), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_cached is not null) return _cached;
|
||||
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("GL account configuration has not been seeded.");
|
||||
|
||||
return _cached = new GlAccountCodes(Current(config));
|
||||
}
|
||||
|
||||
/// <summary>Role → stored code, normalising blanks to null.</summary>
|
||||
private static Dictionary<GlAccountRole, string?> Current(GlAccountConfig c) => new()
|
||||
{
|
||||
[GlAccountRole.Inventory] = Trim(c.InventoryAccountCode),
|
||||
[GlAccountRole.VatRecoverable] = Trim(c.VatRecoverableAccountCode),
|
||||
[GlAccountRole.AccountsReceivable] = Trim(c.AccountsReceivableCode),
|
||||
[GlAccountRole.Clearing] = Trim(c.ClearingAccountCode),
|
||||
[GlAccountRole.TaxPayable] = Trim(c.TaxPayableAccountCode),
|
||||
[GlAccountRole.AccountsPayable] = Trim(c.AccountsPayableCode),
|
||||
[GlAccountRole.SalesRevenue] = Trim(c.SalesRevenueAccountCode),
|
||||
[GlAccountRole.SalesDiscount] = Trim(c.SalesDiscountAccountCode),
|
||||
[GlAccountRole.InventoryGain] = Trim(c.InventoryGainAccountCode),
|
||||
[GlAccountRole.Cogs] = Trim(c.CogsAccountCode),
|
||||
[GlAccountRole.InventoryLoss] = Trim(c.InventoryLossAccountCode),
|
||||
[GlAccountRole.Cash] = Trim(c.CashAccountCode)
|
||||
};
|
||||
|
||||
private static Dictionary<GlAccountRole, string?> Submitted(UpdateGlAccountConfigRequest r) => new()
|
||||
{
|
||||
[GlAccountRole.Inventory] = Trim(r.InventoryAccountCode),
|
||||
[GlAccountRole.VatRecoverable] = Trim(r.VatRecoverableAccountCode),
|
||||
[GlAccountRole.AccountsReceivable] = Trim(r.AccountsReceivableCode),
|
||||
[GlAccountRole.Clearing] = Trim(r.ClearingAccountCode),
|
||||
[GlAccountRole.TaxPayable] = Trim(r.TaxPayableAccountCode),
|
||||
[GlAccountRole.AccountsPayable] = Trim(r.AccountsPayableCode),
|
||||
[GlAccountRole.SalesRevenue] = Trim(r.SalesRevenueAccountCode),
|
||||
[GlAccountRole.SalesDiscount] = Trim(r.SalesDiscountAccountCode),
|
||||
[GlAccountRole.InventoryGain] = Trim(r.InventoryGainAccountCode),
|
||||
[GlAccountRole.Cogs] = Trim(r.CogsAccountCode),
|
||||
[GlAccountRole.InventoryLoss] = Trim(r.InventoryLossAccountCode),
|
||||
[GlAccountRole.Cash] = Trim(r.CashAccountCode)
|
||||
};
|
||||
|
||||
private static string? Trim(string? code) => string.IsNullOrWhiteSpace(code) ? null : code.Trim();
|
||||
|
||||
private static GlAccountConfigDto Map(
|
||||
GlAccountConfig config, IReadOnlyList<GlAccount> accounts, bool glUnavailable)
|
||||
{
|
||||
var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal);
|
||||
|
||||
var mappings = Current(config).Select(kv =>
|
||||
{
|
||||
var found = kv.Value is not null && byCode.TryGetValue(kv.Value, out var a) ? a : null;
|
||||
return new GlAccountMappingDto(
|
||||
kv.Key.ToString(),
|
||||
kv.Value,
|
||||
found?.AccountName,
|
||||
// Unknown while GL is down, rather than falsely "missing".
|
||||
ExistsInGl: glUnavailable || found is not null,
|
||||
IsPostable: glUnavailable || found?.IsPostable == true);
|
||||
}).ToList();
|
||||
|
||||
var options = accounts
|
||||
.Where(a => a.IsPostable && a.IsActive)
|
||||
.OrderBy(a => a.AccountCode, StringComparer.Ordinal)
|
||||
.Select(a => new GlAccountOptionDto(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive))
|
||||
.ToList();
|
||||
|
||||
return new GlAccountConfigDto(mappings, options, glUnavailable, config.UpdatedAt, config.UpdatedBy);
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,7 @@ public sealed class GrnService : IGrnService
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glVatRecoverableAccountCode;
|
||||
private readonly string _glClearingAccountCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
@@ -55,7 +53,7 @@ public sealed class GrnService : IGrnService
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration)
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
@@ -76,9 +74,7 @@ public sealed class GrnService : IGrnService
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_notifications = notifications;
|
||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
@@ -311,17 +307,18 @@ public sealed class GrnService : IGrnService
|
||||
// with it, so inventory and the ledger never diverge (user-approved "fails
|
||||
// atomically" behavior; residual risk if GL posts but the local commit still
|
||||
// fails afterward is accepted for this phase, see the integration plan).
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||
var glLines = new List<GlJournalEntryLineRequest>();
|
||||
decimal totalPayable = 0m;
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
|
||||
if (line.VatAmount > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.VatRecoverable], line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
|
||||
totalPayable += line.LineTotal;
|
||||
}
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], 0m, totalPayable, $"GRN {grn.DocNo} received"));
|
||||
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Services.Gl;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// The role → GL account code mapping: master data, held in <c>gl_account_config</c> and validated
|
||||
/// against GL's live chart of accounts on save. Replaces the per-module <c>Gl*Code</c> appsettings
|
||||
/// keys, which could only be checked at boot and only after a redeploy could be corrected.
|
||||
/// </summary>
|
||||
public interface IGlAccountConfigService
|
||||
{
|
||||
/// <summary>Current mapping plus the selectable accounts, for the settings screen.</summary>
|
||||
Task<ETagged<GlAccountConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the mapping. Fails closed: rejects any code GL does not have or cannot post to, and
|
||||
/// rejects the save outright if GL cannot be reached — an unverified mapping is not a valid one.
|
||||
/// </summary>
|
||||
Task<ETagged<GlAccountConfigDto>> UpdateAsync(
|
||||
UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// The mapping as posting flows consume it. Cached for the lifetime of the scope — a single
|
||||
/// request can post through several services (GrnService → FifoCostingService) and they should
|
||||
/// not each re-read the row.
|
||||
/// </summary>
|
||||
Task<GlAccountCodes> GetCodesAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -28,18 +28,18 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glAccountsPayableCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public PurchaseInvoicePaymentService(
|
||||
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
|
||||
@@ -62,6 +62,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
@@ -72,7 +73,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi
|
||||
Narration = $"Payment against invoice {invoice.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
|
||||
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
|
||||
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
@@ -35,13 +35,12 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glClearingAccountCode;
|
||||
private readonly string _glAccountsPayableCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public PurchaseInvoiceService(
|
||||
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
|
||||
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_vendors = vendors;
|
||||
@@ -50,8 +49,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
_numbers = numbers;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
|
||||
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
|
||||
@@ -186,6 +184,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
|
||||
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
|
||||
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
@@ -196,8 +195,8 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
|
||||
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
|
||||
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
|
||||
new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
|
||||
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
|
||||
@@ -43,15 +43,14 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glClearingAccountCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public PurchaseReturnService(
|
||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_returns = returns;
|
||||
_vendors = vendors;
|
||||
@@ -68,8 +67,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
_notifications = notifications;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<PurchaseReturnSummaryDto>> ListAsync(
|
||||
@@ -221,6 +219,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
var totalValue = refs.Sum(r => r.Value);
|
||||
if (totalValue > 0)
|
||||
{
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
@@ -231,8 +230,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
Narration = $"Purchase return {docNo} — stock returned to vendor",
|
||||
Lines = new List<GlJournalEntryLineRequest>
|
||||
{
|
||||
new(_glClearingAccountCode, totalValue, 0m, $"Purchase return {docNo}"),
|
||||
new(_glInventoryAccountCode, 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
|
||||
new(glCodes[GlAccountRole.Clearing], totalValue, 0m, $"Purchase return {docNo}"),
|
||||
new(glCodes[GlAccountRole.Inventory], 0m, totalValue, $"Purchase return {docNo} — inventory reduction")
|
||||
}
|
||||
}, token);
|
||||
ret.GlJournalNo = glResult.JournalNo;
|
||||
|
||||
@@ -43,18 +43,12 @@ public sealed class SalesDayEndService : ISalesDayEndService
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glCashAccountCode;
|
||||
private readonly string _glSalesRevenueAccountCode;
|
||||
private readonly string _glSalesDiscountAccountCode;
|
||||
private readonly string _glTaxPayableAccountCode;
|
||||
private readonly string _glCogsAccountCode;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glAccountsReceivableCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public SalesDayEndService(
|
||||
IRepository<SalesDayEnd> dayEnds, IRepository<SalesSlip> slips, IRepository<BundleSale> bundles,
|
||||
IRepository<SalesInvoice> invoices, IRepository<StockLedger> ledger, IRepository<Item> items, INumberSequenceService numbers,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_dayEnds = dayEnds;
|
||||
_slips = slips;
|
||||
@@ -66,13 +60,7 @@ public sealed class SalesDayEndService : ISalesDayEndService
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glCashAccountCode = configuration["SalesDayEnd:GlCashAccountCode"] ?? string.Empty;
|
||||
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
|
||||
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
|
||||
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
|
||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glAccountsReceivableCode = configuration["SalesDayEnd:GlAccountsReceivableCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<SalesDayEndPreviewDto> PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default)
|
||||
@@ -206,24 +194,25 @@ public sealed class SalesDayEndService : ISalesDayEndService
|
||||
|
||||
if (grandTotal > 0 || cogs > 0)
|
||||
{
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(businessDate, token);
|
||||
var glLines = new List<GlJournalEntryLineRequest>();
|
||||
if (grandTotal > 0)
|
||||
{
|
||||
if (paidTotal > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glCashAccountCode, paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cash], paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected"));
|
||||
if (balanceTotal > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account"));
|
||||
if (discountTotal > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, discountTotal, 0m, "Sales discount"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, subtotal, "Sales revenue"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], discountTotal, 0m, "Sales discount"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, subtotal, "Sales revenue"));
|
||||
if (taxTotal > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, taxTotal, "Output tax payable"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, taxTotal, "Output tax payable"));
|
||||
}
|
||||
if (cogs > 0)
|
||||
{
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
|
||||
glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
|
||||
}
|
||||
|
||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
|
||||
@@ -29,11 +29,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glAccountsReceivableCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public SalesInvoicePaymentService(
|
||||
IRepository<SalesInvoice> invoices, IRepository<SalesInvoicePayment> payments, IRepository<SalesConfig> salesConfig,
|
||||
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_payments = payments;
|
||||
@@ -41,7 +41,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<SalesInvoicePaymentDto> PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default)
|
||||
@@ -68,6 +68,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
// Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
@@ -79,7 +80,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(account.GlAccountCode, request.Amount, 0m, $"Payment against invoice {invoice.InvoiceNo}"),
|
||||
new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}")
|
||||
new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
@@ -178,10 +179,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService
|
||||
{
|
||||
// One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted
|
||||
// before anything is committed — so a GL rejection leaves neither GL nor local state half-done.
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(paymentDay, token);
|
||||
var glLines = lines
|
||||
.Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration))
|
||||
.Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration))
|
||||
.Append(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, total, narration))
|
||||
.ToList();
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
|
||||
@@ -26,12 +26,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glAccountsReceivableCode;
|
||||
private readonly string _glSalesRevenueAccountCode;
|
||||
private readonly string _glSalesDiscountAccountCode;
|
||||
private readonly string _glTaxPayableAccountCode;
|
||||
private readonly string _glCogsAccountCode;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public SalesPostingService(
|
||||
IRepository<SalesInvoice> invoices,
|
||||
@@ -44,7 +39,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow,
|
||||
IGeneralLedgerService gl,
|
||||
IConfiguration configuration)
|
||||
IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_invoices = invoices;
|
||||
_slips = slips;
|
||||
@@ -56,14 +51,9 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
// Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice
|
||||
// just recognizes them immediately on Post instead of waiting for the day's close.
|
||||
_glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty;
|
||||
_glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty;
|
||||
_glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty;
|
||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<SalesInvoicePostingCheckDto> CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
@@ -185,21 +175,22 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
if (invoice.NetPayable <= 0 && cogs <= 0) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct);
|
||||
var lines = new List<GlJournalEntryLineRequest>();
|
||||
if (invoice.NetPayable > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}"));
|
||||
if (invoice.DiscountTotal > 0)
|
||||
lines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, invoice.DiscountTotal, 0m, "Sales discount"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, invoice.Subtotal, "Sales revenue"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], invoice.DiscountTotal, 0m, "Sales discount"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, invoice.Subtotal, "Sales revenue"));
|
||||
if (invoice.TaxTotal > 0)
|
||||
lines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, invoice.TaxTotal, "Output tax payable"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, invoice.TaxTotal, "Output tax payable"));
|
||||
}
|
||||
if (cogs > 0)
|
||||
{
|
||||
lines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold"));
|
||||
lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold"));
|
||||
lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction"));
|
||||
}
|
||||
|
||||
var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
|
||||
@@ -39,15 +39,14 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glCogsAccountCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public SalesReturnService(
|
||||
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
|
||||
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_returns = returns;
|
||||
_customers = customers;
|
||||
@@ -62,8 +61,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SalesReturnSummaryDto>> ListAsync(
|
||||
@@ -180,6 +178,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
var totalValue = refs.Sum(r => r.Value);
|
||||
if (totalValue > 0)
|
||||
{
|
||||
var glCodes = await _glAccounts.GetCodesAsync(token);
|
||||
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token);
|
||||
var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
@@ -190,8 +189,8 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
Narration = $"Sales return {docNo} — stock restocked",
|
||||
Lines = new List<GlJournalEntryLineRequest>
|
||||
{
|
||||
new(_glInventoryAccountCode, totalValue, 0m, $"Sales return {docNo}"),
|
||||
new(_glCogsAccountCode, 0m, totalValue, $"Sales return {docNo} — COGS reversal")
|
||||
new(glCodes[GlAccountRole.Inventory], totalValue, 0m, $"Sales return {docNo}"),
|
||||
new(glCodes[GlAccountRole.Cogs], 0m, totalValue, $"Sales return {docNo} — COGS reversal")
|
||||
}
|
||||
}, token);
|
||||
ret.GlJournalNo = glResult.JournalNo;
|
||||
|
||||
@@ -26,21 +26,19 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
private readonly IRepository<JournalEntryStub> _journal;
|
||||
private readonly ErpDbContext _db;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glClearingAccountCode;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
public FifoCostingService(
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IRepository<JournalEntryStub> journal, ErpDbContext db,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_journal = journal;
|
||||
_db = db;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Fifo:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glClearingAccountCode = configuration["Fifo:GlClearingAccountCode"] ?? string.Empty;
|
||||
_glAccounts = glAccounts;
|
||||
}
|
||||
|
||||
public async Task<StockLayer> CreateInboundLayerAsync(
|
||||
@@ -273,10 +271,12 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
};
|
||||
await _ledger.AddAsync(entry, ct);
|
||||
|
||||
var glCodes = await _glAccounts.GetCodesAsync(ct);
|
||||
|
||||
// Inbound debits Inventory / credits Clearing; outbound reverses.
|
||||
var (debit, credit) = direction == Direction.In
|
||||
? (_glInventoryAccountCode, _glClearingAccountCode)
|
||||
: (_glClearingAccountCode, _glInventoryAccountCode);
|
||||
? (glCodes[GlAccountRole.Inventory], glCodes[GlAccountRole.Clearing])
|
||||
: (glCodes[GlAccountRole.Clearing], glCodes[GlAccountRole.Inventory]);
|
||||
|
||||
// Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13).
|
||||
await _journal.AddAsync(new JournalEntryStub
|
||||
|
||||
Reference in New Issue
Block a user