Files
ERP_Distribution/Distribution/Features/SupplierPayment/SupplierPaymentService.cs
T
Kalana B. Thilakarathna b992d07689 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.
2026-10-19 21:36:01 +05:30

175 lines
7.8 KiB
C#

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);
}
}