- 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.
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|