feat: add purchase invoice batch print page and related components

- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices.
- Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout.
- Added invoice layout presets for different styles (Classic, Compact, Modern).
- Developed PurchaseInvoicePaymentDialog for handling payments against invoices.
- Introduced API mocks for company profile and invoice template configuration.
- Added validation functions for company profile and purchase invoices.
- Defined types for company profile, invoice template configuration, and purchase invoices.
This commit is contained in:
2026-09-01 15:25:32 +05:30
parent 160c687db3
commit bebec10eb3
70 changed files with 12446 additions and 589 deletions
@@ -1,120 +0,0 @@
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
View File
@@ -92,7 +92,8 @@ 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.PaidAmount, g.BalanceAmount))
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count,
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
@@ -102,6 +103,7 @@ public sealed class GrnService : IGrnService
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.Include(g => g.PurchaseInvoice)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
}
@@ -302,15 +304,13 @@ public sealed class GrnService : IGrnService
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.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, now, grn.GlJournalNo ?? string.Empty,
createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct));
@@ -437,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.GlJournalNo ?? string.Empty, grn.BalanceAmount,
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt, grn.GlJournalNo ?? string.Empty,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
}
@@ -446,7 +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.GlJournalNo, g.PurchaseInvoiceId, g.PurchaseInvoice?.DocNo,
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,10 +0,0 @@
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);
}
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Vendor payments against a Posted purchase invoice's balance (installments allowed).</summary>
public interface IPurchaseInvoicePaymentService
{
Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default);
Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
public interface IPurchaseInvoiceService
{
Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default);
Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default);
Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default);
/// <summary>Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice.</summary>
Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default);
}
@@ -0,0 +1,119 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Procurement;
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 Posted purchase invoice. Multiple installments are allowed
/// until <see cref="PurchaseInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit Accounts Payable / Credit the selected bank-or-cash
/// account) — the mirror image of <see cref="SalesInvoicePaymentService"/> (that one
/// credits Accounts Receivable when the customer pays; this one debits Accounts Payable
/// when the vendor is paid) — using the same call-GL-before-commit pattern so a
/// rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<PurchaseInvoicePayment> _payments;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccountsPayableCode;
public PurchaseInvoicePaymentService(
IRepository<PurchaseInvoice> invoices, IRepository<PurchaseInvoicePayment> payments, IGeneralLedgerService gl,
ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
{
_invoices = invoices;
_payments = payments;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PurchaseInvoicePaymentDto> PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default)
{
var invoice = await _invoices.Query().FirstOrDefaultAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Posted)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotPayable, $"Purchase invoice {purchaseInvoiceId} must be posted before it can be paid.", 409);
if (request.Amount > invoice.BalanceAmount)
throw new DomainException(ErrorCodes.PurchaseInvoicePaymentExceedsBalance,
$"Payment amount {request.Amount} exceeds the remaining balance {invoice.BalanceAmount}.", 400);
var accounts = await _gl.ListBankAccountsAsync(ct);
var account = accounts.FirstOrDefault(a => a.AccountId == request.GlBankAccountId)
?? throw new DomainException(ErrorCodes.PurchaseInvoiceBankAccountNotFound, $"Bank/cash account {request.GlBankAccountId} was not found.", 404);
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var payment = await _uow.ExecuteInTransactionAsync(async token =>
{
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE_PAYMENT",
Reference = invoice.DocNo,
Narration = $"Payment against invoice {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}")
]
}, token);
var entity = new PurchaseInvoicePayment
{
PurchaseInvoiceId = invoice.PurchaseInvoiceId,
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);
invoice.PaidAmount += request.Amount;
invoice.BalanceAmount -= request.Amount;
return entity;
}, ct);
return Map(payment);
}
public async Task<IReadOnlyList<PurchaseInvoicePaymentDto>> ListAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct))
throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
return await _payments.Query().AsNoTracking()
.Where(p => p.PurchaseInvoiceId == purchaseInvoiceId)
.OrderByDescending(p => p.PurchaseInvoicePaymentId)
.Select(p => new PurchaseInvoicePaymentDto(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt))
.ToListAsync(ct);
}
private static PurchaseInvoicePaymentDto Map(PurchaseInvoicePayment p) => new(
p.PurchaseInvoicePaymentId, p.PurchaseInvoiceId, p.Amount, p.PaymentDate,
p.GlBankAccountId, p.BankAccountName, p.Reference, p.GlJournalNo, p.CreatedAt);
}
@@ -0,0 +1,303 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Dtos.Procurement;
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>
/// Purchase invoice service. A purchase invoice consolidates one or more Confirmed
/// GRNs for the same vendor into a single vendor-facing document — each linked GRN
/// becomes one <see cref="PurchaseInvoiceLine"/>, a straight sum of that GRN's line
/// totals (no re-itemization). Draft invoices are fully editable (GRNs can be
/// re-selected); Post reclassifies the GRN clearing balance to Accounts Payable via a
/// real GL journal entry (mirrors <see cref="SalesInvoiceService"/>'s AR recognition);
/// vendor payments are then recorded against this aggregate (see
/// <see cref="PurchaseInvoicePaymentService"/>) rather than per-GRN.
/// </summary>
public sealed class PurchaseInvoiceService : IPurchaseInvoiceService
{
private readonly IRepository<PurchaseInvoice> _invoices;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Grn> _grns;
private readonly ICurrentUser _currentUser;
private readonly INumberSequenceService _numbers;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glClearingAccountCode;
private readonly string _glAccountsPayableCode;
public PurchaseInvoiceService(
IRepository<PurchaseInvoice> invoices, IRepository<Vendor> vendors, IRepository<Grn> grns,
ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow,
IGeneralLedgerService gl, IConfiguration configuration)
{
_invoices = invoices;
_vendors = vendors;
_grns = grns;
_currentUser = currentUser;
_numbers = numbers;
_uow = uow;
_gl = gl;
_glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty;
_glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty;
}
public async Task<PagedResponse<PurchaseInvoiceSummaryDto>> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default)
{
IQueryable<PurchaseInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(x => EF.Functions.ILike(x.DocNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorInvoiceNo, $"%{term}%")
|| EF.Functions.ILike(x.VendorSnapshotName, $"%{term}%"));
}
if (status is not null) q = q.Where(x => x.Status == status);
if (vendorId is not null) q = q.Where(x => x.VendorId == vendorId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.PurchaseInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
return PagedResponse<PurchaseInvoiceSummaryDto>.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total);
}
public async Task<ETagged<PurchaseInvoiceDto>?> GetAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query().AsNoTracking()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct);
return invoice is null ? null : new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> CreateAsync(CreatePurchaseInvoiceRequest request, CancellationToken ct = default)
{
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: null, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: null, ct);
var invoice = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new PurchaseInvoice
{
DocNo = await _numbers.NextAsync(DocumentTypes.PurchaseInvoice, token),
VendorInvoiceNo = vendorInvoiceNo,
VendorInvoiceDate = request.VendorInvoiceDate,
VendorId = request.VendorId,
VendorSnapshotName = vendor.Name,
VendorSnapshotTaxNo = vendor.TaxReg,
Status = PurchaseInvoiceStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = grns.Select(BuildLine).ToList()
};
Recalculate(entity);
await _invoices.AddAsync(entity, token);
foreach (var grn in grns) grn.PurchaseInvoice = entity;
return entity;
}, ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<ETagged<PurchaseInvoiceDto>> UpdateAsync(int purchaseInvoiceId, UpdatePurchaseInvoiceRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase invoice was modified by another request.", 412);
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new DomainException(ErrorCodes.PurchaseInvoiceNotEditable, $"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be edited.", 409);
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == request.VendorId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
var vendorInvoiceNo = request.VendorInvoiceNo.Trim();
await EnsureVendorInvoiceNoIsUniqueAsync(request.VendorId, vendorInvoiceNo, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
var grns = await LoadAndValidateGrnsAsync(request.VendorId, request.GrnIds, excludingPurchaseInvoiceId: purchaseInvoiceId, ct);
// Free GRNs that were linked to this invoice but are no longer selected.
var newGrnIds = grns.Select(g => g.GrnId).ToHashSet();
foreach (var line in invoice.Lines)
if (line.Grn is not null && !newGrnIds.Contains(line.Grn.GrnId)) line.Grn.PurchaseInvoiceId = null;
invoice.VendorId = request.VendorId;
invoice.VendorInvoiceNo = vendorInvoiceNo;
invoice.VendorInvoiceDate = request.VendorInvoiceDate;
invoice.VendorSnapshotName = vendor.Name;
invoice.VendorSnapshotTaxNo = vendor.TaxReg;
invoice.Lines.Clear();
foreach (var line in grns.Select(BuildLine)) invoice.Lines.Add(line);
foreach (var grn in grns) grn.PurchaseInvoice = invoice;
Recalculate(invoice);
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return new ETagged<PurchaseInvoiceDto>(MapInvoice(invoice), invoice.RowVersion);
}
public async Task<PurchaseInvoiceDto> PostAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be posted.");
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
// Reclassify the GRN clearing balance to Accounts Payable — the mirror image of
// SalesInvoicePaymentService's AR credit — before committing, so a GL rejection
// rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync).
var period = await _gl.GetPeriodByDateAsync(DateOnly.FromDateTime(now), token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = DateOnly.FromDateTime(now),
SourceModule = "PURCHASE_INVOICE",
Reference = invoice.DocNo,
Narration = $"Vendor invoice recognized - {invoice.DocNo}",
Lines =
[
new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"),
new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}")
]
}, token);
invoice.Status = PurchaseInvoiceStatus.Posted;
invoice.GlJournalNo = posted.JournalNo;
invoice.GlPostedAt = now;
invoice.UpdatedAt = now;
return 0;
}, ct);
return MapInvoice(invoice);
}
public async Task<PurchaseInvoiceDto> CancelAsync(int purchaseInvoiceId, CancellationToken ct = default)
{
var invoice = await _invoices.Query()
.Include(x => x.Lines).ThenInclude(l => l.Grn)
.FirstOrDefaultAsync(x => x.PurchaseInvoiceId == purchaseInvoiceId, ct)
?? throw new NotFoundException($"Purchase invoice {purchaseInvoiceId} was not found.");
if (invoice.Status != PurchaseInvoiceStatus.Draft)
throw new ConflictException($"Purchase invoice {purchaseInvoiceId} is {invoice.Status} and cannot be cancelled.");
var dto = MapInvoice(invoice);
// Cancelling frees the consolidated GRNs so they can be picked up by a later invoice —
// the link rows are removed (the unique index on GrnId only tracks the active link),
// while the Cancelled header row itself remains for audit history.
foreach (var line in invoice.Lines)
if (line.Grn is not null) line.Grn.PurchaseInvoiceId = null;
invoice.Lines.Clear();
invoice.Status = PurchaseInvoiceStatus.Cancelled;
invoice.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return dto with { Status = PurchaseInvoiceStatus.Cancelled, UpdatedAt = invoice.UpdatedAt };
}
public async Task<IReadOnlyList<GrnEligibleForInvoiceDto>> ListEligibleGrnsAsync(int vendorId, CancellationToken ct = default)
{
return await _grns.Query().AsNoTracking()
.Where(g => g.VendorId == vendorId && g.Status == GrnStatus.Confirmed && g.PurchaseInvoiceId == null)
.OrderBy(g => g.PostedAt)
.Select(g => new GrnEligibleForInvoiceDto(
g.GrnId, g.DocNo, g.PostedAt ?? g.CreatedAt, g.WarehouseId, g.Lines.Count,
g.Lines.Sum(l => l.ReceivedValue), g.Lines.Sum(l => l.VatAmount), g.Lines.Sum(l => l.LineTotal)))
.ToListAsync(ct);
}
private async Task EnsureVendorInvoiceNoIsUniqueAsync(int vendorId, string vendorInvoiceNo, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var duplicate = await _invoices.Query().AsNoTracking().AnyAsync(x =>
x.VendorId == vendorId
&& x.Status != PurchaseInvoiceStatus.Cancelled
&& x.VendorInvoiceNo == vendorInvoiceNo
&& (excludingPurchaseInvoiceId == null || x.PurchaseInvoiceId != excludingPurchaseInvoiceId), ct);
if (duplicate)
throw new DomainException(ErrorCodes.DuplicateVendorInvoiceNo,
$"Vendor invoice number '{vendorInvoiceNo}' already exists for this vendor.", 409);
}
private async Task<List<Grn>> LoadAndValidateGrnsAsync(int vendorId, List<int> grnIds, int? excludingPurchaseInvoiceId, CancellationToken ct)
{
var distinctIds = grnIds.Distinct().ToList();
var grns = await _grns.Query().Include(g => g.Lines)
.Where(g => distinctIds.Contains(g.GrnId)).ToListAsync(ct);
if (grns.Count != distinctIds.Count)
throw new DomainException(ErrorCodes.Validation, "One or more selected GRNs do not exist.", 422);
foreach (var grn in grns)
{
if (grn.Status != GrnStatus.Confirmed)
throw new DomainException(ErrorCodes.GrnNotConfirmed, $"GRN {grn.DocNo} must be confirmed before it can be invoiced.", 409);
if (grn.VendorId != vendorId)
throw new DomainException(ErrorCodes.GrnVendorMismatch, $"GRN {grn.DocNo} belongs to a different vendor.", 409);
if (grn.PurchaseInvoiceId is not null && grn.PurchaseInvoiceId != excludingPurchaseInvoiceId)
throw new DomainException(ErrorCodes.GrnAlreadyInvoiced, $"GRN {grn.DocNo} is already linked to another purchase invoice.", 409);
}
return grns;
}
private static PurchaseInvoiceLine BuildLine(Grn grn) => new()
{
Grn = grn,
GrnId = grn.GrnId,
Subtotal = grn.Lines.Sum(l => l.ReceivedValue),
VatTotal = grn.Lines.Sum(l => l.VatAmount),
LineTotal = grn.Lines.Sum(l => l.LineTotal)
};
private static void Recalculate(PurchaseInvoice invoice)
{
invoice.Subtotal = invoice.Lines.Sum(x => x.Subtotal);
invoice.VatTotal = invoice.Lines.Sum(x => x.VatTotal);
invoice.GrandTotal = invoice.Lines.Sum(x => x.LineTotal);
invoice.PaidAmount = 0m;
invoice.BalanceAmount = invoice.GrandTotal;
}
private static PurchaseInvoiceTotalsDto MapTotals(PurchaseInvoice x) => new(
x.Subtotal, x.VatTotal, x.GrandTotal, x.PaidAmount, x.BalanceAmount);
private static PurchaseInvoiceSummaryDto MapSummary(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.Status, x.Lines.Count, MapTotals(x), x.CreatedAt, x.GlJournalNo);
private static PurchaseInvoiceDto MapInvoice(PurchaseInvoice x) => new(
x.PurchaseInvoiceId, x.DocNo, x.VendorInvoiceNo, x.VendorInvoiceDate, x.VendorId,
x.VendorSnapshotName, x.VendorSnapshotTaxNo, x.Status, x.Lines.Count, x.CreatedBy, x.CreatedAt, x.UpdatedAt,
x.GlJournalNo, x.GlPostedAt, MapTotals(x),
x.Lines.OrderBy(l => l.PurchaseInvoiceLineId).Select(l => new PurchaseInvoiceLineDto(
l.PurchaseInvoiceLineId, l.GrnId, l.Grn?.DocNo ?? string.Empty, l.Grn?.PostedAt ?? default,
l.Subtotal, l.VatTotal, l.LineTotal)).ToList());
}
@@ -16,8 +16,8 @@ namespace ERPCore.Services;
/// until <see cref="SalesInvoice.BalanceAmount"/> reaches zero. Each payment posts its
/// own real GL journal entry (Debit the selected bank-or-cash account / Credit Accounts
/// Receivable) before being recorded, using the same call-GL-before-commit pattern as
/// <see cref="GrnPaymentService"/> — the mirror image of it (that one credits a payable
/// clearing account when vendor-paid; this one credits the receivable asset when
/// <see cref="PurchaseInvoicePaymentService"/> — the mirror image of it (that one debits a
/// payable account when the vendor is paid; this one credits the receivable asset when
/// customer-paid) — so a rejected/unreachable GL post rolls back the whole payment atomically.
/// </summary>
public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService