using System.Globalization; using System.Text.Json; using Distribution.Data; using Distribution.Features.Grn; using Distribution.Features.SysConfig; using Distribution.Infra.UoW; using Distribution.Models; using Microsoft.EntityFrameworkCore; namespace Distribution.Features.GeneralLedger; public record GlLine(string AccountCode, decimal Debit, decimal Credit); /// What closing a financial year would post. Closable is false with a Reason when it cannot be closed. public record YearEndPreview(int Year, string StartDate, string EndDate, decimal TotalIncome, decimal TotalExpenses, decimal NetProfit, bool Closable, string? Reason, bool AlreadyClosed); /// /// One opening balance: Account is a key from OpeningAccounts (e.g. "Inventory", "ShareCapital") or "BankAccount" /// with BankAccountId. Amount is the account's normal balance (>= 0); the debit/credit side comes from the account. /// public record OpeningBalanceLine(string Account, Guid? BankAccountId, decimal Amount); /// /// Mirrors Distribution business events into the General Ledger as journal entries. Every public Post* method is /// called AFTER the business transaction has committed and never throws: a GL problem is recorded in GlPostingLogs /// (status Failed, payload kept) and can be retried later, so it can never block a sale, payment or cheque action. /// public class GlPostingService( IGeneralLedgerClient gl, ISysConfigRepository configRepo, GlSetupService setup, AppDBContext context, IUnitOfWork uow, ILogger logger) { private record Payload(string EntryDate, string JournalType, string SourceModule, string Reference, string Narration, string PreparedBy, List Lines); private record PayloadLine(string AccountCode, decimal DebitAmount, decimal CreditAmount); // ── Events ─────────────────────────────────────────────────────────────── public Task PostInvoiceFinalizedAsync(Models.Invoice invoice, CancellationToken ct = default) => PostAsync("InvoiceFinalized", invoice.Id, invoice.FinalizedAt ?? DateTime.Now, "INVOICE", $"Invoice {invoice.Id} finalized", async () => invoice.TotalAmount <= 0 ? null : [ new GlLine(await Acct(GlKeys.Receivable), invoice.TotalAmount, 0), new GlLine(await Acct(GlKeys.Sales), 0, invoice.TotalAmount) ], ct); public Task PostCustomerPaymentAsync(Models.Payment payment, CancellationToken ct = default) => PostAsync("CustomerPayment", payment.Id, payment.CollectedAt, "PAYMENT", $"Customer payment for invoice {payment.FK_InvoiceId}", async () => { if (payment.Amount <= 0 || payment.Method == PaymentMethod.Credit) return null; var debit = payment.Method == PaymentMethod.Cash ? await BankCode(payment.FK_BankAccountId) : await Acct(GlKeys.ChequesInHand); return [new GlLine(debit, payment.Amount, 0), new GlLine(await Acct(GlKeys.Receivable), 0, payment.Amount)]; }, ct); /// Deposit / Return / Cancel of a received cheque that came from a customer payment. public Task PostReceivedChequeAsync(Models.ReceivedCheque cheque, ReceivedChequeStatusAction action, CancellationToken ct = default) { if (action is not (ReceivedChequeStatusAction.Deposit or ReceivedChequeStatusAction.Return or ReceivedChequeStatusAction.Cancel)) return Task.CompletedTask; var date = action == ReceivedChequeStatusAction.Deposit ? cheque.DepositDate ?? DateTime.Now : DateTime.Now; return PostAsync($"ReceivedCheque{action}", cheque.Id, date, "CHEQUE", $"Received cheque {cheque.ChequeNo} {action}", async () => { // Only cheques created by a customer payment were posted to Cheques-in-hand, so only they can move. if (!await context.Payments.AnyAsync(p => p.FK_ReceivedChequeId == cheque.Id, ct)) return null; var inHand = await Acct(GlKeys.ChequesInHand); return action switch { ReceivedChequeStatusAction.Deposit => [new GlLine(await BankCode(cheque.FK_DepositBankAccountId), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)], ReceivedChequeStatusAction.Return => [new GlLine(inHand, cheque.Amount, 0), new GlLine(await BankCode(cheque.FK_DepositBankAccountId), 0, cheque.Amount)], _ => [new GlLine(await Acct(GlKeys.Receivable), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)] }; }, ct); } public Task PostGrnAsync(Models.GoodsReceipt grn, CancellationToken ct = default) => PostAsync("Grn", grn.Id, grn.ReceivedAt, "GRN", $"Goods received {grn.Id}", async () => { decimal net = 0, nbt = 0, vat = 0; foreach (var i in grn.Items.Where(i => i.IsActive)) { var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent); net += a.Gross - a.Discount; nbt += a.Nbt; vat += a.Vat; } var total = net + nbt + vat; if (total <= 0) return null; var lines = new List { new(await Acct(GlKeys.Inventory), net, 0) }; if (nbt > 0) lines.Add(new GlLine(await Acct(GlKeys.InputNbt), nbt, 0)); if (vat > 0) lines.Add(new GlLine(await Acct(GlKeys.InputVat), vat, 0)); lines.Add(new GlLine(await Acct(GlKeys.Payable), 0, total)); return lines; }, ct); public Task PostSupplierPaymentAsync(Models.SupplierPayment payment, CancellationToken ct = default) => PostAsync("SupplierPayment", payment.Id, payment.PaidAt, "SUPPLIER_PAYMENT", $"Supplier payment {payment.Id}", async () => { if (payment.Amount <= 0) return null; var credit = payment.Method == PaymentMethod.Cash ? await BankCode(payment.FK_BankAccountId) : await Acct(GlKeys.ChequesIssued); return [new GlLine(await Acct(GlKeys.Payable), payment.Amount, 0), new GlLine(credit, 0, payment.Amount)]; }, ct); /// Clear / Bounce / Cancel of a cheque issued through a supplier payment. public Task PostIssuedChequeAsync(Models.ChequePage page, ChequePageStatusAction action, CancellationToken ct = default) { if (action == ChequePageStatusAction.Void) return Task.CompletedTask; var date = action == ChequePageStatusAction.Clear ? page.ClearedDate ?? DateTime.Now : DateTime.Now; return PostAsync($"IssuedCheque{action}", page.Id, date, "CHEQUE", $"Issued cheque {page.ChequeNo} {action}", async () => { var amount = page.Amount ?? 0; if (amount <= 0 || !await context.SupplierPayments.AnyAsync(p => p.FK_ChequePageId == page.Id, ct)) return null; var issued = await Acct(GlKeys.ChequesIssued); if (action == ChequePageStatusAction.Clear) { var bookBankId = await context.ChequeBooks.Where(b => b.Id == page.FK_ChequeBookId) .Select(b => (Guid?)b.FK_BankAccountId).FirstOrDefaultAsync(ct); return [new GlLine(issued, amount, 0), new GlLine(await BankCode(bookBankId), 0, amount)]; } // Bounce / Cancel: the cheque no longer settles the payable, so put it back. return [new GlLine(issued, amount, 0), new GlLine(await Acct(GlKeys.Payable), 0, amount)]; }, ct); } /// /// Posts the opening balances (asset side debited, liability/equity side credited). Zero amounts are skipped, so the /// form can list every account with a default of 0. Reports the outcome so the caller can show a failure. /// public async Task PostOpeningBalanceAsync(DateOnly date, string? narration, List input, CancellationToken ct = default) { if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true") throw new InvalidOperationException("Run the General Ledger setup first."); var lines = new List(); foreach (var l in input ?? []) { if (l.Amount < 0) throw new InvalidOperationException($"'{l.Account}': the amount cannot be negative."); if (l.Amount == 0) continue; if (l.Account == "BankAccount") { lines.Add(new GlLine(await BankCode(l.BankAccountId), l.Amount, 0)); continue; } if (!OpeningAccounts.TryGetValue(l.Account, out var def)) throw new InvalidOperationException($"Unknown account '{l.Account}'."); var code = await Acct(def.Key); lines.Add(def.DebitNormal ? new GlLine(code, l.Amount, 0) : new GlLine(code, 0, l.Amount)); } if (lines.Count == 0) throw new InvalidOperationException("Enter at least one opening balance greater than zero."); var debit = lines.Sum(x => x.Debit); var credit = lines.Sum(x => x.Credit); if (debit != credit) throw new InvalidOperationException( $"The opening balances do not balance: assets (debits) total {debit:N2} but liabilities + equity (credits) total {credit:N2}. " + "Adjust Share Capital / Retained Earnings so they match."); var sourceId = Guid.NewGuid(); await PostAsync("OpeningBalance", sourceId, date.ToDateTime(TimeOnly.MinValue), "OPENING_BALANCE", string.IsNullOrWhiteSpace(narration) ? "Opening balances" : narration.Trim(), () => Task.FromResult?>(lines), ct); return await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == "OpeningBalance" && l.SourceId == sourceId, ct) ?? throw new InvalidOperationException("The opening balances could not be prepared. Check the server log."); } /// Accounts that can carry an opening balance (besides individual cash/bank accounts) and their normal side. public static readonly Dictionary OpeningAccounts = new() { ["ChequesInHand"] = (GlKeys.ChequesInHand, true), ["Receivable"] = (GlKeys.Receivable, true), ["Inventory"] = (GlKeys.Inventory, true), ["InputVat"] = (GlKeys.InputVat, true), ["InputNbt"] = (GlKeys.InputNbt, true), ["Drawings"] = (GlKeys.Drawings, true), ["Payable"] = (GlKeys.Payable, false), ["ChequesIssued"] = (GlKeys.ChequesIssued, false), ["ShareCapital"] = (GlKeys.ShareCapital, false), ["RetainedEarnings"] = (GlKeys.RetainedEarnings, false), ["GeneralReserve"] = (GlKeys.GeneralReserve, false), ["RevaluationReserve"] = (GlKeys.RevaluationReserve, false), }; // ── Year-end close ─────────────────────────────────────────────────────── private const string YearEndEvent = "YearEndClose"; private static Guid YearEndSourceId(int year) => new(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes($"{YearEndEvent}:{year}"))); /// Years that have a posted closing journal. public async Task> GetClosedYearsAsync(CancellationToken ct = default) => (await context.GlPostingLogs .Where(l => l.Event == YearEndEvent && l.Status == GlPostingStatus.Posted) .Select(l => l.Reference) .ToListAsync(ct)) .Select(r => int.TryParse(r.Split(':').Last(), out var y) ? y : 0) .Where(y => y > 0).OrderBy(y => y).ToList(); private record CloseBuild(List Lines, decimal Income, decimal Expenses); /// Reads the year's own P&L from GL (which excludes earlier closing journals) and builds the closing lines. private async Task BuildYearEndCloseAsync(int year, CancellationToken ct) { var result = await gl.SendAsync(HttpMethod.Get, $"v1/reports?reportType=ProfitAndLoss&outputFormat=Json&periodStart={year}-01-01&periodEnd={year}-12-31", null, ct); if (!result.Success || GlJson.Data(result) is not { } pl) throw new InvalidOperationException($"Could not read the {year} profit and loss from the General Ledger: {GlJson.Message(result) ?? result.StatusCode.ToString()}"); // Net CREDIT balance per account: income sections carry their amount as is, expense sections are reported // as positive debits (so flip), unclassified rows are already credit - debit. var netCredit = new Dictionary(); decimal income = 0, expenses = 0; void Collect(string section, int sign) { if (GlJson.Get(pl, section) is not { } s || GlJson.Get(s, "lines") is not { ValueKind: JsonValueKind.Array } sectionLines) return; foreach (var l in sectionLines.EnumerateArray()) { var code = GlJson.GetString(l, "accountCode"); if (code is null || !decimal.TryParse(GlJson.GetString(l, "amount"), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue; var credit = sign * amount; netCredit[code] = netCredit.GetValueOrDefault(code) + credit; if (credit > 0) income += credit; else expenses += -credit; } } Collect("sales", 1); Collect("otherIncome", 1); Collect("costOfSales", -1); Collect("distributionExpenses", -1); Collect("administrationExpenses", -1); Collect("otherExpenses", -1); Collect("financialExpenses", -1); Collect("unclassified", 1); // Closing: an account with a net credit balance is debited, one with a net debit balance is credited. var lines = netCredit.Where(kv => kv.Value != 0) .Select(kv => kv.Value > 0 ? new GlLine(kv.Key, kv.Value, 0) : new GlLine(kv.Key, 0, -kv.Value)).ToList(); var debit = lines.Sum(x => x.Debit); var credit2 = lines.Sum(x => x.Credit); if (debit != credit2) { var retained = await Acct(GlKeys.RetainedEarnings); lines.Add(debit > credit2 ? new GlLine(retained, 0, debit - credit2) : new GlLine(retained, credit2 - debit, 0)); } return new CloseBuild(lines, income, expenses); } public async Task GetYearEndPreviewAsync(int year, CancellationToken ct = default) { var start = $"{year}-01-01"; var end = $"{year}-12-31"; if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true") return new(year, start, end, 0, 0, 0, false, "Run the General Ledger setup first.", false); var sourceId = YearEndSourceId(year); var existing = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct); if (existing?.Status == GlPostingStatus.Posted) return new(year, start, end, 0, 0, 0, false, $"{year} has already been closed.", true); var build = await BuildYearEndCloseAsync(year, ct); var net = build.Income - build.Expenses; string? reason = null; if (new DateOnly(year, 12, 31) > DateOnly.FromDateTime(DateTime.Now)) reason = $"{year} has not ended yet. A year can only be closed after 31 December."; else if (await context.GlPostingLogs.AnyAsync(l => l.Status != GlPostingStatus.Posted && !(l.Event == YearEndEvent && l.SourceId == sourceId), ct)) reason = "There are failed or pending General Ledger postings. Retry them first so the year's figures are complete."; else if (build.Lines.Count == 0) reason = $"There is no income or expense activity in {year} to close."; return new(year, start, end, build.Income, build.Expenses, net, reason is null, reason, false); } /// Posts the closing journal for the year (income/expense into Retained Earnings), then locks the year's periods. public async Task CloseYearAsync(int year, CancellationToken ct = default) { var preview = await GetYearEndPreviewAsync(year, ct); if (!preview.Closable) throw new InvalidOperationException(preview.Reason ?? "This year cannot be closed."); var build = await BuildYearEndCloseAsync(year, ct); var sourceId = YearEndSourceId(year); await PostAsync(YearEndEvent, sourceId, new DateTime(year, 12, 31), "YEAR_END_CLOSE", $"Year-end closing entry for {year}", () => Task.FromResult?>(build.Lines), ct, journalType: "CLOSING", reference: $"{YearEndEvent}:{year}"); var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct) ?? throw new InvalidOperationException("The closing entry could not be prepared. Check the server log."); if (row.Status != GlPostingStatus.Posted) throw new InvalidOperationException((row.Error ?? "The General Ledger did not accept the closing entry.") + " Fix the cause and press Close again."); var locked = await LockYearPeriodsAsync(year, ct); return new { year, glJournalNo = row.GlJournalNo, netProfit = preview.NetProfit, periodsLocked = locked }; } private async Task LockYearPeriodsAsync(int year, CancellationToken ct) { var ids = new HashSet(); for (var month = 1; month <= 12; month++) { var p = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={year}-{month:00}-15", null, ct); if (p.Success && GlJson.Data(p) is { } pd && GlJson.GetLong(pd, "periodId") is { } id) ids.Add(id); } var locked = 0; foreach (var id in ids) { var r = await gl.SendAsync(HttpMethod.Put, $"v1/periods/{id}/lock", new { action = "Close", reason = $"Year-end close {year}" }, ct); if (r.Success) locked++; else logger.LogWarning("Could not lock GL period {PeriodId} after closing {Year}: {Message}", id, year, GlJson.Message(r)); } return locked; } // ── Retry ──────────────────────────────────────────────────────────────── /// Re-sends every Failed/Pending posting. Returns how many are now Posted. public async Task RetryFailedAsync(CancellationToken ct = default) { var rows = await context.GlPostingLogs .Where(l => l.Status != GlPostingStatus.Posted) .OrderBy(l => l.CreatedAt) .ToListAsync(ct); var posted = 0; foreach (var row in rows) { await SendAsync(row, ct); if (row.Status == GlPostingStatus.Posted) posted++; } return posted; } // ── Core ───────────────────────────────────────────────────────────────── private async Task PostAsync(string evt, Guid sourceId, DateTime date, string sourceModule, string narration, Func?>> buildLines, CancellationToken ct, string journalType = "GENERAL", string? reference = null) { try { if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true") return; // GL not set up yet var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == evt && l.SourceId == sourceId, ct); if (row is not null && row.Status == GlPostingStatus.Posted) return; if (row is null) { var lines = await buildLines(); if (lines is null || lines.Count == 0) return; reference ??= $"{evt}:{sourceId}"; var payload = new Payload(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), journalType, sourceModule, reference, narration, "Distribution", lines.Select(l => new PayloadLine(l.AccountCode, l.Debit, l.Credit)).ToList()); row = new GlPostingLog { Event = evt, SourceId = sourceId, Reference = reference, PayloadJson = JsonSerializer.Serialize(payload, JsonOptions) }; await uow.BeginAsync(ct); try { await context.GlPostingLogs.AddAsync(row, ct); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } } await SendAsync(row, ct); } catch (Exception ex) { logger.LogError(ex, "GL posting {Event}:{SourceId} failed before it could be sent", evt, sourceId); } } private async Task SendAsync(GlPostingLog row, CancellationToken ct) { string? journalNo = null; string? error = null; try { var payload = JsonSerializer.Deserialize(row.PayloadJson); var entryDate = payload.GetProperty("entryDate").GetString()!; var period = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={entryDate}", null, ct); var periodId = period.Success && GlJson.Data(period) is { } pd ? GlJson.GetLong(pd, "periodId") : null; if (periodId is null) { error = $"No accounting period in GL covers {entryDate}. Run GL setup for that year."; } else { var body = JsonSerializer.Deserialize>(row.PayloadJson, JsonOptions)!; body["periodId"] = JsonSerializer.SerializeToElement(periodId.Value); var result = await gl.SendAsync(HttpMethod.Post, "v1/journal-entries", body, ct); if (result.Success) journalNo = GlJson.Data(result) is { } d ? GlJson.GetString(d, "journalNo") ?? "" : ""; else error = GlJson.Message(result) ?? $"GL returned HTTP {result.StatusCode}"; } } catch (Exception ex) { error = ex.Message; logger.LogWarning(ex, "GL posting {Reference} failed", row.Reference); } row.Attempts++; row.LastAttemptAt = DateTime.Now; row.Status = error is null ? GlPostingStatus.Posted : GlPostingStatus.Failed; row.GlJournalNo = journalNo; row.Error = error is null ? null : error.Length > 1000 ? error[..1000] : error; await uow.BeginAsync(ct); try { context.GlPostingLogs.Update(row); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } } // ── Account lookups ────────────────────────────────────────────────────── private async Task Acct(string key) => (await configRepo.GetByKeyAsync(key))?.Value is { Length: > 0 } code ? code : throw new InvalidOperationException($"GL account mapping '{key}' is missing. Run GL setup."); private async Task BankCode(Guid? bankAccountId) { if (bankAccountId is null) throw new InvalidOperationException("No cash/bank account on this transaction."); var account = await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == bankAccountId.Value) ?? throw new InvalidOperationException("Cash/bank account not found."); return await setup.EnsureBankAccountAsync(account) ?? throw new InvalidOperationException("Could not map the cash/bank account to a GL account. Run GL setup."); } private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; }