Implement store credit tracking and enforcement
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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user