feat: add supplier payment and allocation models, API, and frontend components

- Created `SupplierPayment` and `SupplierPaymentGRN` models for managing supplier payments and their allocations.
- Implemented API endpoints for listing and creating supplier payments.
- Developed frontend components for displaying and recording supplier payments, including a dialog for payment creation.
- Integrated GRN allocations into the payment creation process, ensuring total allocations match the payment amount.
- Added pagination and loading states for the payments list.
This commit is contained in:
Kalana B. Thilakarathna
2026-10-19 21:36:01 +05:30
parent 94db2a311c
commit b992d07689
25 changed files with 7744 additions and 57 deletions
@@ -3,6 +3,7 @@ using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public record ChequePageResponse(
Guid Id,
string ChequeNo,
string ChequeBookNo,
ChequePageIssueStatus IssueStatus,
@@ -12,7 +12,7 @@ public class ChequeBookService(
private const string DefaultCurrency = "LKR";
private static ChequePageResponse ToPageResponse(Models.ChequePage p, string chequeBookNo) =>
new(p.ChequeNo, chequeBookNo, p.IssueStatus, p.PayeeType, p.PayeeId, p.PayeeName,
new(p.Id, p.ChequeNo, chequeBookNo, p.IssueStatus, p.PayeeType, p.PayeeId, p.PayeeName,
p.IssueDate, p.Amount, p.CurrencyCode, p.VoucherId, p.ReferenceNo, p.Purpose,
p.IsCrossCheque, p.IsAccountPayee, p.IsPostDated, p.Notes,
p.PrintedBy, p.PrintedAt, p.ClearedDate, p.ClearedByBank, p.CancelReason);
+9 -1
View File
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Payment;
@@ -6,8 +6,14 @@ public record CreatePaymentRequest(
decimal Amount,
PaymentMethod Method,
EntryMode EntryMode = EntryMode.RealTime,
// Cash: required. Cheque: optional (used as the deposit account hint if provided).
Guid? BankAccountId = null,
// Cheque: required.
string? ChequeNumber = null,
DateTime? ChequeDate = null,
// Cheque: optional metadata forwarded to the auto-created ReceivedCheque.
string? DrawerBankName = null,
string? AccountHolderName = null,
Guid? CollectedByUserId = null);
public record PaymentResponse(
@@ -16,6 +22,8 @@ public record PaymentResponse(
decimal Amount,
PaymentMethod Method,
EntryMode EntryMode,
Guid? BankAccountId,
Guid? ReceivedChequeId,
string? ChequeNumber,
DateTime? ChequeDate,
DateTime CollectedAt,
@@ -1,4 +1,6 @@
using Distribution.Features.Invoice;
using Distribution.Features.BankAccount;
using Distribution.Features.Invoice;
using Distribution.Features.ReceivedCheque;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -8,11 +10,15 @@ namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
IInvoiceRepository invoiceRepo,
IBankAccountRepository bankAccountRepo,
IReceivedChequeRepository receivedChequeRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode, p.ChequeNumber, p.ChequeDate,
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
p.FK_BankAccountId, p.FK_ReceivedChequeId,
p.ChequeNumber, p.ChequeDate,
p.CollectedAt, p.CollectedBy, p.RecordedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
@@ -31,10 +37,26 @@ public class PaymentService(
if (invoice.Status == InvoiceStatus.Finalized)
throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
if (request.Method == PaymentMethod.Cheque &&
(string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
throw new InvalidOperationException("ChequeNumber and ChequeDate are required when Method is Cheque");
if (request.Method == PaymentMethod.Cash && request.BankAccountId is null)
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
Models.BankAccount? bankAccount = null;
if (request.BankAccountId is not null)
{
bankAccount = await bankAccountRepo.GetByIdAsync(request.BankAccountId.Value);
if (bankAccount is null)
throw new InvalidOperationException("BankAccountId does not reference a valid account");
if (bankAccount.IsActive is false)
throw new InvalidOperationException("The selected account is inactive");
}
var recorderId = currentUser.UserId ?? request.CollectedByUserId
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
var collectedBy = request.CollectedByUserId ?? recorderId;
@@ -50,6 +72,7 @@ public class PaymentService(
EntryMode = request.EntryMode,
ChequeNumber = request.Method == PaymentMethod.Cheque ? request.ChequeNumber : null,
ChequeDate = request.Method == PaymentMethod.Cheque ? request.ChequeDate : null,
FK_BankAccountId = request.BankAccountId,
CollectedAt = DateTime.Now,
CollectedBy = collectedBy,
RecordedBy = recordedBy
@@ -58,6 +81,30 @@ public class PaymentService(
await uow.BeginAsync(ct);
try
{
// Auto-create a ReceivedCheque for the physical cheque. Status starts at Received;
// bookkeeping of the deposit/clear transitions happens through the Received Cheques screen.
if (request.Method == PaymentMethod.Cheque)
{
var cheque = new Models.ReceivedCheque
{
CompanyId = null,
BranchId = null,
ReceivedFromType = ReceivedFromType.Customer,
ReceivedFromId = null,
ReceivedFromName = $"Invoice {invoice.Id}",
DrawerBankName = request.DrawerBankName,
AccountHolderName = request.AccountHolderName,
ChequeNo = request.ChequeNumber!,
ChequeDate = request.ChequeDate!.Value,
Amount = request.Amount,
ReceivedDate = DateTime.Now,
ReferenceType = "Invoice",
Status = ReceivedChequeStatus.Received
};
await receivedChequeRepo.AddAsync(cheque);
payment.FK_ReceivedChequeId = cheque.Id;
}
await repo.AddAsync(payment);
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
@@ -70,6 +117,11 @@ public class PaymentService(
: PaymentStatus.Paid;
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; }
@@ -5,7 +5,7 @@ namespace Distribution.Features.ReceivedCheque;
public record ReceivedChequeResponse(
Guid ReceivedChequeId,
int CompanyId,
int? CompanyId,
int? BranchId,
ReceivedFromType ReceivedFromType,
int? ReceivedFromId,
@@ -0,0 +1,17 @@
using Distribution.Models;
namespace Distribution.Features.SupplierPayment;
public interface ISupplierPaymentRepository
{
Task<Models.SupplierPayment?> GetByIdAsync(Guid id);
Task<List<Models.SupplierPayment>> ListBySupplierAsync(Guid supplierId, int page, int pageSize);
Task<int> CountBySupplierAsync(Guid supplierId);
Task AddAsync(Models.SupplierPayment payment);
/// <summary>Sum of currently-allocated amounts to a GRN, across all active SupplierPayments.</summary>
Task<decimal> GetAllocatedTotalForGrnAsync(Guid goodsReceiptId);
/// <summary>Load a GRN with its items so the total can be computed.</summary>
Task<Models.GoodsReceipt?> GetGrnWithItemsAsync(Guid goodsReceiptId);
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierPayment;
[ApiController]
[Route("api/suppliers/{supplierId:guid}/payments")]
public class SupplierPaymentController(SupplierPaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List(Guid supplierId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.ListBySupplierAsync(supplierId, page, pageSize);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid supplierId, CreateSupplierPaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(supplierId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,43 @@
using Distribution.Models;
namespace Distribution.Features.SupplierPayment;
public record SupplierPaymentAllocationRequest(
Guid GoodsReceiptId,
decimal AllocatedAmount);
public record CreateSupplierPaymentRequest(
decimal Amount,
PaymentMethod Method,
// One or more GRNs this payment covers. Sum of AllocatedAmount must equal Amount.
List<SupplierPaymentAllocationRequest> Allocations,
// Cash: required.
Guid? BankAccountId = null,
// Cheque: required — an Unused page from an Active cheque book.
Guid? ChequePageId = null,
string? Notes = null,
Guid? PaidByUserId = null);
public record SupplierPaymentAllocationResponse(
Guid GoodsReceiptId,
decimal AllocatedAmount);
public record SupplierPaymentResponse(
Guid Id,
Guid SupplierId,
decimal Amount,
PaymentMethod Method,
Guid? BankAccountId,
Guid? ChequePageId,
string? ChequeNo,
DateTime PaidAt,
Guid? PaidByUserId,
string? Notes,
List<SupplierPaymentAllocationResponse> Allocations,
DateTime CreatedAt);
public record PagedSupplierPaymentsResponse(
List<SupplierPaymentResponse> Items,
int Page,
int PageSize,
int Total);
@@ -0,0 +1,43 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SupplierPayment;
public class SupplierPaymentRepository(AppDBContext context) : ISupplierPaymentRepository
{
public async Task<Models.SupplierPayment?> GetByIdAsync(Guid id) =>
await context.SupplierPayments
.Include(p => p.ChequePage)
.Include(p => p.Allocations)
.FirstOrDefaultAsync(p => p.Id == id);
public async Task<List<Models.SupplierPayment>> ListBySupplierAsync(Guid supplierId, int page, int pageSize) =>
await context.SupplierPayments
.Include(p => p.ChequePage)
.Include(p => p.Allocations)
.Where(p => p.FK_SupplierId == supplierId && p.IsActive)
.OrderByDescending(p => p.PaidAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> CountBySupplierAsync(Guid supplierId) =>
await context.SupplierPayments
.CountAsync(p => p.FK_SupplierId == supplierId && p.IsActive);
public async Task AddAsync(Models.SupplierPayment payment) =>
await context.SupplierPayments.AddAsync(payment);
public async Task<decimal> GetAllocatedTotalForGrnAsync(Guid goodsReceiptId) =>
await context.SupplierPaymentGRNs
.Where(a => a.FK_GoodsReceiptId == goodsReceiptId
&& a.IsActive
&& a.SupplierPayment!.IsActive)
.SumAsync(a => (decimal?)a.AllocatedAmount) ?? 0m;
public async Task<Models.GoodsReceipt?> GetGrnWithItemsAsync(Guid goodsReceiptId) =>
await context.GoodsReceipts
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == goodsReceiptId && g.IsActive);
}
@@ -0,0 +1,174 @@
using Distribution.Data;
using Distribution.Features.BankAccount;
using Distribution.Features.ChequeBook;
using Distribution.Features.Grn;
using Distribution.Features.Supplier;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SupplierPayment;
public class SupplierPaymentService(
AppDBContext context,
ISupplierPaymentRepository repo,
ISupplierRepository supplierRepo,
IBankAccountRepository bankAccountRepo,
IChequeBookRepository chequeBookRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
p.FK_BankAccountId, p.FK_ChequePageId, p.ChequePage?.ChequeNo,
p.PaidAt, p.PaidByUserId, p.Notes,
p.Allocations
.Where(a => a.IsActive)
.Select(a => new SupplierPaymentAllocationResponse(a.FK_GoodsReceiptId, a.AllocatedAmount))
.ToList(),
p.CreatedAt);
/// <summary>GRN total = sum per line of (gross - discount + NBT + VAT). Rejected qty never costs.</summary>
private static decimal GrnTotal(Models.GoodsReceipt grn) =>
grn.Items.Where(i => i.IsActive).Sum(i =>
GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent).Total);
public async Task<PagedSupplierPaymentsResponse?> ListBySupplierAsync(Guid supplierId, int page, int pageSize)
{
var supplier = await supplierRepo.GetByIdAsync(supplierId);
if (supplier is null) return null;
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 20;
if (pageSize > 200) pageSize = 200;
var payments = await repo.ListBySupplierAsync(supplierId, page, pageSize);
var total = await repo.CountBySupplierAsync(supplierId);
return new PagedSupplierPaymentsResponse(payments.Select(ToResponse).ToList(), page, pageSize, total);
}
public async Task<SupplierPaymentResponse?> CreateAsync(Guid supplierId, CreateSupplierPaymentRequest request, CancellationToken ct = default)
{
var supplier = await supplierRepo.GetByIdAsync(supplierId);
if (supplier is null) return null;
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
if (request.Method == PaymentMethod.Credit)
throw new InvalidOperationException("Credit is not a valid outgoing payment method");
if (request.Allocations is null || request.Allocations.Count == 0)
throw new InvalidOperationException("At least one GRN allocation is required");
var duplicateGrn = request.Allocations.GroupBy(a => a.GoodsReceiptId).FirstOrDefault(g => g.Count() > 1);
if (duplicateGrn is not null)
throw new InvalidOperationException($"GRN {duplicateGrn.Key} is allocated more than once");
if (request.Allocations.Any(a => a.AllocatedAmount <= 0))
throw new InvalidOperationException("Each allocation amount must be greater than zero");
var allocationSum = request.Allocations.Sum(a => a.AllocatedAmount);
if (allocationSum != request.Amount)
throw new InvalidOperationException($"Allocations sum ({allocationSum}) must equal Amount ({request.Amount})");
// Validate each GRN exists, belongs to this supplier, and the new allocation does not push cumulative paid over the GRN total.
foreach (var alloc in request.Allocations)
{
var grn = await repo.GetGrnWithItemsAsync(alloc.GoodsReceiptId)
?? throw new InvalidOperationException($"GRN {alloc.GoodsReceiptId} not found");
var grnSupplierId = grn.FK_SupplierId
?? grn.PurchaseOrder?.FK_SupplierId;
if (grnSupplierId is null)
{
// PO-based GRN without the PO loaded — re-query to confirm supplier match.
var po = await context.PurchaseOrders.FindAsync([grn.FK_PurchaseOrderId], ct);
grnSupplierId = po?.FK_SupplierId;
}
if (grnSupplierId != supplierId)
throw new InvalidOperationException($"GRN {alloc.GoodsReceiptId} does not belong to the specified supplier");
var grnTotal = GrnTotal(grn);
var alreadyAllocated = await repo.GetAllocatedTotalForGrnAsync(grn.Id);
if (alreadyAllocated + alloc.AllocatedAmount > grnTotal)
throw new InvalidOperationException(
$"Allocation to GRN {grn.Id} ({alloc.AllocatedAmount}) exceeds outstanding balance " +
$"(GRN total {grnTotal}, already paid {alreadyAllocated})");
}
var payerId = currentUser.UserId ?? request.PaidByUserId;
var payment = new Models.SupplierPayment
{
FK_SupplierId = supplierId,
Amount = request.Amount,
Method = request.Method,
PaidAt = DateTime.Now,
PaidByUserId = payerId,
Notes = request.Notes,
Allocations = request.Allocations.Select(a => new Models.SupplierPaymentGRN
{
FK_GoodsReceiptId = a.GoodsReceiptId,
AllocatedAmount = a.AllocatedAmount
}).ToList()
};
await uow.BeginAsync(ct);
try
{
if (request.Method == PaymentMethod.Cash)
{
if (request.BankAccountId is null)
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
var account = await bankAccountRepo.GetByIdAsync(request.BankAccountId.Value);
if (account is null)
throw new InvalidOperationException("BankAccountId does not reference a valid account");
if (account.IsActive is false)
throw new InvalidOperationException("The selected account is inactive");
payment.FK_BankAccountId = account.Id;
}
else // Cheque
{
if (request.ChequePageId is null)
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
var page = await context.ChequePages
.Include(p => p.ChequeBook)
.FirstOrDefaultAsync(p => p.Id == request.ChequePageId.Value, ct);
if (page is null)
throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
throw new InvalidOperationException("The cheque book is not active");
page.IssueStatus = ChequePageIssueStatus.Issued;
page.PayeeType = PayeeType.Supplier;
page.PayeeName = supplier.Name;
page.IssueDate = DateTime.Now;
page.Amount = request.Amount;
page.CurrencyCode = "LKR";
page.PrintedAt = DateTime.Now;
chequeBookRepo.UpdatePage(page);
payment.FK_ChequePageId = page.Id;
payment.ChequePage = page;
}
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; }
return ToResponse(payment);
}
}