Add issue/cancel lifecycle, GST derivation and integer-paise totals

issue_invoice allocates the number, freezes vendor and bank details and
stores server-computed totals in one transaction. Issued invoices are
cancelled, not deleted. Tax heads derive from supplier state and place of
supply (a disagreeing choice is rejected); unregistered suppliers issue a
plain Invoice. Adds GSTIN checksum validation, canonical India Compliance
state list, server-side drafts, series validation, relative and
magic-byte-checked asset paths, and GST settings. PDF re-exports use the
frozen vendor snapshot.
This commit is contained in:
2026-10-04 04:15:58 +05:30
parent ecf57c9221
commit a00c816635
19 changed files with 2403 additions and 327 deletions
+54 -18
View File
@@ -3,6 +3,7 @@ import {
Button,
InlineLoading,
InlineNotification,
Modal,
Table,
TableBody,
TableCell,
@@ -10,9 +11,10 @@ import {
TableHead,
TableHeader,
TableRow,
Tag,
TextInput,
} from "@carbon/react";
import { Download, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { Download, Close } from "@carbon/icons-react";
import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf";
@@ -23,6 +25,8 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState("");
const load = async () => {
try {
@@ -52,16 +56,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
}
};
const onDelete = async (row: InvoiceSummary) => {
const ok = await confirm(`Delete invoice ${row.number}? This cannot be undone.`, {
title: "Delete invoice",
kind: "warning",
});
if (!ok) return;
const onCancel = async () => {
if (!cancelTarget) return;
try {
await api.deleteInvoice(row.id);
await api.cancelInvoice(cancelTarget.id, reason);
setCancelTarget(null);
setReason("");
await load();
} catch (e) {
setCancelTarget(null);
setError(String(e));
}
};
@@ -99,7 +102,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</TableCell>
<TableCell>{row.status}</TableCell>
<TableCell>
{row.status === "cancelled" ? (
<Tag type="red" size="sm">
Cancelled
</Tag>
) : (
row.status
)}
</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button
@@ -111,14 +122,19 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
>
{busyId === row.id ? "Exporting…" : "PDF"}
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={() => onDelete(row)}
>
Delete
</Button>
{row.status === "issued" ? (
<Button
kind="ghost"
size="sm"
renderIcon={Close}
onClick={() => {
setReason("");
setCancelTarget(row);
}}
>
Cancel
</Button>
) : null}
</div>
</TableCell>
</TableRow>
@@ -127,6 +143,26 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
</Table>
</TableContainer>
)}
<Modal
open={cancelTarget !== null}
danger
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
primaryButtonText="Cancel invoice"
secondaryButtonText="Keep invoice"
onRequestClose={() => setCancelTarget(null)}
onRequestSubmit={onCancel}
>
<p style={{ marginBottom: "1rem" }}>
The invoice stays in the list as cancelled and its number is not reused.
</p>
<TextInput
id="cancelReason"
labelText="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</Modal>
</div>
);
}