ca81e9bc42
- Implemented General Ledger client component for managing GL integration. - Created a dedicated page for General Ledger configuration. - Developed Finance Report client component for generating various financial reports. - Added report blocks for structured display of financial data. - Introduced API endpoints for fetching GL accounts and financial reports. - Updated navigation to include links for GL integration and financial reports. - Enhanced permissions to manage GL and view financial reports. - Updated appsettings to include General Ledger API configuration.
450 lines
24 KiB
C#
450 lines
24 KiB
C#
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);
|
|
|
|
/// <summary>What closing a financial year would post. Closable is false with a Reason when it cannot be closed.</summary>
|
|
public record YearEndPreview(int Year, string StartDate, string EndDate, decimal TotalIncome, decimal TotalExpenses,
|
|
decimal NetProfit, bool Closable, string? Reason, bool AlreadyClosed);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public record OpeningBalanceLine(string Account, Guid? BankAccountId, decimal Amount);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class GlPostingService(
|
|
IGeneralLedgerClient gl,
|
|
ISysConfigRepository configRepo,
|
|
GlSetupService setup,
|
|
AppDBContext context,
|
|
IUnitOfWork uow,
|
|
ILogger<GlPostingService> logger)
|
|
{
|
|
private record Payload(string EntryDate, string JournalType, string SourceModule, string Reference, string Narration,
|
|
string PreparedBy, List<PayloadLine> 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);
|
|
|
|
/// <summary>Deposit / Return / Cancel of a received cheque that came from a customer payment.</summary>
|
|
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<GlLine> { 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);
|
|
|
|
/// <summary>Clear / Bounce / Cancel of a cheque issued through a supplier payment.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<GlPostingLog> PostOpeningBalanceAsync(DateOnly date, string? narration, List<OpeningBalanceLine> 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<GlLine>();
|
|
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<List<GlLine>?>(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.");
|
|
}
|
|
|
|
/// <summary>Accounts that can carry an opening balance (besides individual cash/bank accounts) and their normal side.</summary>
|
|
public static readonly Dictionary<string, (string Key, bool DebitNormal)> 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}")));
|
|
|
|
/// <summary>Years that have a posted closing journal.</summary>
|
|
public async Task<List<int>> 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<GlLine> Lines, decimal Income, decimal Expenses);
|
|
|
|
/// <summary>Reads the year's own P&L from GL (which excludes earlier closing journals) and builds the closing lines.</summary>
|
|
private async Task<CloseBuild> 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<string, decimal>();
|
|
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<YearEndPreview> 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);
|
|
}
|
|
|
|
/// <summary>Posts the closing journal for the year (income/expense into Retained Earnings), then locks the year's periods.</summary>
|
|
public async Task<object> 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<List<GlLine>?>(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<int> LockYearPeriodsAsync(int year, CancellationToken ct)
|
|
{
|
|
var ids = new HashSet<long>();
|
|
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 ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Re-sends every Failed/Pending posting. Returns how many are now Posted.</summary>
|
|
public async Task<int> 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<Task<List<GlLine>?>> 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<JsonElement>(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<Dictionary<string, JsonElement>>(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<string> 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<string> 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 };
|
|
}
|