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
+101 -33
View File
@@ -23,8 +23,14 @@ import {
import { Add, Save, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types";
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
import {
INDIAN_STATES,
type BankAccount,
type GstRegistration,
type Settings,
type TaxType,
} from "../lib/types";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
const blankBank = (): BankAccount => ({
@@ -70,7 +76,19 @@ export default function AppSettings({
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
const registered = form.gstRegistration !== "unregistered";
const gstinProblem = vendorGstinError(
form.gstRegistration,
form.vendorGstin,
form.vendorPan,
form.vendorStateCode,
);
const saveAll = async () => {
if (gstinProblem) {
setError(gstinProblem);
return;
}
setBusy(true);
setError(null);
setNotice(null);
@@ -140,6 +158,22 @@ export default function AppSettings({
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3>
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="GST registration"
name="s-gstReg"
orientation="horizontal"
valueSelected={form.gstRegistration}
onChange={(value) => {
set("gstRegistration", value as GstRegistration);
if (value === "unregistered") set("defaultTaxType", "none");
else if (form.defaultTaxType === "none") set("defaultTaxType", "cgst_sgst");
}}
>
<RadioButton id="s-gr-un" labelText="Unregistered" value="unregistered" />
<RadioButton id="s-gr-reg" labelText="Regular" value="regular" />
</RadioButtonGroup>
</Column>
<Column sm={4} md={8} lg={12}>
<TextInput
id="s-vendorName"
@@ -187,14 +221,18 @@ export default function AppSettings({
invalidText={panError(form.vendorPan)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorGstin"
labelText="GSTIN (blank if unregistered)"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
/>
</Column>
{registered ? (
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorGstin"
labelText="GSTIN"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
invalid={Boolean(gstinProblem)}
invalidText={gstinProblem}
/>
</Column>
) : null}
<Column sm={4} md={8} lg={12}>
<Select
id="s-vendorState"
@@ -207,6 +245,22 @@ export default function AppSettings({
))}
</Select>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-signatoryName"
labelText="Authorised signatory name"
value={form.signatoryName}
onChange={(e) => set("signatoryName", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-signatoryDesignation"
labelText="Signatory designation"
value={form.signatoryDesignation}
onChange={(e) => set("signatoryDesignation", e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={8}>
<ImagePicker
kind="logo"
@@ -229,17 +283,29 @@ export default function AppSettings({
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3>
<Grid narrow>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
@@ -261,18 +327,20 @@ export default function AppSettings({
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>