make account service with grn
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <inheritdoc cref="IGeneralLedgerService"/>
|
||||
public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IGeneralLedgerClient _client;
|
||||
|
||||
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
|
||||
@@ -13,4 +23,89 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
|
||||
public Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
||||
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
|
||||
|
||||
public async Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
|
||||
{
|
||||
using var body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(request, JsonOptions));
|
||||
var response = await _client.SendAsync(HttpMethod.Post, "journal-entries", null, "application/json", body, ct);
|
||||
var data = ParseSuccess<GlJournalEntryData>(response);
|
||||
return new GlJournalEntryResult(data.JournalNo, data.IsPosted);
|
||||
}
|
||||
|
||||
public async Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.SendAsync(
|
||||
HttpMethod.Get, "fiscal-years/periods/by-date", $"?date={date:yyyy-MM-dd}", null, null, ct);
|
||||
var data = ParseSuccess<GlPeriodData>(response);
|
||||
return new GlPeriod(data.PeriodId, data.FiscalYearId);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct)
|
||||
{
|
||||
var response = await _client.SendAsync(HttpMethod.Get, "bank-accounts", "?accountType=Both", null, null, ct);
|
||||
var data = ParseSuccess<List<GlBankAccountData>>(response);
|
||||
return data.Select(a => new GlBankAccount(
|
||||
a.AccountType, a.AccountId, a.AccountName, a.BankName,
|
||||
a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses GL's <c>ApiResponse</c> envelope (case-insensitively, since GL's success bodies are
|
||||
/// camelCase and its error bodies are PascalCase — docs/12 §4) and throws
|
||||
/// <see cref="DomainException"/> if the call didn't succeed.
|
||||
/// </summary>
|
||||
private static T ParseSuccess<T>(GeneralLedgerResponse response)
|
||||
{
|
||||
GlEnvelope<T>? envelope;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<GlEnvelope<T>>(response.Body, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
envelope = null;
|
||||
}
|
||||
|
||||
if (response.StatusCode is < 200 or >= 300 || envelope is null || !envelope.Success || envelope.Data is null)
|
||||
{
|
||||
var message = envelope?.Message ?? "The General Ledger service rejected the request.";
|
||||
var status = response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502;
|
||||
throw new DomainException(ErrorCodes.GlRequestFailed, message, status);
|
||||
}
|
||||
|
||||
return envelope.Data;
|
||||
}
|
||||
|
||||
private sealed class GlEnvelope<T>
|
||||
{
|
||||
public int StatusCode { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlJournalEntryData
|
||||
{
|
||||
public string JournalNo { get; set; } = string.Empty;
|
||||
public bool IsPosted { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlPeriodData
|
||||
{
|
||||
public int PeriodId { get; set; }
|
||||
public int FiscalYearId { get; set; }
|
||||
}
|
||||
|
||||
private sealed class GlBankAccountData
|
||||
{
|
||||
public string AccountType { get; set; } = string.Empty;
|
||||
public long AccountId { get; set; }
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public string? BankName { get; set; }
|
||||
public string? CashAccountTypeName { get; set; }
|
||||
public string? AccountNumber { get; set; }
|
||||
public long GlAccountId { get; set; }
|
||||
public string GlAccountCode { get; set; } = string.Empty;
|
||||
public string CurrencyCode { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ERPCore.Services.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Purpose-built request/response shapes for the specific GL endpoints ERPCore's GRN
|
||||
/// module calls directly (journal-entries, period lookup, bank-account listing) — not
|
||||
/// a full model of GL's contract (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6: typed
|
||||
/// DTOs are added only for the flow that actually needs them).
|
||||
/// </summary>
|
||||
public sealed record GlJournalEntryLineRequest(string AccountCode, decimal DebitAmount, decimal CreditAmount, string? Memo = null);
|
||||
|
||||
public sealed class GlJournalEntryRequest
|
||||
{
|
||||
public int PeriodId { get; set; }
|
||||
public DateOnly EntryDate { get; set; }
|
||||
public string? SourceModule { get; set; }
|
||||
public string? Reference { get; set; }
|
||||
public string? Narration { get; set; }
|
||||
public List<GlJournalEntryLineRequest> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed record GlJournalEntryResult(string JournalNo, bool IsPosted);
|
||||
|
||||
public sealed record GlPeriod(int PeriodId, int FiscalYearId);
|
||||
|
||||
public sealed record GlBankAccount(
|
||||
string AccountType, long AccountId, string AccountName, string? BankName,
|
||||
string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode);
|
||||
@@ -0,0 +1,120 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor payments against a confirmed GRN. Multiple installments are allowed until
|
||||
/// <see cref="Grn.BalanceAmount"/> reaches zero. Each payment posts its own real GL
|
||||
/// journal entry (Debit GRN Clearing / Credit the selected bank-or-cash account) before
|
||||
/// being recorded, using the same call-GL-before-commit pattern as <see cref="GrnService.ConfirmAsync"/>
|
||||
/// so a rejected/unreachable GL post rolls back the whole payment atomically.
|
||||
/// </summary>
|
||||
public sealed class GrnPaymentService : IGrnPaymentService
|
||||
{
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly IRepository<GrnPayment> _payments;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public GrnPaymentService(
|
||||
IRepository<Grn> grns, IRepository<GrnPayment> payments, IGeneralLedgerService gl,
|
||||
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_payments = payments;
|
||||
_gl = gl;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
if (grn.Status == GrnStatus.Draft)
|
||||
throw new DomainException(ErrorCodes.GrnNotPayable, $"GRN {grnId} must be confirmed before it can be paid.", 409);
|
||||
if (request.Amount > grn.BalanceAmount)
|
||||
throw new DomainException(ErrorCodes.GrnPaymentExceedsBalance,
|
||||
$"Payment amount {request.Amount} exceeds the remaining balance {grn.BalanceAmount}.", 400);
|
||||
|
||||
var accounts = await _gl.ListBankAccountsAsync(ct);
|
||||
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
|
||||
?? throw new DomainException(ErrorCodes.GrnBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var payment = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
// Same atomicity approach as GrnService.ConfirmAsync: post to GL first, inside
|
||||
// this transaction, before anything is committed — a GL rejection/timeout rolls
|
||||
// the whole payment back with no partial local state.
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "GRN_PAYMENT",
|
||||
Reference = grn.DocNo,
|
||||
Narration = $"Payment against GRN {grn.DocNo}",
|
||||
Lines =
|
||||
[
|
||||
new GlJournalEntryLineRequest(_glClearingAccountCode, request.Amount, 0m, $"Payment against GRN {grn.DocNo}"),
|
||||
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against GRN {grn.DocNo}")
|
||||
]
|
||||
}, token);
|
||||
|
||||
var entity = new GrnPayment
|
||||
{
|
||||
GrnId = grn.GrnId,
|
||||
Amount = request.Amount,
|
||||
PaymentDate = now,
|
||||
GlBankAccountId = account.AccountId,
|
||||
BankAccountName = account.AccountName,
|
||||
Reference = request.Reference,
|
||||
GlJournalNo = posted.JournalNo,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = now
|
||||
};
|
||||
await _payments.AddAsync(entity, token);
|
||||
|
||||
grn.PaidAmount += request.Amount;
|
||||
grn.BalanceAmount -= request.Amount;
|
||||
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(payment);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _grns.Query().AnyAsync(g => g.GrnId == grnId, ct))
|
||||
throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
return await _payments.Query().AsNoTracking()
|
||||
.Where(p => p.GrnId == grnId)
|
||||
.OrderByDescending(p => p.GrnPaymentId)
|
||||
.Select(p => new GrnPaymentDto(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private static GrnPaymentDto Map(GrnPayment p) => new(
|
||||
p.GrnPaymentId, p.GrnId, p.Amount, p.PaymentDate,
|
||||
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -38,13 +39,18 @@ public sealed class GrnService : IGrnService
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly string _glInventoryAccountCode;
|
||||
private readonly string _glVatRecoverableAccountCode;
|
||||
private readonly string _glClearingAccountCode;
|
||||
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IConfiguration configuration)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
@@ -60,6 +66,10 @@ public sealed class GrnService : IGrnService
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
_gl = gl;
|
||||
_glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty;
|
||||
_glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty;
|
||||
_glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
@@ -82,7 +92,7 @@ public sealed class GrnService : IGrnService
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(g => new GrnSummaryDto(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
|
||||
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.PaidAmount, g.BalanceAmount))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -260,15 +270,47 @@ public sealed class GrnService : IGrnService
|
||||
}
|
||||
}
|
||||
|
||||
// Post the real GL journal entry for this receipt before committing — if GL
|
||||
// rejects it or is unreachable, the exception propagates out of this callback
|
||||
// and the whole transaction (FIFO layers, stock ledger, PO accrual) rolls back
|
||||
// with it, so inventory and the ledger never diverge (user-approved "fails
|
||||
// atomically" behavior; residual risk if GL posts but the local commit still
|
||||
// fails afterward is accepted for this phase, see the integration plan).
|
||||
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
|
||||
var glLines = new List<GlJournalEntryLineRequest>();
|
||||
decimal totalPayable = 0m;
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}"));
|
||||
if (line.VatAmount > 0)
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT"));
|
||||
totalPayable += line.LineTotal;
|
||||
}
|
||||
glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received"));
|
||||
|
||||
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
|
||||
{
|
||||
PeriodId = period.PeriodId,
|
||||
EntryDate = DateOnly.FromDateTime(now),
|
||||
SourceModule = "GRN",
|
||||
Reference = grn.DocNo,
|
||||
Narration = $"Goods received - GRN {grn.DocNo}",
|
||||
Lines = glLines
|
||||
}, token);
|
||||
|
||||
grn.Status = GrnStatus.Confirmed;
|
||||
grn.PostedAt = now;
|
||||
grn.GlJournalNo = posted.JournalNo;
|
||||
grn.GlPostedAt = now;
|
||||
grn.PaidAmount = 0m;
|
||||
grn.BalanceAmount = totalPayable;
|
||||
|
||||
await UpdatePoStatusAsync(grn.PoId, token);
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, now,
|
||||
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
createdLayers.Select(ToCreatedLayer).ToList(),
|
||||
ledgerRefs.Select(l => l.LedgerId).ToList(),
|
||||
await GetPoStatusAsync(grn.PoId, ct));
|
||||
@@ -395,7 +437,7 @@ public sealed class GrnService : IGrnService
|
||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt,
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty, grn.BalanceAmount,
|
||||
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
|
||||
}
|
||||
|
||||
@@ -404,6 +446,7 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.GlJournalNo, g.PaidAmount, g.BalanceAmount,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
using ERPCore.Infra.Gl;
|
||||
using ERPCore.Services.Gl;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Single entry point into the external General Ledger service — the one function
|
||||
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
|
||||
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
|
||||
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
|
||||
/// No business logic lives here yet; this pass only connects the transport.
|
||||
/// requests, forwarded verbatim) and by other ERPCore services that post directly to
|
||||
/// GL (docs/12-GENERAL-LEDGER-INTEGRATION.md §5/§6). <see cref="ForwardAsync"/> stays a
|
||||
/// byte-for-byte passthrough; the three typed methods below are the first internal
|
||||
/// callers (GRN receipt + payment posting) and model only what those flows need.
|
||||
/// </summary>
|
||||
public interface IGeneralLedgerService
|
||||
{
|
||||
Task<GeneralLedgerResponse> ForwardAsync(
|
||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
|
||||
|
||||
/// <summary>Creates and posts a balanced journal entry. Throws <see cref="ERPCore.System.Errors.DomainException"/> on rejection/unreachability.</summary>
|
||||
Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>Resolves the accounting period covering <paramref name="date"/>.</summary>
|
||||
Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct);
|
||||
|
||||
/// <summary>Lists GL's cash and bank accounts (default: both types).</summary>
|
||||
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ERPCore.Dtos.Grn;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Vendor payments against a confirmed GRN's balance (installments allowed).</summary>
|
||||
public interface IGrnPaymentService
|
||||
{
|
||||
Task<GrnPaymentDto> PayAsync(int grnId, CreateGrnPaymentRequest request, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<GrnPaymentDto>> ListAsync(int grnId, CancellationToken ct = default);
|
||||
}
|
||||
Reference in New Issue
Block a user