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.
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user