5707bd4c31
Introduced StoreCreditMovement entity and migration to track store credit usage and settlements. Updated payment flow to enforce credit limits and record credit movements. Modified outstanding balance calculation to use credit movements. Enhanced frontend to display real-time credit info and prevent over-credit payments. Updated API types, seed data, and DI registrations accordingly.
164 lines
6.9 KiB
C#
164 lines
6.9 KiB
C#
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<List<PaymentResponse>?> 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<PaymentResponse?> 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);
|
|
}
|
|
}
|