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 d6ec6b2a17
commit 5597fb791d
19 changed files with 2403 additions and 327 deletions
+50 -42
View File
@@ -6,8 +6,6 @@ import {
Grid,
InlineNotification,
NumberInput,
RadioButton,
RadioButtonGroup,
Select,
SelectItem,
TextArea,
@@ -29,6 +27,7 @@ import type {
} from "../lib/types";
import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format";
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
import { InvoicePreview } from "../components/InvoicePreview";
@@ -41,6 +40,7 @@ interface NewInvoiceProps {
}
export default function NewInvoice({ settings }: NewInvoiceProps) {
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
const today = todayIso();
const [invoiceDate, setInvoiceDate] = useState(today);
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
@@ -55,13 +55,11 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
const [placeOfSupply, setPlaceOfSupply] = useState(settings.vendorStateCode);
const [saveClient, setSaveClient] = useState(false);
const [items, setItems] = useState<InvoiceItem[]>([{ ...EMPTY_ITEM }]);
const [items, setItems] = useState<InvoiceItem[]>([newItem()]);
const [discount, setDiscount] = useState(0);
const [taxesApplicable, setTaxesApplicable] = useState(settings.defaultTaxType !== "none");
const [taxType, setTaxType] = useState<TaxType>(
settings.defaultTaxType === "none" ? "cgst_sgst" : settings.defaultTaxType,
);
const registered = settings.gstRegistration !== "unregistered";
const [reverseCharge, setReverseCharge] = useState(false);
const [taxRate, setTaxRate] = useState(settings.defaultTaxRate);
const [banks, setBanks] = useState<BankAccount[]>([]);
@@ -122,7 +120,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
};
}, [signaturePath]);
const effectiveTaxType: TaxType = taxesApplicable ? taxType : "none";
// Rust derives the tax type from the supplier state and the place of supply; this
// mirrors that rule so the preview and the value sent for the cross-check agree.
const effectiveTaxType: TaxType = !registered
? "none"
: !placeOfSupply || placeOfSupply === settings.vendorStateCode
? "cgst_sgst"
: "igst";
const clientGstinError = gstinFullError(clientGstin);
const totals = useMemo(
() => computeTotals(items, discount, effectiveTaxType, taxRate),
[items, discount, effectiveTaxType, taxRate],
@@ -159,7 +164,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
const addItem = () => setItems((prev) => [...prev, newItem()]);
const removeItem = (index: number) =>
setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index)));
@@ -179,7 +184,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
setClientGstin("");
setPoNumber("");
setSaveClient(false);
setItems([{ ...EMPTY_ITEM }]);
setItems([newItem()]);
setDiscount(0);
setNotes("");
setInvoiceDate(todayIso());
@@ -193,6 +198,10 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
};
const onSave = async () => {
if (clientGstinError) {
setError(`Client GSTIN: ${clientGstinError}`);
return;
}
setSaving(true);
setError(null);
setSaved(null);
@@ -206,27 +215,18 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
clientGstin,
poNumber,
placeOfSupplyStateCode: placeOfSupply,
subtotal: totals.subtotal,
discount: totals.discount,
discount,
taxType: effectiveTaxType,
taxRate,
cgstAmount: totals.cgst,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: words,
reverseCharge: registered && reverseCharge,
bankAccountId: bankId,
bankSnapshot: bank ? JSON.stringify(bank) : "",
signaturePath,
notes,
saveClient,
items: items.map((item, index) => ({
...item,
sortOrder: index,
amount: computeLineAmount(item),
})),
draftId: null,
items: items.map((item, index) => ({ ...item, sortOrder: index })),
};
const invoice = await api.createInvoice(input);
const invoice = await api.issueInvoice(input, {});
const props = await buildPdfProps(invoice, settings, bank);
const path = await exportPdfFromProps(props, invoice.number);
if (path) setSaved(path);
@@ -336,6 +336,8 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
labelText="Client GSTIN (optional)"
value={clientGstin}
onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
invalid={Boolean(clientGstinError)}
invalidText={clientGstinError}
/>
</Column>
<Column sm={4} md={4} lg={5}>
@@ -391,6 +393,16 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
onChange={(e) => updateItem(index, { description: e.target.value })}
/>
</div>
{registered ? (
<div style={{ flex: "0 1 120px" }}>
<TextInput
id={`hsn-${index}`}
labelText="HSN/SAC"
value={item.hsnSac ?? ""}
onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
/>
</div>
) : null}
<div style={{ flex: "0 1 150px" }}>
<Select
id={`mode-${index}`}
@@ -479,26 +491,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
onChange={(_e, { value }) => setDiscount(Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="taxes"
labelText="Taxes applicable"
toggled={taxesApplicable}
onToggle={setTaxesApplicable}
/>
</Column>
{taxesApplicable ? (
{registered ? (
<>
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Tax treatment"
name="taxType"
valueSelected={taxType}
onChange={(value) => setTaxType(value as TaxType)}
>
<RadioButton id="t-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="t-igst" labelText="IGST (inter-state)" value="igst" />
</RadioButtonGroup>
<p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
{effectiveTaxType === "igst"
? "IGST (inter-state supply)"
: "CGST + SGST (supply within the supplier's state)"}
</p>
</Column>
<Column sm={4} md={4} lg={8}>
<NumberInput
@@ -511,6 +511,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
onChange={(_e, { value }) => setTaxRate(Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="reverseCharge"
labelText="Tax payable on reverse charge"
toggled={reverseCharge}
onToggle={setReverseCharge}
/>
</Column>
</>
) : null}
</Grid>