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
@@ -23,8 +23,8 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, bankAccounts as bankAccountsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse } from '@/types/api'
import { payments as paymentsApi, bankAccounts as bankAccountsApi, stores as storesApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse, OutstandingBalanceResponse } from '@/types/api'
import type { BankAccountResponse } from '@/types/payments'
const schema = z
@@ -67,6 +67,7 @@ const truncId = (id: string) => id.substring(0, 8)
export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoiceId }: PaymentSheetProps) {
const [accounts, setAccounts] = useState<BankAccountResponse[]>([])
const [credit, setCredit] = useState<OutstandingBalanceResponse | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -84,6 +85,16 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
})
const method = form.watch('method')
const invoiceId = form.watch('invoiceId')
const amount = form.watch('amount')
const selectedInvoice = invoices.find((inv) => inv.id === invoiceId)
const selectedStoreId = selectedInvoice?.storeId
const remaining = selectedInvoice ? selectedInvoice.totalAmount - selectedInvoice.paidAmount : 0
useEffect(() => {
if (!open || !selectedInvoice) return
form.setValue('amount', Number((selectedInvoice.totalAmount - selectedInvoice.paidAmount).toFixed(2)))
}, [open, selectedInvoice?.id])
useEffect(() => {
if (!open) return
@@ -101,10 +112,39 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
bankAccountsApi.list()
.then(list => setAccounts(list.filter(a => a.isActive !== false)))
.catch(() => {})
setCredit(null)
}, [open, defaultInvoiceId])
useEffect(() => {
if (!open || method !== 'Credit' || !selectedStoreId) {
setCredit(null)
return
}
let cancelled = false
storesApi.outstandingBalance(selectedStoreId)
.then((data) => { if (!cancelled) setCredit(data) })
.catch(() => { if (!cancelled) setCredit(null) })
return () => { cancelled = true }
}, [open, method, selectedStoreId])
async function onSubmit(values: FormValues) {
const { invoiceId, method, bankAccountId, chequeNumber, chequeDate, drawerBankName, accountHolderName, ...rest } = values
if (selectedInvoice && values.amount > remaining) {
toast.add({
title: 'Amount exceeds outstanding balance',
description: `Outstanding: Rs. ${remaining.toLocaleString()}`,
type: 'error',
})
return
}
if (method === 'Credit' && credit && values.amount > credit.availableCredit) {
toast.add({
title: 'Credit limit exceeded',
description: `Available credit: Rs. ${credit.availableCredit.toLocaleString()}`,
type: 'error',
})
return
}
try {
await paymentsApi.create(invoiceId, {
...rest,
@@ -161,6 +201,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
name="amount"
label="Amount"
required
description={selectedInvoice ? `Outstanding: ${fmtAmount(remaining)}` : undefined}
render={({ field, invalid }) => (
<Input
id="amount"
@@ -172,7 +213,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
placeholder="0.00"
aria-invalid={invalid}
aria-invalid={invalid || (selectedInvoice ? amount > remaining : false)}
/>
)}
/>
@@ -196,6 +237,36 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
)}
/>
{method === 'Credit' && selectedStoreId && (
<div className="rounded-md border bg-muted/40 p-3 text-sm">
{credit ? (
<div className="flex flex-col gap-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Credit limit</span>
<span>{fmtAmount(credit.creditLimit)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Currently used</span>
<span>{fmtAmount(credit.outstandingBalance)}</span>
</div>
<div className="flex justify-between font-medium">
<span>Available</span>
<span className={amount > credit.availableCredit ? 'text-destructive' : ''}>
{fmtAmount(credit.availableCredit)}
</span>
</div>
{amount > credit.availableCredit && (
<p className="text-destructive text-xs mt-1">
Amount exceeds available credit.
</p>
)}
</div>
) : (
<span className="text-muted-foreground">Loading credit info…</span>
)}
</div>
)}
{method === 'Cash' && (
<FormField
control={form.control}
+2
View File
@@ -1004,6 +1004,7 @@ export interface ShopPOResponse {
export interface InvoiceResponse {
id: string
shopPOId: string
storeId: string
storeName: string
status: InvoiceStatus
paymentStatus: PaymentStatus
@@ -1013,6 +1014,7 @@ export interface InvoiceResponse {
finalizedAt: string | null
finalizedBy: string | null
totalAmount: number
paidAmount: number
isActive: boolean
createdAt: string
}