Files
Voiced/src/lib/historyExport.ts
T
xavierk 2ffbf0f66c 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

184 lines
8.4 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.
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<string, string> = { tax_invoice: "Tax Invoice", invoice: "Invoice" };
const PAYMENT_STATUS: Record<string, string> = {
unpaid: "Unpaid",
partially_paid: "Partly paid",
paid: "Paid",
overdue: "Overdue",
none: "",
};
const STATUS: Record<string, string> = { 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<LedgerRow[]>;
getLastExportDir(): Promise<string>;
save(opts: { defaultPath: string; format: HistoryFormat }): Promise<string | null>;
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
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 };
}