- Global Carbon toast stack replaces scattered inline notices; errors persist until closed. - Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings. - New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot. - Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular. - New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control. - Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
102 lines
3.8 KiB
TypeScript
102 lines
3.8 KiB
TypeScript
const PAN_RE = /^[A-Z]{5}[0-9]{4}[A-Z]$/;
|
||
const IFSC_RE = /^[A-Z]{4}0[A-Z0-9]{6}$/;
|
||
const GSTIN_RE = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
const PHONE_RE = /^[0-9+\-()\s]{6,16}$/;
|
||
|
||
const norm = (v: string) => v.trim().toUpperCase();
|
||
|
||
export function panError(value: string): string | undefined {
|
||
if (!value.trim()) return undefined;
|
||
return PAN_RE.test(norm(value)) ? undefined : "Enter a valid PAN, e.g. ABCDE1234F";
|
||
}
|
||
|
||
export function ifscError(value: string): string | undefined {
|
||
if (!value.trim()) return undefined;
|
||
return IFSC_RE.test(norm(value)) ? undefined : "Enter a valid IFSC, e.g. TEST0000001";
|
||
}
|
||
|
||
export function gstinError(value: string): string | undefined {
|
||
const v = norm(value);
|
||
if (!v || v === "NA") return 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";
|
||
}
|
||
|
||
export function phoneError(value: string): string | undefined {
|
||
if (!value.trim()) return undefined;
|
||
return PHONE_RE.test(value.trim()) ? undefined : "Enter a valid phone number";
|
||
}
|
||
|
||
export function accountError(value: string): string | undefined {
|
||
if (!value.trim()) return undefined;
|
||
return /^[0-9]{6,20}$/.test(value.trim()) ? undefined : "Account number should be 6–20 digits";
|
||
}
|
||
|
||
export const isPan = (v: string) => PAN_RE.test(norm(v));
|
||
export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
|
||
export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
|
||
|
||
/**
|
||
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
|
||
* are given. Empty (or "NA") is valid; a client may be unregistered.
|
||
*/
|
||
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
|
||
const g = norm(gstin);
|
||
if (!g || g === "NA") return undefined;
|
||
const base = gstinFullError(g);
|
||
if (base) return base;
|
||
if (stateCode && g.slice(0, 2) !== stateCode) {
|
||
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
|
||
}
|
||
return undefined;
|
||
}
|