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:
+11
-2
@@ -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 }),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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> = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user