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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user