feat: add General Ledger integration and financial reporting features
- 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.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -7,7 +8,8 @@ namespace Distribution.Features.ChequeBook;
|
||||
public class ChequeBookService(
|
||||
IChequeBookRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow)
|
||||
IUnitOfWork uow,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private const string DefaultCurrency = "LKR";
|
||||
|
||||
@@ -171,6 +173,7 @@ public class ChequeBookService(
|
||||
|
||||
repo.UpdatePage(page);
|
||||
await uow.CommitAsync(ct);
|
||||
await gl.PostIssuedChequeAsync(page, request.Action, ct);
|
||||
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.FinanceReport;
|
||||
|
||||
/// <summary>
|
||||
/// Proxies the General Ledger service's reports (TrialBalance, GeneralLedger, BalanceSheet, ProfitAndLoss, CashFlow,
|
||||
/// BudgetVsActual, TaxSummary). Query parameters are forwarded as-is; Json is returned as the GL data payload and
|
||||
/// Pdf/Csv are returned as downloadable files.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/reports/finance")]
|
||||
public class FinanceReportController(IGeneralLedgerClient gl) : ControllerBase
|
||||
{
|
||||
/// <summary>Active GL accounts (code + name) for the report filters' account dropdown.</summary>
|
||||
[HttpGet("accounts")]
|
||||
[Authorize("finance-reports.view")]
|
||||
public async Task<IActionResult> Accounts(CancellationToken ct)
|
||||
{
|
||||
GeneralLedgerResult result;
|
||||
try { result = await gl.SendAsync(HttpMethod.Get, "v1/accounts?isActive=true", null, ct); }
|
||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
||||
|
||||
if (!result.Success || GlJson.Data(result) is not { } data)
|
||||
return StatusCode(502, new { error = GlJson.Message(result) ?? "Could not load GL accounts." });
|
||||
|
||||
var items = GlJson.Get(data, "items");
|
||||
var accounts = items is { ValueKind: JsonValueKind.Array }
|
||||
? items.Value.EnumerateArray()
|
||||
.Select(a => new { accountCode = GlJson.GetString(a, "accountCode") ?? "", accountName = GlJson.GetString(a, "accountName") ?? "" })
|
||||
.OrderBy(a => a.accountCode, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
: [];
|
||||
return Ok(accounts);
|
||||
}
|
||||
|
||||
[HttpGet("{reportType}")]
|
||||
[Authorize("finance-reports.view")]
|
||||
public async Task<IActionResult> Get(string reportType, [FromQuery] string outputFormat = "Json", CancellationToken ct = default)
|
||||
{
|
||||
var query = new StringBuilder($"v1/reports?reportType={Uri.EscapeDataString(reportType)}&outputFormat={Uri.EscapeDataString(outputFormat)}");
|
||||
foreach (var (key, values) in Request.Query)
|
||||
{
|
||||
if (key is "reportType" or "outputFormat") continue;
|
||||
foreach (var v in values)
|
||||
query.Append('&').Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(v ?? ""));
|
||||
}
|
||||
|
||||
GeneralLedgerResult result;
|
||||
try { result = await gl.SendAsync(HttpMethod.Get, query.ToString(), null, ct); }
|
||||
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
|
||||
|
||||
if (!result.Success)
|
||||
return StatusCode(result.StatusCode is >= 400 and < 500 ? result.StatusCode : 502,
|
||||
new { error = GlJson.Message(result) ?? "The General Ledger service rejected the request." });
|
||||
|
||||
var data = GlJson.Data(result);
|
||||
if (data is null) return StatusCode(502, new { error = "The General Ledger service returned no data." });
|
||||
|
||||
if (!outputFormat.Equals("Json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var base64 = GlJson.GetString(data.Value, "contentBase64");
|
||||
if (base64 is null) return StatusCode(502, new { error = "The General Ledger service returned no file." });
|
||||
return File(Convert.FromBase64String(base64),
|
||||
GlJson.GetString(data.Value, "contentType") ?? "application/octet-stream",
|
||||
GlJson.GetString(data.Value, "fileName"));
|
||||
}
|
||||
|
||||
return Content(data.Value.GetRawText(), "application/json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
public record GeneralLedgerResult(int StatusCode, bool Success, JsonElement? Body);
|
||||
|
||||
public interface IGeneralLedgerClient
|
||||
{
|
||||
Task<bool> IsHealthyAsync(CancellationToken ct = default);
|
||||
Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service-to-service client for the General Ledger microservice. The GL service is not user-facing:
|
||||
/// every request carries the shared X-Api-Key (GeneralLedger:ApiKey, must equal the GL service's ApiKey:Value).
|
||||
/// <paramref name="path"/> is relative to GeneralLedger:BaseUrl and carries the API version (e.g. "v1/accounts", "v1/journal-entries"), so moving a call to v2 is a one-path change.
|
||||
/// </summary>
|
||||
public class GeneralLedgerClient(HttpClient http) : IGeneralLedgerClient
|
||||
{
|
||||
public async Task<bool> IsHealthyAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync("/health", ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
|
||||
if (payload is not null) request.Content = JsonContent.Create(payload);
|
||||
|
||||
using var response = await http.SendAsync(request, ct);
|
||||
JsonElement? body = null;
|
||||
if (response.Content.Headers.ContentLength != 0)
|
||||
{
|
||||
try { body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct); }
|
||||
catch (JsonException) { }
|
||||
}
|
||||
return new GeneralLedgerResult((int)response.StatusCode, response.IsSuccessStatusCode, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
public record GlSetupRequest(int? Year);
|
||||
|
||||
public record YearEndCloseRequest(int Year);
|
||||
|
||||
public record OpeningBalanceRequest(DateOnly Date, string? Narration, List<OpeningBalanceLine> Lines);
|
||||
|
||||
[ApiController]
|
||||
[Route("api/gl")]
|
||||
public class GlController(
|
||||
GlSetupService setup,
|
||||
GlPostingService posting,
|
||||
IGeneralLedgerClient gl,
|
||||
Distribution.Features.SysConfig.ISysConfigRepository configRepo,
|
||||
AppDBContext context,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Status()
|
||||
{
|
||||
var mapping = new Dictionary<string, string?>();
|
||||
foreach (var key in GlKeys.AllAccountKeys)
|
||||
mapping[key] = (await configRepo.GetByKeyAsync(key))?.Value;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
glReachable = await gl.IsHealthyAsync(),
|
||||
setupDone = (await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value == "true",
|
||||
openingBalancePosted = await context.GlPostingLogs.AnyAsync(l => l.Event == "OpeningBalance" && l.Status == GlPostingStatus.Posted),
|
||||
closedYears = await posting.GetClosedYearsAsync(),
|
||||
mapping,
|
||||
posted = await context.GlPostingLogs.CountAsync(l => l.Status == GlPostingStatus.Posted),
|
||||
failed = await context.GlPostingLogs.CountAsync(l => l.Status != GlPostingStatus.Posted)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("setup")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Setup(GlSetupRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await setup.RunAsync(request.Year, currentUser.UserId, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("opening-balances")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> OpeningBalances(OpeningBalanceRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var row = await posting.PostOpeningBalanceAsync(request.Date, request.Narration, request.Lines, ct);
|
||||
return row.Status == GlPostingStatus.Posted
|
||||
? Ok(new { status = row.Status, glJournalNo = row.GlJournalNo })
|
||||
: StatusCode(502, new { error = row.Error ?? "The General Ledger service did not accept the entry. It was saved and can be retried." });
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("year-end/preview")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> YearEndPreview([FromQuery] int year, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await posting.GetYearEndPreviewAsync(year, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("year-end/close")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> YearEndClose(YearEndCloseRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await posting.CloseYearAsync(request.Year, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("postings")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Postings([FromQuery] GlPostingStatus? status, [FromQuery] int take = 100)
|
||||
{
|
||||
var query = context.GlPostingLogs.AsQueryable();
|
||||
if (status is not null) query = query.Where(l => l.Status == status);
|
||||
var rows = await query.OrderByDescending(l => l.CreatedAt).Take(Math.Clamp(take, 1, 500))
|
||||
.Select(l => new { l.Id, l.Event, l.SourceId, l.Reference, l.Status, l.GlJournalNo, l.Error, l.Attempts, l.LastAttemptAt, l.CreatedAt })
|
||||
.ToListAsync();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
[HttpPost("postings/retry")]
|
||||
[Authorize("gl.manage")]
|
||||
public async Task<IActionResult> Retry(CancellationToken ct) =>
|
||||
Ok(new { posted = await posting.RetryFailedAsync(ct) });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
/// <summary>Small readers for the GL service's ApiResponse envelope ({statusCode, success, message, data}).</summary>
|
||||
public static class GlJson
|
||||
{
|
||||
public static JsonElement? Data(GeneralLedgerResult result) =>
|
||||
result.Body is { ValueKind: JsonValueKind.Object } body && Get(body, "data") is { } data ? data : null;
|
||||
|
||||
public static string? Message(GeneralLedgerResult result) =>
|
||||
result.Body is { ValueKind: JsonValueKind.Object } body ? Get(body, "message")?.GetString()
|
||||
?? Get(body, "Message")?.GetString() : null;
|
||||
|
||||
/// <summary>Case-insensitive property lookup (GL success bodies are camelCase, its error bodies PascalCase).</summary>
|
||||
public static JsonElement? Get(JsonElement element, string name)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var p in element.EnumerateObject())
|
||||
if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) return p.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? GetString(JsonElement element, string name)
|
||||
{
|
||||
var v = Get(element, name);
|
||||
return v is null || v.Value.ValueKind == JsonValueKind.Null ? null
|
||||
: v.Value.ValueKind == JsonValueKind.String ? v.Value.GetString() : v.Value.ToString();
|
||||
}
|
||||
|
||||
public static long? GetLong(JsonElement element, string name) =>
|
||||
long.TryParse(GetString(element, name), out var n) ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
/// <summary>SysConfig keys holding the GL account codes Distribution posts to (created by GlSetupService).</summary>
|
||||
public static class GlKeys
|
||||
{
|
||||
public const string SetupDone = "Gl.Setup.Done";
|
||||
|
||||
public const string CashEquivalents = "Gl.Account.CashEquivalents"; // parent of Cash in Hand + Bank Accounts (GL cash root)
|
||||
public const string CashInHand = "Gl.Account.CashInHand"; // parent of every Distribution Cash account
|
||||
public const string BankAccounts = "Gl.Account.BankAccounts"; // parent of every Distribution Bank account
|
||||
public const string ChequesInHand = "Gl.Account.ChequesInHand";
|
||||
public const string Receivable = "Gl.Account.Receivable";
|
||||
public const string Inventory = "Gl.Account.Inventory";
|
||||
public const string InputVat = "Gl.Account.InputVat";
|
||||
public const string InputNbt = "Gl.Account.InputNbt";
|
||||
public const string Payable = "Gl.Account.Payable";
|
||||
public const string ChequesIssued = "Gl.Account.ChequesIssued";
|
||||
public const string Equity = "Gl.Account.Equity"; // header; parent of the equity accounts below
|
||||
public const string ShareCapital = "Gl.Account.ShareCapital";
|
||||
public const string RetainedEarnings = "Gl.Account.RetainedEarnings";
|
||||
public const string Drawings = "Gl.Account.Drawings";
|
||||
public const string GeneralReserve = "Gl.Account.GeneralReserve";
|
||||
public const string RevaluationReserve = "Gl.Account.RevaluationReserve";
|
||||
public const string Sales = "Gl.Account.Sales";
|
||||
|
||||
public static readonly string[] AllAccountKeys =
|
||||
[
|
||||
CashEquivalents, CashInHand, BankAccounts, ChequesInHand, Receivable, Inventory,
|
||||
InputVat, InputNbt, Payable, ChequesIssued, Equity, ShareCapital, RetainedEarnings, Drawings,
|
||||
GeneralReserve, RevaluationReserve, Sales
|
||||
];
|
||||
|
||||
public static bool IsGlKey(string key) => key == SetupDone || AllAccountKeys.Contains(key);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.SysConfig;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.GeneralLedger;
|
||||
|
||||
public record GlSetupResult(bool Done, List<string> Actions);
|
||||
|
||||
/// <summary>
|
||||
/// One-time (re-runnable) bootstrap of the General Ledger service for Distribution: creates the chart of accounts
|
||||
/// Distribution posts to, a fiscal year with its periods, the GL cash-root config, and a GL account for every
|
||||
/// Distribution cash/bank account. The resulting GL account codes are stored in SysConfig (see GlKeys).
|
||||
/// </summary>
|
||||
public class GlSetupService(
|
||||
IGeneralLedgerClient gl,
|
||||
ISysConfigRepository configRepo,
|
||||
AppDBContext context,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private const short Asset = 1, Liability = 2, Equity = 3, Income = 4;
|
||||
|
||||
// key, GL account name, account type, parent key, postable, tag name, tag value.
|
||||
// Operating = working-capital account for the Cash Flow report (cashFlowCategory OPERATING).
|
||||
private record AccountDef(string Key, string Name, short TypeId, string? ParentKey, bool Postable, string? TagField, string? TagValue, bool Operating = false);
|
||||
|
||||
private static readonly AccountDef[] Chart =
|
||||
[
|
||||
new(GlKeys.CashEquivalents, "Cash and Cash Equivalents", Asset, null, false, "balanceSheetClassification", "CURRENT_ASSET"),
|
||||
new(GlKeys.CashInHand, "Cash in Hand", Asset, GlKeys.CashEquivalents, false, null, null),
|
||||
new(GlKeys.BankAccounts, "Bank Accounts", Asset, GlKeys.CashEquivalents, false, null, null),
|
||||
new(GlKeys.ChequesInHand, "Cheques in Hand", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.Receivable, "Trade Receivables", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.Inventory, "Inventory", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.InputVat, "Input VAT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.InputNbt, "Input NBT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
|
||||
new(GlKeys.Payable, "Trade Payables", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
||||
new(GlKeys.ChequesIssued, "Cheques Issued - Uncleared", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
|
||||
new(GlKeys.Equity, "Owner's Equity", Equity, null, false, null, null),
|
||||
new(GlKeys.ShareCapital, "Share Capital / Owner's Capital", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.RetainedEarnings, "Retained Earnings", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.Drawings, "Drawings and Dividends", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.GeneralReserve, "General Reserve", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.RevaluationReserve, "Revaluation Reserve", Equity, GlKeys.Equity, true, null, null),
|
||||
new(GlKeys.Sales, "Sales Revenue", Income, null, true, "incomeStatementSection", "SALES"),
|
||||
];
|
||||
|
||||
public async Task<GlSetupResult> RunAsync(int? year, Guid? userId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await gl.IsHealthyAsync(ct))
|
||||
throw new InvalidOperationException("The General Ledger service is not reachable. Check GeneralLedger:BaseUrl and that the service is running.");
|
||||
|
||||
var actions = new List<string>();
|
||||
var codes = new Dictionary<string, string>();
|
||||
|
||||
foreach (var key in GlKeys.AllAccountKeys)
|
||||
{
|
||||
var existing = (await configRepo.GetByKeyAsync(key))?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(existing)) codes[key] = existing;
|
||||
}
|
||||
|
||||
// 1. Chart of accounts (skips accounts whose code is already stored and still exists in GL).
|
||||
foreach (var def in Chart)
|
||||
{
|
||||
if (codes.TryGetValue(def.Key, out var code) && await AccountIdAsync(code, ct) is not null)
|
||||
continue;
|
||||
|
||||
long? parentId = null;
|
||||
if (def.ParentKey is not null)
|
||||
parentId = await AccountIdAsync(codes[def.ParentKey], ct);
|
||||
|
||||
var created = await CreateAccountAsync(def.Name, def.TypeId, parentId, def.Postable, ct);
|
||||
codes[def.Key] = created.Code;
|
||||
actions.Add($"Created GL account {created.Code} - {def.Name}");
|
||||
}
|
||||
|
||||
// Tags are applied on every run (idempotent), so accounts created by an earlier run are corrected too.
|
||||
foreach (var def in Chart)
|
||||
{
|
||||
var tags = new Dictionary<string, object?>();
|
||||
if (def.TagField is not null) tags[def.TagField] = def.TagValue;
|
||||
if (def.Operating) tags["cashFlowCategory"] = "OPERATING";
|
||||
if (!def.Postable) tags["isPostable"] = false; // header accounts (re-applied so older runs are corrected)
|
||||
if (tags.Count > 0) await UpdateAccountAsync(codes[def.Key], tags, ct);
|
||||
}
|
||||
|
||||
// 2. Fiscal year + periods for the requested year (default: current year).
|
||||
var fy = year ?? DateTime.Now.Year;
|
||||
var probe = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={fy}-06-30", null, ct);
|
||||
if (!probe.Success)
|
||||
{
|
||||
var fyResult = await gl.SendAsync(HttpMethod.Post, "v1/fiscal-years", new
|
||||
{
|
||||
yearName = $"FY{fy}",
|
||||
startDate = $"{fy}-01-01",
|
||||
endDate = $"{fy}-12-31",
|
||||
autoGeneratePeriods = true,
|
||||
periodCount = 12
|
||||
}, ct);
|
||||
if (!fyResult.Success)
|
||||
throw new InvalidOperationException($"Could not create fiscal year {fy} in GL: {GlJson.Message(fyResult) ?? fyResult.StatusCode.ToString()}");
|
||||
actions.Add($"Created fiscal year FY{fy} with 12 periods");
|
||||
}
|
||||
|
||||
// 3. GL cash root (GL locks this key after one edit, so only set it when empty).
|
||||
var cashRootId = await AccountIdAsync(codes[GlKeys.CashEquivalents], ct);
|
||||
var cfgResult = await gl.SendAsync(HttpMethod.Get, "v1/system-config", null, ct);
|
||||
var currentRoot = ReadGlConfigValue(cfgResult, "CASH_ACCOUNT_ROOT_ID");
|
||||
if (string.IsNullOrWhiteSpace(currentRoot) && cashRootId is not null)
|
||||
{
|
||||
var set = await gl.SendAsync(HttpMethod.Put, "v1/system-config/CASH_ACCOUNT_ROOT_ID",
|
||||
new { configValue = cashRootId.Value.ToString() }, ct);
|
||||
if (!set.Success)
|
||||
throw new InvalidOperationException($"Could not set CASH_ACCOUNT_ROOT_ID in GL: {GlJson.Message(set) ?? set.StatusCode.ToString()}");
|
||||
actions.Add("Set GL CASH_ACCOUNT_ROOT_ID");
|
||||
}
|
||||
|
||||
// 4. Persist the mapping, then mirror every existing Distribution cash/bank account.
|
||||
await SaveConfigAsync(codes, userId, ct);
|
||||
|
||||
foreach (var account in await context.BankAccounts.Where(a => a.GlAccountCode == null).ToListAsync(ct))
|
||||
{
|
||||
var code = await EnsureBankAccountAsync(account, ct);
|
||||
if (code is not null) actions.Add($"Mirrored {account.AccountName} as GL account {code}");
|
||||
}
|
||||
|
||||
await SaveConfigAsync(new Dictionary<string, string> { [GlKeys.SetupDone] = "true" }, userId, ct);
|
||||
return new GlSetupResult(true, actions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the GL account code for a Distribution cash/bank account, creating the GL child account on first use.
|
||||
/// Saves the code on the BankAccount. Returns null if GL setup has not been run.
|
||||
/// </summary>
|
||||
public async Task<string?> EnsureBankAccountAsync(Models.BankAccount account, CancellationToken ct = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account.GlAccountCode)) return account.GlAccountCode;
|
||||
|
||||
var parentKey = account.AccountType == BankAccountType.Bank ? GlKeys.BankAccounts : GlKeys.CashInHand;
|
||||
var parentCode = (await configRepo.GetByKeyAsync(parentKey))?.Value;
|
||||
if (string.IsNullOrWhiteSpace(parentCode)) return null;
|
||||
|
||||
var parentId = await AccountIdAsync(parentCode, ct);
|
||||
if (parentId is null) return null;
|
||||
|
||||
var name = account.AccountType == BankAccountType.Bank && !string.IsNullOrWhiteSpace(account.BankName)
|
||||
? $"{account.BankName} - {account.AccountName}"
|
||||
: account.AccountName;
|
||||
|
||||
var created = await CreateAccountAsync(name, Asset, parentId, true, ct);
|
||||
|
||||
account.GlAccountCode = created.Code;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
context.BankAccounts.Update(account);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return created.Code;
|
||||
}
|
||||
|
||||
private async Task SaveConfigAsync(Dictionary<string, string> values, Guid? userId, CancellationToken ct)
|
||||
{
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var (key, value) in values)
|
||||
await configRepo.UpsertAsync(new Models.SysConfig { Key = key, Value = value, UpdatedAt = DateTime.Now, UpdatedBy = userId });
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
}
|
||||
|
||||
private async Task<long?> AccountIdAsync(string code, CancellationToken ct)
|
||||
{
|
||||
var result = await gl.SendAsync(HttpMethod.Get, $"v1/accounts/{Uri.EscapeDataString(code)}", null, ct);
|
||||
if (!result.Success) return null;
|
||||
var data = GlJson.Data(result);
|
||||
if (data is null) return null;
|
||||
// GET /accounts/{code} wraps the account: { account: {...}, ... }
|
||||
var account = GlJson.Get(data.Value, "account") ?? data.Value;
|
||||
return GlJson.GetLong(account, "accountId");
|
||||
}
|
||||
|
||||
private async Task<(long Id, string Code)> CreateAccountAsync(string name, short typeId, long? parentId, bool postable, CancellationToken ct)
|
||||
{
|
||||
var result = await gl.SendAsync(HttpMethod.Post, "v1/accounts", new
|
||||
{
|
||||
accountName = name,
|
||||
parentAccountId = parentId,
|
||||
accountTypeId = typeId,
|
||||
isControlAccount = false,
|
||||
isPostable = postable
|
||||
}, ct);
|
||||
if (!result.Success)
|
||||
throw new InvalidOperationException($"Could not create GL account '{name}': {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
||||
|
||||
var data = GlJson.Data(result) ?? throw new InvalidOperationException($"GL returned no data when creating account '{name}'.");
|
||||
var id = GlJson.GetLong(data, "accountId");
|
||||
var code = GlJson.GetString(data, "accountCode");
|
||||
if (id is null || code is null)
|
||||
throw new InvalidOperationException($"GL returned an unexpected body when creating account '{name}'.");
|
||||
return (id.Value, code);
|
||||
}
|
||||
|
||||
private async Task UpdateAccountAsync(string code, Dictionary<string, object?> fields, CancellationToken ct)
|
||||
{
|
||||
var result = await gl.SendAsync(HttpMethod.Put, $"v1/accounts/{Uri.EscapeDataString(code)}", fields, ct);
|
||||
if (!result.Success)
|
||||
throw new InvalidOperationException($"Could not update GL account {code}: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
|
||||
}
|
||||
|
||||
private static string? ReadGlConfigValue(GeneralLedgerResult result, string key)
|
||||
{
|
||||
if (!result.Success || GlJson.Data(result) is not { } data) return null;
|
||||
var items = data.ValueKind == JsonValueKind.Array ? data : GlJson.Get(data, "items");
|
||||
if (items is not { ValueKind: JsonValueKind.Array }) return null;
|
||||
foreach (var item in items.Value.EnumerateArray())
|
||||
if (GlJson.GetString(item, "configKey") == key) return GlJson.GetString(item, "configValue");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.SysConfig;
|
||||
@@ -13,7 +14,8 @@ public class GrnService(
|
||||
IUnitOfWork uow,
|
||||
Distribution.Features.Item.IItemRepository itemRepo,
|
||||
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
|
||||
Distribution.Features.SysConfig.SysConfigService sysConfig)
|
||||
Distribution.Features.SysConfig.SysConfigService sysConfig,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
|
||||
{
|
||||
@@ -482,6 +484,7 @@ public class GrnService(
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||
await gl.PostGrnAsync(saved!, ct);
|
||||
return Map(saved!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -8,7 +9,8 @@ namespace Distribution.Features.Invoice;
|
||||
public class InvoiceService(
|
||||
IInvoiceRepository repo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static InvoiceResponse ToResponse(Models.Invoice i) =>
|
||||
new(i.Id, i.FK_ShopPOId,
|
||||
@@ -96,6 +98,8 @@ public class InvoiceService(
|
||||
try { repo.Update(invoice); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
await gl.PostInvoiceFinalizedAsync(invoice, ct);
|
||||
|
||||
return ToResponse(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Invoice;
|
||||
using Distribution.Features.ReceivedCheque;
|
||||
using Distribution.Features.StoreCredit;
|
||||
@@ -15,7 +16,8 @@ public class PaymentService(
|
||||
IReceivedChequeRepository receivedChequeRepo,
|
||||
IStoreCreditMovementRepository creditRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
|
||||
@@ -150,14 +152,13 @@ public class PaymentService(
|
||||
|
||||
invoiceRepo.Update(invoice);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Cash/Bank (BankAccount) OR Cheques-in-hand (for Cheque payments)
|
||||
// Credit Accounts Receivable (invoice.Store)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// Mirrors to the General Ledger after commit: Dr Cash/Bank (or Cheques-in-hand) / Cr Receivable. Never throws.
|
||||
await gl.PostCustomerPaymentAsync(payment, ct);
|
||||
|
||||
return ToResponse(payment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
@@ -8,7 +9,8 @@ namespace Distribution.Features.ReceivedCheque;
|
||||
public class ReceivedChequeService(
|
||||
IReceivedChequeRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IUnitOfWork uow)
|
||||
IUnitOfWork uow,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
|
||||
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
|
||||
@@ -110,6 +112,7 @@ public class ReceivedChequeService(
|
||||
|
||||
repo.Update(cheque);
|
||||
await uow.CommitAsync(ct);
|
||||
await gl.PostReceivedChequeAsync(cheque, request.Action, ct);
|
||||
return ToResponse(cheque);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Features.GeneralLedger;
|
||||
using Distribution.Features.Grn;
|
||||
using Distribution.Features.Supplier;
|
||||
using Distribution.Infra.Authorization;
|
||||
@@ -17,7 +18,8 @@ public class SupplierPaymentService(
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IChequeBookRepository chequeBookRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
ICurrentUserService currentUser,
|
||||
GlPostingService gl)
|
||||
{
|
||||
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
|
||||
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
|
||||
@@ -161,14 +163,13 @@ public class SupplierPaymentService(
|
||||
|
||||
await repo.AddAsync(payment);
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Accounts Payable (supplier)
|
||||
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// Mirrors to the General Ledger after commit: Dr Payable / Cr Cash/Bank (or Cheques-issued). Never throws.
|
||||
await gl.PostSupplierPaymentAsync(payment, ct);
|
||||
|
||||
return ToResponse(payment);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user