import { fyLabel } from "./fiscal"; import type { HistoryFilters } from "./historyFilter"; import { INDIAN_STATES, type InvoiceSummary } from "./types"; /** Per-invoice fields the History list does not carry (from the `list_invoice_ledger` command). Money is integer paise. */ export interface LedgerRow { id: number; clientGstin: string; placeOfSupplyCode: string; docType: string; hsnSac: string[]; taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number; reverseCharge: boolean; } export const HISTORY_SCHEMA = "voiced.history.v1"; /** 123450 -> "1234.50", -5 -> "-0.05". Integer arithmetic only; throws on anything that is not a safe integer. */ export function paiseToDecimal(paise: number): string { if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount in paise: ${paise}`); const abs = Math.abs(paise); const whole = Math.floor(abs / 100); const frac = abs % 100; return `${paise < 0 ? "-" : ""}${whole}.${frac < 10 ? "0" : ""}${frac}`; } /** Text cells are guarded against spreadsheet formula injection; amount cells are known numbers and stay numeric. */ type Kind = "text" | "amount"; interface Column { key: string; header: string; kind: Kind; value: (s: InvoiceSummary, l: LedgerRow | undefined) => string; } const DOC_TYPES: Record = { tax_invoice: "Tax Invoice", invoice: "Invoice" }; const PAYMENT_STATUS: Record = { unpaid: "Unpaid", partially_paid: "Partly paid", paid: "Paid", overdue: "Overdue", none: "", }; const STATUS: Record = { issued: "Issued", cancelled: "Cancelled", draft: "Draft" }; /** An amount that comes from the ledger row; blank (not a made-up zero) if the row is missing. */ const ledgerAmount = (pick: (l: LedgerRow) => number) => (_s: InvoiceSummary, l: LedgerRow | undefined) => l ? paiseToDecimal(pick(l)) : ""; /** * Column order and meaning for both formats. Cancelled invoices keep their figures (the document exists and * keeps its number); filter on the Cancelled column to leave them out of a sum. */ export const HISTORY_COLUMNS: readonly Column[] = [ { key: "invoiceNo", header: "Invoice No", kind: "text", value: (s) => s.number }, { key: "date", header: "Date", kind: "text", value: (s) => s.invoiceDate }, { key: "dueDate", header: "Due Date", kind: "text", value: (s) => s.dueDate }, { key: "client", header: "Client", kind: "text", value: (s) => s.clientName }, { key: "clientGstin", header: "Client GSTIN", kind: "text", value: (_s, l) => l?.clientGstin ?? "" }, { key: "placeOfSupplyCode", header: "Place of Supply Code", kind: "text", value: (_s, l) => l?.placeOfSupplyCode ?? "" }, { key: "placeOfSupply", header: "Place of Supply", kind: "text", value: (_s, l) => INDIAN_STATES.find((st) => st.code === l?.placeOfSupplyCode)?.name ?? "", }, { key: "documentType", header: "Document Type", kind: "text", value: (_s, l) => (l ? (DOC_TYPES[l.docType] ?? l.docType) : "") }, { key: "hsnSac", header: "HSN/SAC", kind: "text", value: (_s, l) => (l ? [...new Set(l.hsnSac)].join("; ") : "") }, { key: "taxableValue", header: "Taxable Value", kind: "amount", value: ledgerAmount((l) => l.taxablePaise) }, { key: "cgst", header: "CGST", kind: "amount", value: ledgerAmount((l) => l.cgstPaise) }, { key: "sgst", header: "SGST", kind: "amount", value: ledgerAmount((l) => l.sgstPaise) }, { key: "igst", header: "IGST", kind: "amount", value: ledgerAmount((l) => l.igstPaise) }, { key: "totalTax", header: "Total Tax", kind: "amount", value: ledgerAmount((l) => l.cgstPaise + l.sgstPaise + l.igstPaise) }, { key: "invoiceTotal", header: "Invoice Total", kind: "amount", value: (s) => paiseToDecimal(s.totalPaise) }, { key: "received", header: "Received", kind: "amount", value: (s) => paiseToDecimal(s.paidPaise) }, { key: "tds", header: "TDS", kind: "amount", value: (s) => paiseToDecimal(s.tdsPaise) }, { key: "balance", header: "Balance", kind: "amount", value: (s) => paiseToDecimal(s.balancePaise) }, { key: "status", header: "Status", kind: "text", value: (s) => STATUS[s.status] ?? s.status }, { key: "paymentStatus", header: "Payment Status", kind: "text", value: (s) => PAYMENT_STATUS[s.paymentStatus] ?? s.paymentStatus }, { key: "poNumber", header: "PO Number", kind: "text", value: (s) => s.poNumber }, { key: "cancelled", header: "Cancelled", kind: "text", value: (s) => (s.status === "cancelled" ? "Yes" : "No") }, ]; /** The cells of every exported row, in column order. `ledger` is looked up by invoice id. */ export function buildHistoryRows(summaries: InvoiceSummary[], ledger: LedgerRow[]): string[][] { const byId = new Map(ledger.map((l) => [l.id, l])); return summaries.map((s) => HISTORY_COLUMNS.map((c) => c.value(s, byId.get(s.id)))); } /** A text cell that a spreadsheet would read as a formula gets a leading apostrophe. */ export function guardFormula(text: string): string { return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text; } /** RFC 4180 field: quoted when it holds a quote, comma or line break (or edge spaces), inner quotes doubled. */ export function csvField(text: string): string { return /[",\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } /** UTF-8 BOM (so Excel reads it as UTF-8), CRLF line ends, header row always present. */ export function toCsv(cells: string[][]): string { const lines = [HISTORY_COLUMNS.map((c) => csvField(c.header))]; for (const row of cells) { lines.push( row.map((value, i) => { const column = HISTORY_COLUMNS[i]; return csvField(column.kind === "text" ? guardFormula(value) : value); }), ); } return `${lines.map((l) => l.join(",")).join("\r\n")}\r\n`; } /** * `{ schema, exported_at, filters, count, rows }`. Rows are objects keyed like the CSV columns; amounts are the same * 2-decimal rupee strings as in the CSV (no binary floats anywhere). */ export function toJson(cells: string[][], filters: HistoryFilters, exportedAt: string): string { const rows = cells.map((row) => Object.fromEntries(HISTORY_COLUMNS.map((c, i) => [c.key, row[i]]))); return `${JSON.stringify({ schema: HISTORY_SCHEMA, exported_at: exportedAt, filters, count: rows.length, rows }, null, 2)}\n`; } const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-"); /** `voiced-history-FY2025-26.csv`, or the date range of the rows when no year is picked, or `all` when empty. */ export function historyExportName( filters: HistoryFilters, summaries: InvoiceSummary[], ext: "csv" | "json", ): string { let part: string; if (filters.fy !== "all") { part = fyLabel(filters.fy).replace(/^FY /, "FY"); if (filters.month !== "all") part += `-m${String(filters.month).padStart(2, "0")}`; } else { const dates = summaries.map((s) => s.invoiceDate).filter(Boolean).sort(); if (dates.length === 0) part = "all"; else part = dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]}_to_${dates[dates.length - 1]}`; } return `voiced-history-${safeName(part)}.${ext}`; } export type HistoryFormat = "csv" | "json"; export interface HistoryExportDeps { listLedger(): Promise; getLastExportDir(): Promise; save(opts: { defaultPath: string; format: HistoryFormat }): Promise; writeExportFile(path: string, bytes: Uint8Array): Promise; now(): Date; } export function joinPath(dir: string, name: string): string { if (!dir) return name; if (/[\\/]$/.test(dir)) return `${dir}${name}`; return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`; } /** Exports exactly `summaries` (the filtered, sorted rows) through the save dialog. Returns the path, or null if cancelled. */ export async function exportHistory( deps: HistoryExportDeps, summaries: InvoiceSummary[], filters: HistoryFilters, format: HistoryFormat, ): Promise<{ path: string; count: number } | null> { const ledger = await deps.listLedger(); const cells = buildHistoryRows(summaries, ledger); const text = format === "csv" ? toCsv(cells) : toJson(cells, filters, deps.now().toISOString()); 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, historyExportName(filters, summaries, format)), format }); if (!chosen) return null; const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen; return { path, count: cells.length }; }