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:
2026-10-20 18:54:30 +05:30
parent cc33ecdd54
commit ca81e9bc42
32 changed files with 8786 additions and 134 deletions
+11
View File
@@ -66,6 +66,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
public DbSet<SysConfig> SysConfigs { get; set; }
public DbSet<BankAccount> BankAccounts { get; set; }
public DbSet<GlPostingLog> GlPostingLogs { get; set; }
public DbSet<ChequeBook> ChequeBooks { get; set; }
public DbSet<ChequePage> ChequePages { get; set; }
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
@@ -582,6 +583,16 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId)
.IsUnique();
// --- GL posting log ---
modelBuilder.Entity<GlPostingLog>()
.HasIndex(l => new { l.Event, l.SourceId })
.IsUnique();
modelBuilder.Entity<GlPostingLog>()
.Property(l => l.Event)
.HasMaxLength(60);
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
@@ -68,6 +68,8 @@ public static class RoleAndPermissionSeed
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
private static readonly Guid PermFinanceReportView = new("20000000-0000-0000-0000-000000000051");
private static readonly Guid PermGlManage = new("20000000-0000-0000-0000-000000000052");
public static void Apply(ModelBuilder modelBuilder)
{
@@ -134,7 +136,9 @@ public static class RoleAndPermissionSeed
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermFinanceReportView, Code = "finance-reports.view", Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGlManage, Code = "gl.manage", Description = "Set up the General Ledger link and retry failed postings", CreatedAt = SeedDate, IsActive = true }
);
// SuperAdmin gets every permission
@@ -191,7 +195,9 @@ public static class RoleAndPermissionSeed
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermFinanceReportView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGlManage, CreatedAt = SeedDate, IsActive = true }
);
}
}
@@ -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&amp;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;
}
}
+4 -1
View File
@@ -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);
}
}
@@ -98,6 +98,12 @@ namespace Distribution.Migrations
table: "DeliveryTasks",
newName: "FK_ShopPOId");
// MariaDB drops the ascii collation on CHANGE COLUMN; restore it so the FK to ShopPOs.Id can be created.
migrationBuilder.Sql("ALTER TABLE `DeliveryTasks` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
migrationBuilder.Sql("ALTER TABLE `SchemeRedemptions` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NULL;");
migrationBuilder.Sql("ALTER TABLE `SaleReturns` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
migrationBuilder.Sql("ALTER TABLE `Payments` MODIFY `FK_InvoiceId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
migrationBuilder.RenameIndex(
name: "IX_DeliveryTasks_FK_OrderId",
table: "DeliveryTasks",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,814 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class GlIntegration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GlAccountCode",
table: "BankAccounts",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "GlPostingLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Event = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SourceId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Reference = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<int>(type: "int", nullable: false),
PayloadJson = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
GlJournalNo = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Error = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Attempts = table.Column<int>(type: "int", nullable: false),
LastAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GlPostingLogs", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "BankAccounts",
keyColumn: "Id",
keyValue: new Guid("11111111-1111-1111-1111-000000000001"),
column: "GlAccountCode",
value: null);
migrationBuilder.InsertData(
table: "Permissions",
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000051"), "finance-reports.view", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "View financial reports (trial balance, P&L, balance sheet, cash flow...)", true },
{ new Guid("20000000-0000-0000-0000-000000000052"), "gl.manage", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Set up the General Ledger link and retry failed postings", true }
});
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("52dedbb8-6251-4131-82de-41c2064a663b"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("47450af3-cf95-4725-a208-eeca124022b7"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("44deed45-fc6f-420c-a026-f4483efab782"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("12ae0586-2ff1-4333-92be-9e4660d0f34d"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("cd258515-3abd-448f-9972-1b9ae9a953d8"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("9d2dcbbe-cf49-4842-bbcd-1ed0c3bf5f05"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("16ad892c-b785-4872-a426-1015f32c90b9"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("3ac5579e-2ba0-44e2-b194-e8a34c91d831"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("498230b5-7d98-4826-afaf-944123658f69"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("e960268d-ad0e-4cdc-a387-e2b4fa4ede47"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("61b598f0-1138-4733-8dac-b84911b1ac18"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("91ab95eb-c25b-44f9-be90-9b0ffeba4e7a"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("96ffe8c1-2d62-4d56-8933-02bc4f79972e"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("1f38d01e-e333-40cd-811c-cc1bcaaaa97b"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("8c768b0a-0920-4b28-b48a-3f62ba8a16d5"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("7a87ddf5-e185-4978-9390-09388ab1b264"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("86229744-f68d-4e7e-9b07-2aad58584331"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("646c3d0c-801a-4382-8a50-3a8d57d3225e"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("e2b513a0-5fae-4642-8bf5-c2e3dc734aca"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("59ce3bc1-2466-4090-97fe-9ae81d718a83"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("48772797-f181-423d-bfb3-f6226a9abe11"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("e99740a5-b681-4b6b-9a42-754ca3114e00"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("a1b180a1-fbc5-4f18-aeaa-82b443a4fc3f"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("83070521-f8f2-4b54-ae59-8cd2972de2a1"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("132b8143-0836-4a3b-b5b3-90cdbe6df12d"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("9ad2e440-e355-41c8-9e9e-00a78de66abb"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("b76e221a-fb92-43a3-a723-dcd5fa3327f6"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("8f63c426-38bb-456d-adff-75e477857607"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("e4a559d1-bc00-438b-b636-a85803a7ef02"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("d0a1ab21-0809-4605-a1d2-29e546cb4ae8"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("1e3fb35b-0de5-4d15-860f-51e0c9ec1bb1"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("12e98296-456e-40a3-b11c-61d3393b335e"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("1d47dc32-9faa-418e-8241-a0dc8a32c974"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("5e701ad5-f462-4642-86d1-303148323a32"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("52dfa802-8a12-4554-b7ab-1e286998b3b9"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("7944f075-4eb3-4ccd-98bf-584f79f9f4d5"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("6ce15356-7240-4a79-bfc8-340c8f31e270"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("ac248603-3c71-4c1b-84dc-174f25cade65"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("7805ec5e-8b99-4b45-b47a-be01405a2151"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("7ea67b95-2918-48e9-b569-71123adb82a0"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("a2515783-f099-4332-a07c-be81f2e85160"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("2bff0498-d66e-4564-b0ec-5924cbab96a3"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("5fa95034-9d7f-4350-b754-9a16b03804fe"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("f1a82d54-68b8-4992-a4ba-28bd58dd5281"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("fa183267-8eb0-4831-8f5f-ba1af4d5a78d"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("fd75e8b3-8751-4b67-870b-6370098a5e1e"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("c6632177-d567-4cce-98dd-80d735c2e5db"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("b463deda-4bb9-4d86-a0fc-513d10fc57b1"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("b44bfcb4-ff1d-4848-9098-1ae54bace236"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("93c036e4-d811-4915-b73c-1cb358f17065"));
migrationBuilder.InsertData(
table: "RolePermissions",
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000051"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("9c27d325-1915-41c6-9b62-d85a12f102e1"), true },
{ new Guid("20000000-0000-0000-0000-000000000052"), new Guid("10000000-0000-0000-0000-000000000007"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1f48dd7b-2f14-451d-b80d-71579d5625e6"), true }
});
migrationBuilder.CreateIndex(
name: "IX_GlPostingLogs_Event_SourceId",
table: "GlPostingLogs",
columns: new[] { "Event", "SourceId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GlPostingLogs");
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000051"), new Guid("10000000-0000-0000-0000-000000000007") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000052"), new Guid("10000000-0000-0000-0000-000000000007") });
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000051"));
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000052"));
migrationBuilder.DropColumn(
name: "GlAccountCode",
table: "BankAccounts");
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000001"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("be712eda-98a5-4a30-820a-1737e84d1e4b"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000002"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("47ea9420-d6a0-4376-a2b8-211639d95b27"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000003"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("369eed1f-df4c-4131-a3c6-ebbe63d2dc91"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000004"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("14b6944f-2d4a-4378-a974-c6fc0f9dfb81"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000005"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("14a01e0b-7be5-4c65-ae6a-899f9aa7ad28"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000006"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("5d486ccd-3866-4e7d-8ef1-fb177fb637c1"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000007"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("d830e59b-f451-4bde-9a77-78daea5f9a61"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000008"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("096d3837-a649-4184-8987-2ea0dfa2d4a1"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000009"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("5e43bbde-689b-4d07-baf9-0764078b5586"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000010"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("f317d30c-1644-44f0-b0aa-6bcf1fa66f44"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000011"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("31ffac6f-2aa8-48ee-bc87-213f35933de4"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000012"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("fb525bc4-9f16-455a-bfd5-ca6f3e7ad141"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000013"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("2388918f-6392-4384-b269-0a1469f5bf4c"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000014"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("7159012e-5f25-4881-b5e8-5300e382332e"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000015"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("cc4b2e82-ad42-4440-92ba-9b2100bb5f84"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("6b15c133-ea0e-473d-b58a-7f140e9f03bf"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000017"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("c2dd52a9-380f-479f-8e5e-6b56bfc4738f"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("bf5f6c7a-2fde-4a01-83f4-aacf91333c4c"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("162a0346-c4c0-4c4e-96e9-aa8154d25b39"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000020"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("5cc76807-25d1-45e5-9da3-cfd6d6f3ad7c"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("51a2653e-4b1a-4f6f-80c2-f533b26109ea"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("e2a382e2-d712-4a97-a7ec-cd6232d77a78"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000023"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("b60260b5-70b0-4f9e-9ab9-ba4abbbdfb02"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("a73fe794-383c-4157-89cf-227c4e487a6d"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("a9743e96-15b2-4c1d-b410-698cd6db0b7b"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("a94d92ab-66d8-4dd9-8ba3-c242eb450e68"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("9cb1a028-a98e-492a-aafc-70456fe46c96"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000028"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("5bc99a62-f5d7-48fe-b25c-518e4774614c"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000029"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("eceee678-5cee-4ee9-b16d-8d8d21fbb5ea"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000030"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("3a049ed7-75b7-4b1d-b3bb-402ca77c350f"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("aeaab85c-5b02-4bc2-b0e3-318c0f5b07d3"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("b79800e0-baa6-4add-9374-7d1e2ed650e8"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("0da6bf0b-26ff-46b9-bb8c-f9c641b37a89"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("ff84aeeb-93c0-465d-9a4a-8b686ba7bc75"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("921f8073-7a66-49bb-ae23-cf8fcde12c9c"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000036"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("bff05501-4988-4c8a-b61d-e32389e795a8"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("2c4f7432-e697-4d65-97df-069f8913a662"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000038"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("18d27746-3178-4c57-afd5-b75f0061637f"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("a5d1978b-c497-4314-857b-a7e3b8323a53"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("88300763-b8c3-4bd2-803b-df0e43bc691e"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000041"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("7aaad5a9-4497-429f-b3dc-9b59664e2e19"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000042"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("2db130c1-4b6f-4719-b3bb-7bd8a0662d75"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000043"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("be349d14-38b5-4a13-a152-9ea055de26b1"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000044"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("5292e02d-9a7d-4a18-be14-86c2f3351cac"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000045"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("97021474-1218-4d21-9115-4b9b2726e38b"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000046"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("56f68355-7b1b-49e4-829c-1a4d47fc93de"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000047"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("3017c8cb-f9e4-41c8-ac2e-f03d23fe944a"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000048"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("619a08e5-f6cb-4515-8b4d-a094d19277d8"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000049"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("cb8b4e96-7026-4485-8b52-3145b5289350"));
migrationBuilder.UpdateData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000050"), new Guid("10000000-0000-0000-0000-000000000007") },
column: "Id",
value: new Guid("f0f57dfd-21a5-4a0b-a77e-e641f00300fa"));
}
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -13,5 +13,8 @@ namespace Distribution.Models
public string CurrencyCode { get; set; } = "LKR";
public bool IsDefault { get; set; }
/// <summary>Code of the mirrored General Ledger account (child of GL Cash in Hand / Bank Accounts). Null until synced.</summary>
public string? GlAccountCode { get; set; }
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Distribution.Models
{
public enum GlPostingStatus { Pending, Posted, Failed }
/// <summary>
/// One row per journal entry Distribution sends to the General Ledger service. (Event, SourceId) is unique,
/// so a business event can never be posted twice; Failed rows keep their payload and can be retried.
/// </summary>
public class GlPostingLog : Template
{
public string Event { get; set; } = string.Empty;
public Guid SourceId { get; set; }
public string Reference { get; set; } = string.Empty;
public GlPostingStatus Status { get; set; } = GlPostingStatus.Pending;
/// <summary>The journal entry body (without periodId, which is resolved from EntryDate at send time).</summary>
public string PayloadJson { get; set; } = string.Empty;
public string? GlJournalNo { get; set; }
public string? Error { get; set; }
public int Attempts { get; set; }
public DateTime? LastAttemptAt { get; set; }
}
}
+10
View File
@@ -11,6 +11,7 @@ using Distribution.Features.ChequeBook;
using Distribution.Features.ReceivedCheque;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.GeneralLedger;
using Distribution.Features.Grn;
using Distribution.Features.Item;
using Distribution.Features.ShopPO;
@@ -141,6 +142,8 @@ builder.Services.AddScoped<ChequeBookService>();
builder.Services.AddScoped<ReceivedChequeService>();
builder.Services.AddScoped<SupplierPaymentService>();
builder.Services.AddScoped<SysConfigService>();
builder.Services.AddScoped<GlSetupService>();
builder.Services.AddScoped<GlPostingService>();
builder.Services.AddMemoryCache();
builder.Services.AddHttpContextAccessor();
@@ -150,6 +153,13 @@ builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(client =>
var baseUrl = builder.Configuration["AuthHex:BaseUrl"] ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
client.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(client =>
{
var baseUrl = builder.Configuration["GeneralLedger:BaseUrl"] ?? throw new InvalidOperationException("GeneralLedger:BaseUrl is not configured.");
var apiKey = builder.Configuration["GeneralLedger:ApiKey"] ?? throw new InvalidOperationException("GeneralLedger:ApiKey is not configured.");
client.BaseAddress = new Uri(baseUrl);
client.DefaultRequestHeaders.Add("X-Api-Key", apiKey);
});
builder.Services.AddScoped<IPermissionRepository, PermissionRepository>();
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
builder.Services.AddScoped<IPermissionService, PermissionService>();
+6
View File
@@ -1,8 +1,14 @@
{
"Urls": "http://localhost:8802",
"ConnectionStrings": {
// "DefaultConnection": "Server=localhost;Port=3306;Database=distribution;User=root;Password="
// live server
"DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive"
},
"GeneralLedger": {
"BaseUrl": "http://localhost:6704/api/",
"ApiKey": "ff303a4b1919092501a2ab2b8725709177bbb8f0025c58d39de4ad678e2b4eda"
},
"Logging": {
"LogLevel": {
"Default": "Information",