Implement store credit tracking and enforcement
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m30s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m1s

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.
This commit is contained in:
Kalana B. Thilakarathna
2026-10-20 11:45:11 +05:30
parent 28c465f59b
commit 5707bd4c31
16 changed files with 6978 additions and 126 deletions
@@ -1,6 +1,7 @@
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;
@@ -12,6 +13,7 @@ public class PaymentService(
IInvoiceRepository invoiceRepo,
IBankAccountRepository bankAccountRepo,
IReceivedChequeRepository receivedChequeRepo,
IStoreCreditMovementRepository creditRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
@@ -34,12 +36,16 @@ public class PaymentService(
{
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status == InvoiceStatus.Finalized)
throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
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");
@@ -47,6 +53,19 @@ public class PaymentService(
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)
{
@@ -107,9 +126,22 @@ public class PaymentService(
await repo.AddAsync(payment);
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
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