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
+11 -2
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type {
BankAccount,
Client,
DraftSummary,
Invoice,
InvoiceInput,
InvoiceSeries,
@@ -27,10 +28,18 @@ export const api = {
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
createInvoice: (input: InvoiceInput) => invoke<Invoice>("create_invoice", { input }),
issueInvoice: (input: InvoiceInput, renderPrefs: Record<string, unknown>) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
cancelInvoice: (id: number, reason: string) =>
invoke<Invoice>("cancel_invoice", { id, reason }),
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
deleteInvoice: (id: number) => invoke<void>("delete_invoice", { id }),
saveDraft: (id: number | null, payload: unknown) =>
invoke<number>("save_draft", { id, payload }),
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
getDraft: (id: number) => invoke<unknown>("get_draft", { id }),
deleteDraft: (id: number) => invoke<void>("delete_draft", { id }),
importAsset: (sourcePath: string, kind: string) =>
invoke<string>("import_asset", { sourcePath, kind }),
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { computeLineAmount, computeTotals } from "./invoice";
import { EMPTY_ITEM, type InvoiceItem } from "./types";
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
// and the stored totals stay identical.
const fixed = (amount: number): InvoiceItem => ({ ...EMPTY_ITEM, mode: "fixed", amount });
const rate = (r: number, quantity: number): InvoiceItem => ({
...EMPTY_ITEM,
mode: "rate",
rate: r,
quantity,
unit: "hour",
});
describe("computeTotals", () => {
it("splits 18% on 7310.00 into 657.90 CGST and SGST", () => {
const t = computeTotals([fixed(7310)], 0, "cgst_sgst", 18);
expect(t.subtotal).toBe(7310);
expect(t.cgst).toBe(657.9);
expect(t.sgst).toBe(657.9);
expect(t.igst).toBe(0);
expect(t.total).toBe(8625.8);
});
it("rounds odd paise half up for IGST and per head for CGST/SGST", () => {
const igst = computeTotals([fixed(33.33)], 0, "igst", 5);
expect(igst.igst).toBe(1.67);
expect(igst.total).toBe(35);
const split = computeTotals([fixed(33.33)], 0, "cgst_sgst", 5);
expect([split.cgst, split.sgst]).toEqual([0.83, 0.83]);
expect(split.total).toBe(34.99);
});
it("handles fractional quantities", () => {
const t = computeTotals([rate(1200, 1.5)], 0, "none", 0);
expect(t.subtotal).toBe(1800);
expect(t.total).toBe(1800);
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
});
it("clamps the discount to the subtotal", () => {
const items = [fixed(100), fixed(50.5)];
const over = computeTotals(items, 999.99, "igst", 18);
expect(over.discount).toBe(150.5);
expect(over.taxable).toBe(0);
expect(over.total).toBe(0);
expect(computeTotals(items, -5, "none", 0).discount).toBe(0);
expect(computeTotals(items, 50.5, "none", 0).taxable).toBe(100);
});
it("ignores the rate when no tax applies", () => {
const t = computeTotals([fixed(1000)], 0, "none", 18);
expect(t.taxTotal).toBe(0);
expect(t.total).toBe(1000);
});
});
+32 -20
View File
@@ -1,12 +1,23 @@
import { round2 } from "./format";
import type { InvoiceItem, TaxType } from "./types";
// The preview must show exactly what Rust stores (src-tauri/src/gst.rs), so money is
// computed in integer paise with round-half-up, the same way.
const toPaise = (rupees: number) => Math.round((Number.isFinite(rupees) ? rupees : 0) * 100);
function linePaise(item: InvoiceItem): number {
if (item.mode === "rate") {
return Math.round(toPaise(item.rate || 0) * (item.quantity || 0));
}
return toPaise(item.amount || 0);
}
/** Round-half-up division for non-negative integers. */
const divRound = (num: number, den: number) => Math.floor((num + den / 2) / den);
/** Amount for a single line: a fixed total, or rate × quantity. */
export function computeLineAmount(item: InvoiceItem): number {
if (item.mode === "rate") {
return round2((item.rate || 0) * (item.quantity || 0));
}
return round2(item.amount || 0);
return linePaise(item) / 100;
}
export interface Totals {
@@ -26,29 +37,30 @@ export function computeTotals(
taxType: TaxType,
taxRate: number,
): Totals {
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
const taxable = round2(subtotal - discountValue);
const subtotal = items.reduce((sum, item) => sum + linePaise(item), 0);
const discountPaise = Math.min(Math.max(toPaise(discount), 0), Math.max(subtotal, 0));
const taxable = subtotal - discountPaise;
const rateBp = Math.round((Number.isFinite(taxRate) ? taxRate : 0) * 100);
let cgst = 0;
let sgst = 0;
let igst = 0;
if (taxType === "cgst_sgst") {
cgst = round2((taxable * (taxRate / 2)) / 100);
sgst = round2((taxable * (taxRate / 2)) / 100);
cgst = divRound(taxable * rateBp, 20_000);
sgst = cgst;
} else if (taxType === "igst") {
igst = round2((taxable * taxRate) / 100);
igst = divRound(taxable * rateBp, 10_000);
}
const taxTotal = round2(cgst + sgst + igst);
const taxTotal = cgst + sgst + igst;
return {
subtotal,
discount: discountValue,
taxable,
cgst,
sgst,
igst,
taxTotal,
total: round2(taxable + taxTotal),
subtotal: subtotal / 100,
discount: discountPaise / 100,
taxable: taxable / 100,
cgst: cgst / 100,
sgst: sgst / 100,
igst: igst / 100,
taxTotal: taxTotal / 100,
total: (taxable + taxTotal) / 100,
};
}
+14 -2
View File
@@ -17,12 +17,24 @@ function blobToBase64(blob: Blob): Promise<string> {
});
}
function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
if (!snapshot) return live;
try {
return { ...live, ...(JSON.parse(snapshot) as Partial<Settings>) };
} catch {
return live;
}
}
export async function buildPdfProps(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<InvoicePdfProps> {
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
// legacy rows without a snapshot.
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath)
: null;
@@ -37,7 +49,7 @@ export async function buildPdfProps(
}
return {
vendor: settings,
vendor,
bank: resolvedBank,
logoDataUri,
signatureDataUri,
+56 -13
View File
@@ -19,7 +19,7 @@ export interface Settings {
currency: string;
onboarded: boolean;
theme: string;
gstRegistration: string;
gstRegistration: GstRegistration;
defaultHsnSac: string;
signatoryName: string;
signatoryDesignation: string;
@@ -65,9 +65,41 @@ export interface InvoiceItem {
quantity: number;
amount: number;
sortOrder: number;
/** Optional so older callers that build items by hand keep compiling; stored as "" when absent. */
hsnSac?: string;
}
export type GstRegistration = "unregistered" | "regular";
/** What the webview sends to issue an invoice; totals, tax and words are derived in Rust. */
export interface InvoiceInput {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
discount: number;
taxType: TaxType;
taxRate: number;
reverseCharge: boolean;
bankAccountId: number | null;
signaturePath: string | null;
notes: string;
saveClient: boolean;
draftId: number | null;
items: InvoiceItem[];
}
export type InvoiceStatus = "draft" | "issued" | "cancelled";
/** An invoice as stored: all amounts are derived on the Rust side. */
export interface Invoice {
id: number;
number: string;
seriesId: number | null;
invoiceDate: string;
dueDate: string;
clientId: number | null;
@@ -89,18 +121,25 @@ export interface InvoiceInput {
bankSnapshot: string;
signaturePath: string | null;
notes: string;
saveClient: boolean;
status: InvoiceStatus;
createdAt: string;
updatedAt: string;
docType: "invoice" | "tax_invoice";
reverseCharge: boolean;
/** JSON of the supplier details at issue time; empty when unknown. */
vendorSnapshot: string;
snapshotOrigin: string;
cancelledAt: string | null;
cancelReason: string;
archivedPdfSha256: string | null;
items: InvoiceItem[];
}
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
export interface DraftSummary {
id: number;
number: string;
seriesId: number | null;
status: string;
createdAt: string;
updatedAt: string;
items: InvoiceItem[];
clientName: string | null;
total: number | null;
}
export interface InvoiceSummary {
@@ -110,7 +149,7 @@ export interface InvoiceSummary {
dueDate: string;
clientName: string;
total: number;
status: string;
status: InvoiceStatus;
createdAt: string;
}
@@ -122,10 +161,12 @@ export const EMPTY_ITEM: InvoiceItem = {
quantity: 1,
amount: 0,
sortOrder: 0,
hsnSac: "",
};
/** GST state codes, same list as STATES in src-tauri/src/gst.rs (from India Compliance). */
export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "01", name: "Jammu & Kashmir" },
{ code: "01", name: "Jammu and Kashmir" },
{ code: "02", name: "Himachal Pradesh" },
{ code: "03", name: "Punjab" },
{ code: "04", name: "Chandigarh" },
@@ -149,18 +190,20 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "22", name: "Chhattisgarh" },
{ code: "23", name: "Madhya Pradesh" },
{ code: "24", name: "Gujarat" },
{ code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" },
{ code: "26", name: "Dadra and Nagar Haveli and Daman and Diu" },
{ code: "27", name: "Maharashtra" },
{ code: "29", name: "Karnataka" },
{ code: "30", name: "Goa" },
{ code: "31", name: "Lakshadweep" },
{ code: "31", name: "Lakshadweep Islands" },
{ code: "32", name: "Kerala" },
{ code: "33", name: "Tamil Nadu" },
{ code: "34", name: "Puducherry" },
{ code: "35", name: "Andaman & Nicobar Islands" },
{ code: "35", name: "Andaman and Nicobar Islands" },
{ code: "36", name: "Telangana" },
{ code: "37", name: "Andhra Pradesh" },
{ code: "38", name: "Ladakh" },
{ code: "96", name: "Other Countries" },
{ code: "97", name: "Other Territory" },
];
export const UNIT_LABELS: Record<LineUnit, string> = {
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
describe("GSTIN checksum", () => {
it("accepts valid GSTINs", () => {
expect(gstinChecksumError("27AAPFU0939F1ZV")).toBeUndefined();
expect(gstinChecksumError("07AAGFF2194N1Z1")).toBeUndefined();
expect(gstinChecksumError("29ABCDE1234F1ZW")).toBeUndefined();
});
it("rejects a wrong check digit and bad shapes", () => {
expect(gstinChecksumError("29ABCDE1234F1Z5")).toMatch(/expected W/);
expect(gstinChecksumError("27AAPFU0939F1ZX")).toMatch(/check digit/);
expect(gstinChecksumError("27AAPFU0939F1Z")).toMatch(/15-character/);
});
it("cross-checks the PAN and the state", () => {
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939F")).toBeUndefined();
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939G")).toMatch(/PAN/);
expect(gstinFullError("")).toBeUndefined();
expect(vendorGstinError("unregistered", "", "", "29")).toBeUndefined();
expect(vendorGstinError("regular", "", "", "27")).toMatch(/needs a GSTIN/);
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "29")).toMatch(/state code/);
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
});
});
+44
View File
@@ -22,6 +22,50 @@ export function gstinError(value: string): string | undefined {
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
}
const GSTIN_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/** Standard GSTIN mod-36 check digit. Returns an error message, or undefined when valid. */
export function gstinChecksumError(value: string): string | undefined {
const v = norm(value);
if (!GSTIN_RE.test(v)) return "Enter a valid 15-character GSTIN";
let sum = 0;
for (let i = 0; i < 14; i++) {
const product = GSTIN_CHARS.indexOf(v[i]) * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
const expected = GSTIN_CHARS[(36 - (sum % 36)) % 36];
return v[14] === expected ? undefined : `GSTIN check digit looks wrong (expected ${expected})`;
}
/**
* Full GSTIN check: shape, check digit and, when a PAN is given, that the GSTIN
* contains that PAN. Empty (or "NA") is valid; the field is optional.
*/
export function gstinFullError(value: string, pan = ""): string | undefined {
const v = norm(value);
if (!v || v === "NA") return undefined;
const base = gstinError(v) ?? gstinChecksumError(v);
if (base) return base;
if (pan.trim() && v.slice(2, 12) !== norm(pan)) return "GSTIN does not contain the PAN";
return undefined;
}
/** Supplier GSTIN rules for a registered business; an unregistered one needs none. */
export function vendorGstinError(
registration: string,
gstin: string,
pan: string,
stateCode: string,
): string | undefined {
if (registration === "unregistered") return undefined;
const g = norm(gstin);
if (!g) return "A registered business needs a GSTIN";
const base = gstinFullError(g, pan);
if (base) return base;
if (g.slice(0, 2) !== stateCode) return `GSTIN starts with ${g.slice(0, 2)}, but your state code is ${stateCode}`;
return undefined;
}
export function emailError(value: string): string | undefined {
if (!value.trim()) return undefined;
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
+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>
+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>
);
}
+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>
+71 -34
View File
@@ -15,9 +15,9 @@ import {
TextInput,
} from "@carbon/react";
import { api } from "../lib/api";
import type { BankAccount, Settings, TaxType } from "../lib/types";
import type { BankAccount, GstRegistration, Settings, TaxType } from "../lib/types";
import { INDIAN_STATES } from "../lib/types";
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
interface OnboardingProps {
@@ -73,9 +73,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
const bankSet = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setBank((prev) => ({ ...prev, [key]: value }));
const registered = form.gstRegistration !== "unregistered";
const gstinProblem = vendorGstinError(
form.gstRegistration,
form.vendorGstin,
form.vendorPan,
form.vendorStateCode,
);
const businessInvalid = useMemo(
() => Boolean(panError(form.vendorPan) || emailError(form.vendorEmail) || phoneError(form.vendorPhone)),
[form.vendorPan, form.vendorEmail, form.vendorPhone],
() =>
Boolean(
panError(form.vendorPan) ||
emailError(form.vendorEmail) ||
phoneError(form.vendorPhone) ||
gstinProblem,
),
[form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem],
);
const finish = async () => {
@@ -110,6 +123,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
adjust anything that has changed.
</p>
</Column>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="GST registration"
name="ob-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="ob-gr-un" labelText="Unregistered" value="unregistered" />
<RadioButton id="ob-gr-reg" labelText="Regular" value="regular" />
</RadioButtonGroup>
</Column>
<Column sm={4} md={8} lg={12}>
<TextInput
id="vendorName"
@@ -158,14 +187,18 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
invalidText={panError(form.vendorPan)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="vendorGstin"
labelText="GSTIN (leave blank if not registered)"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
/>
</Column>
{registered ? (
<Column sm={4} md={4} lg={6}>
<TextInput
id="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="vendorState"
@@ -287,16 +320,18 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<NumberInput
id="taxRate"
label="Default tax rate (%)"
min={0}
max={100}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
{registered ? (
<Column sm={4} md={4} lg={6}>
<NumberInput
id="taxRate"
label="Default tax rate (%)"
min={0}
max={100}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
) : null}
<Column sm={4} md={4} lg={6}>
<NumberInput
id="terms"
@@ -307,18 +342,20 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="Default tax treatment"
name="taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
{registered ? (
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="Default tax treatment"
name="taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
),
},