Files
Voiced/src/lib/invoiceExportV1.ts
T
xavierk 3591bb9f7d 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.
2026-10-04 20:57:31 +05:30

365 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}