Add the ERPNext settings UI, History and detail controls, and voiced.invoice.v1 export (Phase F3)

- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save.
- Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send.
- History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured.
- Optional auto-push after issue runs in the background and never blocks or fails the issue.
- Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string.

The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
This commit is contained in:
2026-10-04 20:57:31 +05:30
parent 15f942165f
commit 3591bb9f7d
24 changed files with 2855 additions and 11 deletions
+364
View File
@@ -0,0 +1,364 @@
/**
* The generic `voiced.invoice.v1` export: a canonical, deterministic JSON document per invoice (and a CSV of line
* items) for software that Voiced has no direct integration with. Pure: it formats data it is handed, using integer
* paise for every amount, and never touches Tauri. The format is described in docs/voiced-invoice-v1.md.
*/
import { csvField, guardFormula, joinPath, paiseToDecimal } from "./historyExport";
import type { Payment } from "./payments";
import { INDIAN_STATES, type Client, type Invoice } from "./types";
export const INVOICE_SCHEMA = "voiced.invoice.v1";
/** Union territories without a legislature (mirror of `is_ut_without_legislature` in gst.rs): their second head is UTGST. */
const UT_WITHOUT_LEGISLATURE = new Set(["04", "26", "31", "35", "38"]);
export interface TaxHeadV1 {
/** Percent, e.g. "9" or "2.5"; "0" when the head does not apply to the invoice. */
rate: string;
amount_paise: number;
amount: string;
}
export interface InvoiceDocV1 {
schema: typeof INVOICE_SCHEMA;
number: string;
issue_date: string;
due_date: string | null;
document_type: "tax_invoice" | "invoice";
status: string;
cancelled: boolean;
cancelled_at: string | null;
cancel_reason: string | null;
currency: "INR";
po_number: string | null;
supplier: {
name: string;
address: string;
gstin: string | null;
pan: string | null;
gst_registration: string;
state: { code: string; name: string };
};
client: {
name: string;
gstin: string | null;
gst_category: string;
address: {
text: string;
line1: string | null;
line2: string | null;
city: string | null;
pincode: string | null;
state: { code: string; name: string } | null;
};
place_of_supply: { code: string; name: string };
};
items: {
line: number;
description: string;
hsn_sac: string | null;
quantity: string;
unit: string | null;
rate_paise: number;
rate: string;
taxable_value_paise: number;
taxable_value: string;
}[];
tax: { cgst: TaxHeadV1; sgst: TaxHeadV1; utgst: TaxHeadV1; igst: TaxHeadV1 };
totals: {
subtotal_paise: number;
subtotal: string;
discount_paise: number;
discount: string;
taxable_value_paise: number;
taxable_value: string;
tax_paise: number;
tax: string;
total_paise: number;
total: string;
amount_in_words: string | null;
};
reverse_charge: boolean;
notes: string | null;
payments: {
date: string;
amount_paise: number;
amount: string;
tds_paise: number;
tds: string;
mode: string;
reference: string | null;
}[];
}
/** Rupees as stored (a REAL with two decimals) to integer paise, rounded exactly as the backend does. */
export function rupeesToPaise(rupees: number): number {
const paise = Math.round(rupees * 100);
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount: ${rupees}`);
return paise === 0 ? 0 : paise; // no negative zero
}
const blank = (s: string | null | undefined): string | null => {
const t = (s ?? "").trim();
return t === "" ? null : t;
};
const gstinOf = (s: string | null | undefined): string | null => {
const t = blank(s);
return t === null || t.toUpperCase() === "NA" ? null : t;
};
const stateOf = (code: string): { code: string; name: string } => ({
code,
name: INDIAN_STATES.find((s) => s.code === code)?.name ?? "",
});
/** A percentage as the shortest decimal string (9, 6.25), without binary noise. */
export function rateString(rate: number): string {
return String(Number(rate.toFixed(4)));
}
interface Snapshot {
vendorName?: string;
vendorAddress?: string;
vendorGstin?: string;
vendorPan?: string;
vendorStateCode?: string;
gstRegistration?: string;
}
function parseSnapshot(text: string): Snapshot {
try {
const v: unknown = text ? JSON.parse(text) : {};
return v && typeof v === "object" ? (v as Snapshot) : {};
} catch {
return {};
}
}
const head = (rate: number, rupees: number): TaxHeadV1 => {
const paise = rupeesToPaise(rupees);
return { rate: rateString(rate), amount_paise: paise, amount: paiseToDecimal(paise) };
};
/**
* One invoice as `voiced.invoice.v1`. The supplier comes from the snapshot frozen at issue. The client's name, GSTIN
* and place of supply are frozen on the invoice too; the structured address and GST category are read from the saved
* client record (`client`, optional) because the invoice only froze the printed text.
*/
export function buildInvoiceDoc(invoice: Invoice, payments: Payment[], client?: Client | null): InvoiceDocV1 {
const snap = parseSnapshot(invoice.vendorSnapshot);
const supplierState = snap.vendorStateCode ?? "";
const isUt = UT_WITHOUT_LEGISLATURE.has(supplierState);
const half = invoice.taxType === "cgst_sgst" ? invoice.taxRate / 2 : 0;
const igstRate = invoice.taxType === "igst" ? invoice.taxRate : 0;
const zero = (): TaxHeadV1 => head(0, 0);
const secondHead = head(half, invoice.sgstAmount);
const tax = {
cgst: head(half, invoice.cgstAmount),
sgst: isUt ? zero() : secondHead,
utgst: isUt ? secondHead : zero(),
igst: head(igstRate, invoice.igstAmount),
};
const subtotal = rupeesToPaise(invoice.subtotal);
const discount = rupeesToPaise(invoice.discount);
const totalTax = tax.cgst.amount_paise + tax.sgst.amount_paise + tax.utgst.amount_paise + tax.igst.amount_paise;
const total = invoice.totalPaise ?? rupeesToPaise(invoice.total);
const gstin = gstinOf(invoice.clientGstin);
const pos = invoice.placeOfSupplyStateCode;
const clientState = client && client.stateCode ? stateOf(client.stateCode) : null;
return {
schema: INVOICE_SCHEMA,
number: invoice.number,
issue_date: invoice.invoiceDate,
due_date: blank(invoice.dueDate),
document_type: invoice.docType,
status: invoice.status,
cancelled: invoice.status === "cancelled",
cancelled_at: invoice.status === "cancelled" ? blank(invoice.cancelledAt) : null,
cancel_reason: invoice.status === "cancelled" ? blank(invoice.cancelReason) : null,
currency: "INR",
po_number: blank(invoice.poNumber),
supplier: {
name: snap.vendorName ?? "",
address: snap.vendorAddress ?? "",
gstin: gstinOf(snap.vendorGstin),
pan: blank(snap.vendorPan),
gst_registration: snap.gstRegistration ?? "",
state: stateOf(supplierState),
},
client: {
name: invoice.clientName,
gstin,
gst_category: client?.gstCategory ?? (gstin ? "registered_regular" : "unregistered"),
address: {
text: invoice.clientAddress,
line1: blank(client?.addressLine1),
line2: blank(client?.addressLine2),
city: blank(client?.city),
pincode: blank(client?.pincode),
state: clientState,
},
place_of_supply: stateOf(pos),
},
items: invoice.items.map((item, index) => {
const fixed = item.mode === "fixed";
const amount = rupeesToPaise(item.amount);
const rate = fixed ? amount : rupeesToPaise(item.rate);
return {
line: index + 1,
description: item.description,
hsn_sac: blank(item.hsnSac),
quantity: fixed ? "1" : String(item.quantity),
unit: fixed ? null : item.unit,
rate_paise: rate,
rate: paiseToDecimal(rate),
taxable_value_paise: amount,
taxable_value: paiseToDecimal(amount),
};
}),
tax,
totals: {
subtotal_paise: subtotal,
subtotal: paiseToDecimal(subtotal),
discount_paise: discount,
discount: paiseToDecimal(discount),
taxable_value_paise: subtotal - discount,
taxable_value: paiseToDecimal(subtotal - discount),
tax_paise: totalTax,
tax: paiseToDecimal(totalTax),
total_paise: total,
total: paiseToDecimal(total),
amount_in_words: blank(invoice.amountInWords),
},
reverse_charge: invoice.reverseCharge,
notes: blank(invoice.notes),
payments: payments.map((p) => ({
date: p.paidOn,
amount_paise: p.amountPaise,
amount: paiseToDecimal(p.amountPaise),
tds_paise: p.tdsPaise,
tds: paiseToDecimal(p.tdsPaise),
mode: p.mode,
reference: blank(p.reference),
})),
};
}
/** The JSON file: an array of invoice documents in the given order. Same input, same bytes. */
export function toInvoiceJson(docs: InvoiceDocV1[]): string {
return `${JSON.stringify(docs, null, 2)}\n`;
}
// ---- CSV: one row per line item ----
type Kind = "text" | "amount";
const LINE_COLUMNS: readonly { header: string; kind: Kind; value: (d: InvoiceDocV1, line: number) => string }[] = [
{ header: "invoice_number", kind: "text", value: (d) => d.number },
{ header: "issue_date", kind: "text", value: (d) => d.issue_date },
{ header: "due_date", kind: "text", value: (d) => d.due_date ?? "" },
{ header: "document_type", kind: "text", value: (d) => d.document_type },
{ header: "status", kind: "text", value: (d) => d.status },
{ header: "cancelled", kind: "text", value: (d) => (d.cancelled ? "yes" : "no") },
{ header: "currency", kind: "text", value: (d) => d.currency },
{ header: "supplier_name", kind: "text", value: (d) => d.supplier.name },
{ header: "supplier_gstin", kind: "text", value: (d) => d.supplier.gstin ?? "" },
{ header: "client_name", kind: "text", value: (d) => d.client.name },
{ header: "client_gstin", kind: "text", value: (d) => d.client.gstin ?? "" },
{ header: "client_gst_category", kind: "text", value: (d) => d.client.gst_category },
{ header: "place_of_supply_code", kind: "text", value: (d) => d.client.place_of_supply.code },
{ header: "place_of_supply", kind: "text", value: (d) => d.client.place_of_supply.name },
{ header: "reverse_charge", kind: "text", value: (d) => (d.reverse_charge ? "yes" : "no") },
{ header: "line", kind: "text", value: (d, i) => (d.items[i] ? String(d.items[i].line) : "") },
{ header: "description", kind: "text", value: (d, i) => d.items[i]?.description ?? "" },
{ header: "hsn_sac", kind: "text", value: (d, i) => d.items[i]?.hsn_sac ?? "" },
{ header: "quantity", kind: "text", value: (d, i) => d.items[i]?.quantity ?? "" },
{ header: "unit", kind: "text", value: (d, i) => d.items[i]?.unit ?? "" },
{ header: "rate", kind: "amount", value: (d, i) => d.items[i]?.rate ?? "" },
{ header: "taxable_value", kind: "amount", value: (d, i) => d.items[i]?.taxable_value ?? "" },
// Invoice-level amounts sit on the first row only, so summing a column never counts an invoice twice.
{ header: "invoice_discount", kind: "amount", value: (d, i) => (i === 0 ? d.totals.discount : "") },
{ header: "invoice_cgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.cgst.amount : "") },
{ header: "invoice_sgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.sgst.amount : "") },
{ header: "invoice_utgst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.utgst.amount : "") },
{ header: "invoice_igst", kind: "amount", value: (d, i) => (i === 0 ? d.tax.igst.amount : "") },
{ header: "invoice_total", kind: "amount", value: (d, i) => (i === 0 ? d.totals.total : "") },
];
export const LINE_CSV_HEADERS: readonly string[] = LINE_COLUMNS.map((c) => c.header);
/** UTF-8 BOM, CRLF, header row always present; text cells are guarded against spreadsheet formulas. */
export function toLineItemsCsv(docs: InvoiceDocV1[]): string {
const lines: string[] = [LINE_COLUMNS.map((c) => csvField(c.header)).join(",")];
for (const doc of docs) {
const rows = Math.max(1, doc.items.length);
for (let i = 0; i < rows; i++) {
lines.push(LINE_COLUMNS.map((c) => csvField(c.kind === "text" ? guardFormula(c.value(doc, i)) : c.value(doc, i))).join(","));
}
}
return `${lines.join("\r\n")}\r\n`;
}
// ---- saving ----
export type InvoiceExportFormat = "json" | "csv";
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
export function invoiceExportName(docs: InvoiceDocV1[], format: InvoiceExportFormat, now: Date): string {
const base =
docs.length === 1
? `voiced-invoice-${safeName(docs[0].number)}`
: `voiced-invoices-${docs.length}-${now.toISOString().slice(0, 10)}`;
return `${base}${format === "csv" ? "-lines" : ""}.${format}`;
}
export interface InvoiceExportDeps {
getInvoice(id: number): Promise<Invoice>;
listPayments(invoiceId: number): Promise<Payment[]>;
listClients(): Promise<Client[]>;
getLastExportDir(): Promise<string>;
save(opts: { defaultPath: string; format: InvoiceExportFormat }): Promise<string | null>;
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
now(): Date;
}
/** Reads the invoices (frozen data plus payments) in the order given; the client record is optional detail. */
export async function loadInvoiceDocs(deps: Pick<InvoiceExportDeps, "getInvoice" | "listPayments" | "listClients">, ids: number[]): Promise<InvoiceDocV1[]> {
const unique = [...new Set(ids)];
const loaded: { invoice: Invoice; payments: Payment[] }[] = [];
for (const id of unique) {
const [invoice, payments] = await Promise.all([deps.getInvoice(id), deps.listPayments(id)]);
loaded.push({ invoice, payments });
}
let clients = new Map<number, Client>();
if (loaded.some((l) => l.invoice.clientId !== null)) {
try {
clients = new Map((await deps.listClients()).filter((c) => c.id !== null).map((c) => [c.id as number, c]));
} catch {
// The structured address is extra detail; the export is complete without it.
}
}
return loaded.map(({ invoice, payments }) => buildInvoiceDoc(invoice, payments, invoice.clientId === null ? null : (clients.get(invoice.clientId) ?? null)));
}
/** Exports the chosen invoices through the save dialog. Returns the path, or null if the dialog was cancelled. */
export async function exportInvoicesV1(deps: InvoiceExportDeps, ids: number[], format: InvoiceExportFormat): Promise<{ path: string; count: number } | null> {
const docs = await loadInvoiceDocs(deps, ids);
const text = format === "csv" ? toLineItemsCsv(docs) : toInvoiceJson(docs);
let dir = "";
try {
dir = await deps.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const chosen = await deps.save({ defaultPath: joinPath(dir, invoiceExportName(docs, format, deps.now())), format });
if (!chosen) return null;
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
return { path, count: docs.length };
}