- 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.
184 lines
8.4 KiB
TypeScript
184 lines
8.4 KiB
TypeScript
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 };
|
||
}
|