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); /// GRN total = sum per line of (gross - discount + NBT + VAT). Rejected qty never costs. 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 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 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); } }