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:
@@ -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