ca81e9bc42
- Implemented General Ledger client component for managing GL integration. - Created a dedicated page for General Ledger configuration. - Developed Finance Report client component for generating various financial reports. - Added report blocks for structured display of financial data. - Introduced API endpoints for fetching GL accounts and financial reports. - Updated navigation to include links for GL integration and financial reports. - Enhanced permissions to manage GL and view financial reports. - Updated appsettings to include General Ledger API configuration.
176 lines
7.8 KiB
C#
176 lines
7.8 KiB
C#
using Distribution.Data;
|
|
using Distribution.Features.BankAccount;
|
|
using Distribution.Features.ChequeBook;
|
|
using Distribution.Features.GeneralLedger;
|
|
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,
|
|
GlPostingService gl)
|
|
{
|
|
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);
|
|
|
|
await uow.CommitAsync(ct);
|
|
}
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
// Mirrors to the General Ledger after commit: Dr Payable / Cr Cash/Bank (or Cheques-issued). Never throws.
|
|
await gl.PostSupplierPaymentAsync(payment, ct);
|
|
|
|
return ToResponse(payment);
|
|
}
|
|
}
|