/** * 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; listPayments(invoiceId: number): Promise; listClients(): Promise; getLastExportDir(): Promise; save(opts: { defaultPath: string; format: InvoiceExportFormat }): Promise; writeExportFile(path: string, bytes: Uint8Array): Promise; 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, ids: number[]): Promise { 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(); 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 }; }