Files
Voiced/src/lib/validators.ts
T
xavierk 3510cc4260 Add toasts, Settings tabs, Clients page and item presets (Phase E1)
- 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.
2026-10-04 17:29:46 +05:30

102 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}