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";