using Distribution.Features.BankAccount; using Distribution.Features.Invoice; using Distribution.Features.ReceivedCheque; using Distribution.Features.StoreCredit; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.Payment; public class PaymentService( IPaymentRepository repo, IInvoiceRepository invoiceRepo, IBankAccountRepository bankAccountRepo, IReceivedChequeRepository receivedChequeRepo, IStoreCreditMovementRepository creditRepo, IUnitOfWork uow, ICurrentUserService currentUser) { private static PaymentResponse ToResponse(Models.Payment p) => 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?> GetByInvoiceIdAsync(Guid invoiceId) { var invoice = await invoiceRepo.GetByIdAsync(invoiceId); if (invoice is null || !invoice.IsActive) return null; var payments = await repo.GetByInvoiceIdAsync(invoiceId); return payments.Select(ToResponse).ToList(); } public async Task CreateAsync(Guid invoiceId, CreatePaymentRequest request, CancellationToken ct = default) { var invoice = await invoiceRepo.GetByIdAsync(invoiceId); if (invoice is null || !invoice.IsActive) return null; if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero"); var existingPaid = (await repo.GetByInvoiceIdAsync(invoiceId)).Sum(p => p.Amount); var remaining = invoice.TotalAmount - existingPaid; if (request.Amount > remaining) throw new InvalidOperationException( $"Amount exceeds the outstanding balance of {remaining:0.##}"); 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"); if (request.Method == PaymentMethod.Credit) { var store = invoice.ShopPO?.Store ?? throw new InvalidOperationException("Invoice is missing the owning store; cannot record a credit payment"); var currentOutstanding = await creditRepo.GetOutstandingForStoreAsync(store.Id); if (currentOutstanding + request.Amount > store.CreditLimit) { var available = store.CreditLimit - currentOutstanding; throw new InvalidOperationException( $"Credit limit exceeded. Available credit: {available:0.##}"); } } 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; var recordedBy = request.EntryMode == EntryMode.DayEndManual && collectedBy != recorderId ? recorderId : (Guid?)null; var payment = new Models.Payment { FK_InvoiceId = invoiceId, Amount = request.Amount, Method = request.Method, 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 }; 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); if (request.Method == PaymentMethod.Credit) { var movement = new StoreCreditMovement { FK_StoreId = invoice.ShopPO!.Store!.Id, Direction = StoreCreditMovementDirection.Usage, Amount = request.Amount, FK_InvoiceId = invoice.Id, FK_PaymentId = payment.Id, OccurredAt = DateTime.Now, RecordedBy = recorderId }; await creditRepo.AddAsync(movement); } var total = existingPaid + request.Amount; invoice.PaymentStatus = total == 0 ? PaymentStatus.Pending : total < invoice.TotalAmount ? PaymentStatus.PartiallyPaid : 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; } return ToResponse(payment); } }