- 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.
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|