Add payments with TDS, invoice detail view and filterable History (Phase E2)
- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary. - New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted. - History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page. - Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { InlineNotification, Modal, Select, SelectItem, TextInput } from "@carbon/react";
|
||||
import { api } from "../lib/api";
|
||||
import { todayIst } from "../lib/fiscal";
|
||||
import { formatPaise, paiseToInput, parseRupeesToPaise } from "../lib/money";
|
||||
import { PAYMENT_MODES, type Payment, type PaymentMode } from "../lib/payments";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
invoiceId: number;
|
||||
invoiceNumber: string;
|
||||
/** What is still owed, in paise. */
|
||||
balancePaise: number;
|
||||
onClose: () => void;
|
||||
onRecorded: (payment: Payment) => void;
|
||||
}
|
||||
|
||||
/** "Record payment": cash received plus TDS deducted, validated against the balance before it is sent. */
|
||||
export default function RecordPaymentModal({ open, invoiceId, invoiceNumber, balancePaise, onClose, onRecorded }: Props) {
|
||||
const [paidOn, setPaidOn] = useState(todayIst());
|
||||
const [amount, setAmount] = useState("");
|
||||
const [tds, setTds] = useState("");
|
||||
const [mode, setMode] = useState<PaymentMode>("bank_transfer");
|
||||
const [reference, setReference] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
useReturnFocus(open);
|
||||
|
||||
// Each opening starts from the balance, so the common case (paid in full) is one click.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPaidOn(todayIst());
|
||||
setAmount(paiseToInput(balancePaise));
|
||||
setTds("");
|
||||
setMode("bank_transfer");
|
||||
setReference("");
|
||||
setNotes("");
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
}, [open, balancePaise]);
|
||||
|
||||
const amountPaise = parseRupeesToPaise(amount);
|
||||
const tdsPaise = parseRupeesToPaise(tds);
|
||||
const amountInvalid = amountPaise === null;
|
||||
const tdsInvalid = tdsPaise === null;
|
||||
const total = (amountPaise ?? 0) + (tdsPaise ?? 0);
|
||||
const overBalance = !amountInvalid && !tdsInvalid && total > balancePaise;
|
||||
const empty = !amountInvalid && !tdsInvalid && total === 0;
|
||||
const dateInvalid = !/^\d{4}-\d{2}-\d{2}$/.test(paidOn);
|
||||
const canSubmit = !busy && !amountInvalid && !tdsInvalid && !overBalance && !empty && !dateInvalid;
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit || amountPaise === null || tdsPaise === null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payment = await api.recordPayment({ invoiceId, paidOn, amountPaise, tdsPaise, mode, reference, notes });
|
||||
onRecorded(payment);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
modalHeading={`Record payment for ${invoiceNumber}`}
|
||||
primaryButtonText={busy ? "Saving…" : "Record payment"}
|
||||
secondaryButtonText="Cancel"
|
||||
primaryButtonDisabled={!canSubmit}
|
||||
onRequestClose={onClose}
|
||||
onRequestSubmit={() => void submit()}
|
||||
>
|
||||
<div className="voiced-stack">
|
||||
<p>
|
||||
Balance due: <strong>{formatPaise(balancePaise)}</strong>
|
||||
</p>
|
||||
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not record the payment" subtitle={error} /> : null}
|
||||
<TextInput
|
||||
id="payDate"
|
||||
type="date"
|
||||
labelText="Date received"
|
||||
value={paidOn}
|
||||
invalid={dateInvalid}
|
||||
invalidText="Enter a date"
|
||||
onChange={(e) => setPaidOn(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="payAmount"
|
||||
inputMode="decimal"
|
||||
labelText="Amount received (₹)"
|
||||
helperText="Cash that reached your account"
|
||||
value={amount}
|
||||
invalid={amountInvalid || overBalance || empty}
|
||||
invalidText={
|
||||
amountInvalid
|
||||
? "Enter an amount like 1250.50"
|
||||
: overBalance
|
||||
? `Amount plus TDS is more than the balance of ${formatPaise(balancePaise)}`
|
||||
: "Enter an amount received or TDS deducted"
|
||||
}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
id="payTds"
|
||||
inputMode="decimal"
|
||||
labelText="TDS deducted by client (₹)"
|
||||
helperText="Counts towards settling the invoice; leave empty if none"
|
||||
value={tds}
|
||||
invalid={tdsInvalid}
|
||||
invalidText="Enter an amount like 125.00"
|
||||
onChange={(e) => setTds(e.target.value)}
|
||||
/>
|
||||
<Select id="payMode" labelText="Mode" value={mode} onChange={(e) => setMode(e.target.value as PaymentMode)}>
|
||||
{PAYMENT_MODES.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||
))}
|
||||
</Select>
|
||||
<TextInput id="payRef" labelText="UTR / cheque number (optional)" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
<TextInput id="payNotes" labelText="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user