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:
2026-10-04 20:57:31 +05:30
parent d3b5fbb1b8
commit 2ffbf0f66c
24 changed files with 2855 additions and 11 deletions
+118
View File
@@ -0,0 +1,118 @@
[
{
"schema": "voiced.invoice.v1",
"number": "INV/2026-001",
"issue_date": "2026-04-01",
"due_date": "2026-05-01",
"document_type": "tax_invoice",
"status": "issued",
"cancelled": false,
"cancelled_at": null,
"cancel_reason": null,
"currency": "INR",
"po_number": "PO-9",
"supplier": {
"name": "Test Vendor",
"address": "1 Main Rd\nPune",
"gstin": "27AAPFU0939F1ZV",
"pan": "AAPFU0939F",
"gst_registration": "regular",
"state": {
"code": "27",
"name": "Maharashtra"
}
},
"client": {
"name": "Client Ltd",
"gstin": "29AABCU9603R1ZJ",
"gst_category": "registered_regular",
"address": {
"text": "12 MG Road\nBengaluru 560001",
"line1": "12 MG Road",
"line2": null,
"city": "Bengaluru",
"pincode": "560001",
"state": {
"code": "29",
"name": "Karnataka"
}
},
"place_of_supply": {
"code": "29",
"name": "Karnataka"
}
},
"items": [
{
"line": 1,
"description": "Design",
"hsn_sac": "998391",
"quantity": "1",
"unit": null,
"rate_paise": 500010,
"rate": "5000.10",
"taxable_value_paise": 500010,
"taxable_value": "5000.10"
},
{
"line": 2,
"description": "Consulting",
"hsn_sac": null,
"quantity": "2",
"unit": "hour",
"rate_paise": 115505,
"rate": "1155.05",
"taxable_value_paise": 231000,
"taxable_value": "2310.00"
}
],
"tax": {
"cgst": {
"rate": "0",
"amount_paise": 0,
"amount": "0.00"
},
"sgst": {
"rate": "0",
"amount_paise": 0,
"amount": "0.00"
},
"utgst": {
"rate": "0",
"amount_paise": 0,
"amount": "0.00"
},
"igst": {
"rate": "18",
"amount_paise": 131400,
"amount": "1314.00"
}
},
"totals": {
"subtotal_paise": 731010,
"subtotal": "7310.10",
"discount_paise": 1010,
"discount": "10.10",
"taxable_value_paise": 730000,
"taxable_value": "7300.00",
"tax_paise": 131400,
"tax": "1314.00",
"total_paise": 861400,
"total": "8614.00",
"amount_in_words": "Rupees Eight Thousand Six Hundred Fourteen Only"
},
"reverse_charge": false,
"notes": "Thank you",
"payments": [
{
"date": "2026-04-10",
"amount_paise": 700000,
"amount": "7000.00",
"tds_paise": 70000,
"tds": "700.00",
"mode": "bank_transfer",
"reference": "UTR123"
}
]
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
/** Types for the ERPNext commands (see src-tauri/src/commands/erpnext.rs). Types only: no UI yet. */
/** Types for the ERPNext commands (see src-tauri/src/commands/erpnext.rs); the UI helpers live in erpnextUi.ts. */
export type ErpnextNamingMode = "mirror" | "series";
+7
View File
@@ -0,0 +1,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { api } from "./api";
/** Opens the invoice's ERPNext page in the system browser (`<base>/app/sales-invoice/<name>`); rejects if it was never sent. */
export async function openInErpnext(invoiceId: number): Promise<void> {
await openUrl(await api.erpnextOpenUrl(invoiceId));
}
+286
View File
@@ -0,0 +1,286 @@
import { describe, expect, it, vi } from "vitest";
import type { ErpnextConfig, ErpnextConnectionTest, ErpnextPushResult, ErpnextSyncStatus } from "./erpnext";
import {
autoPushIssued,
baseUrlProblem,
connectionProblems,
describePaymentPush,
describePush,
failedPush,
hasDetectResult,
isConfigured,
paymentPushDisabledReason,
pushDisabledReason,
summarizeBatch,
syncTag,
toInput,
validateConfigForm,
warningSeverity,
withDefaultUoms,
withStoredValue,
sortWarnings,
sendableSelection,
formatSyncedAt,
} from "./erpnextUi";
const test: ErpnextConnectionTest = {
user: "api@example.com",
versions: { frappe: "15.80.0", erpnext: "15.70.0", indiaCompliance: "15.30.0" },
features: { v2Naming: true, indiaCompliance: true },
indiaCompliance: true,
warnings: [],
};
const config = (patch: Partial<ErpnextConfig> = {}): ErpnextConfig => ({
baseUrl: "https://erp.example.com",
apiKey: "key",
apiSecretSet: true,
extraCaPem: "",
company: "Test Co",
companyAddress: "",
namingMode: "mirror",
namingSeries: "",
incomeAccount: "",
costCenter: "",
cgstAccount: "",
sgstAccount: "",
utgstAccount: "",
igstAccount: "",
taxTemplateIntra: "",
taxTemplateInter: "",
paymentBankAccount: "",
tdsAccount: "",
defaultItemCode: "",
uomMap: {},
customerGroup: "",
territory: "",
sellingPriceList: "",
submitOnPush: false,
attachPdf: true,
autoPushOnIssue: false,
createMissingCustomers: true,
lastDetectResult: test,
...patch,
});
const sync = (patch: Partial<ErpnextSyncStatus>): ErpnextSyncStatus => ({
invoiceId: 1,
status: "synced",
remoteName: "INV/2026-001",
remoteDocstatus: 0,
lastError: "",
syncedAt: null,
attached: false,
...patch,
});
const push = (patch: Partial<ErpnextPushResult> = {}): ErpnextPushResult => ({
invoiceId: 1,
number: "INV/2026-001",
ok: true,
status: "synced",
remoteName: "INV/2026-001",
remoteDocstatus: 0,
created: true,
noOp: false,
attached: true,
error: null,
errorKind: null,
warnings: [],
...patch,
});
describe("status tags", () => {
it("maps every sync state", () => {
expect(syncTag(undefined)).toMatchObject({ label: "Not sent", type: "gray" });
expect(syncTag(sync({ status: "none" })).label).toBe("Not sent");
expect(syncTag(sync({ remoteDocstatus: 0 }))).toMatchObject({ label: "Draft in ERPNext", type: "blue" });
expect(syncTag(sync({ remoteDocstatus: 1 }))).toMatchObject({ label: "Submitted", type: "green" });
expect(syncTag(sync({ status: "error", lastError: "Item missing" }))).toMatchObject({ label: "Error", type: "red", tooltip: "Item missing" });
expect(syncTag(sync({ status: "conflict", lastError: "" }))).toMatchObject({ label: "Conflict", type: "purple" });
expect(syncTag(sync({ status: "error", lastError: "" })).tooltip).toBeTruthy();
});
});
describe("configured and disabled reasons", () => {
it("needs an address, a key and a stored secret", () => {
expect(isConfigured(config())).toBe(true);
expect(isConfigured(config({ baseUrl: " " }))).toBe(false);
expect(isConfigured(config({ apiKey: "" }))).toBe(false);
expect(isConfigured(config({ apiSecretSet: false }))).toBe(false);
expect(isConfigured(null)).toBe(false);
});
it("asks for a connection test only when the vendor is registered", () => {
const untested = config({ lastDetectResult: null });
expect(pushDisabledReason({ cfg: untested, vendorRegistered: true })).toMatch(/Run Test connection first/);
expect(pushDisabledReason({ cfg: untested, vendorRegistered: false })).toBeNull();
expect(pushDisabledReason({ cfg: config(), vendorRegistered: true })).toBeNull();
expect(hasDetectResult(null)).toBe(false);
expect(hasDetectResult({} as ErpnextConnectionTest)).toBe(false);
expect(pushDisabledReason({ cfg: null, vendorRegistered: false })).toMatch(/not set up/);
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "cancelled" })).toMatch(/Cancelled/);
expect(pushDisabledReason({ cfg: config(), vendorRegistered: false, invoiceStatus: "issued" })).toBeNull();
});
it("allows a payment only against a submitted invoice, once", () => {
const cfg = config();
expect(paymentPushDisabledReason({ cfg, sync: undefined, entry: null })).toMatch(/Send the invoice/);
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 0 }), entry: null })).toMatch(/Submit the invoice in ERPNext first/);
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: null })).toBeNull();
expect(paymentPushDisabledReason({ cfg, sync: sync({ remoteDocstatus: 1 }), entry: "ACC-PAY-1" })).toMatch(/ACC-PAY-1/);
});
});
describe("push results", () => {
it("describes outcomes", () => {
expect(describePush(push())).toMatchObject({ kind: "success", subtitle: "INV/2026-001 (draft)" });
expect(describePush(push({ remoteDocstatus: 1 })).subtitle).toContain("submitted");
expect(describePush(push({ noOp: true })).kind).toBe("info");
const warned = describePush(push({ warnings: ["The PDF was not attached: no permission"] }));
expect(warned.kind).toBe("warning");
expect(warned.subtitle).toContain("no permission");
expect(describePush(push({ ok: false, status: "refused", error: "Run Test connection first", errorKind: "precondition" })).kind).toBe("warning");
expect(describePush(push({ ok: false, status: "conflict", error: "different total" })).title).toMatch(/conflicts/);
expect(describePush(push({ ok: false, status: "error", error: "417", errorKind: "validation" })).kind).toBe("error");
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: true, entryName: "PE-1", alreadySynced: false, error: null, errorKind: null }).kind).toBe("success");
expect(describePaymentPush({ paymentId: 1, invoiceId: 1, ok: false, entryName: null, alreadySynced: false, error: "nope", errorKind: "other" }).kind).toBe("error");
});
it("turns a rejected call into a failed row", () => {
const r = failedPush(3, "INV/3", "ERPNext is not set up yet");
expect(r).toMatchObject({ ok: false, invoiceId: 3, number: "INV/3", error: "ERPNext is not set up yet" });
expect(failedPush(3, "INV/3", new Error("boom")).error).toBe("boom");
});
it("summarises a batch", () => {
const s = summarizeBatch([push(), push({ noOp: true }), push({ ok: false, status: "error", error: "x" })]);
expect(s).toMatchObject({ total: 3, sent: 1, upToDate: 1, failed: 1, kind: "warning" });
expect(s.title).toBe("1 sent, 1 already up to date, 1 failed");
expect(summarizeBatch([push(), push()]).kind).toBe("success");
expect(summarizeBatch([push({ ok: false })]).kind).toBe("error");
expect(summarizeBatch([push({ warnings: ["w"] })]).kind).toBe("warning");
});
});
describe("auto push on issue", () => {
const invoice = { id: 7, number: "INV/2026-007" };
it("does nothing when it is off or ERPNext is not set up", async () => {
const pushFn = vi.fn();
expect(await autoPushIssued({ getConfig: async () => config(), push: pushFn }, invoice, true)).toBeNull();
expect(await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, apiSecretSet: false }), push: pushFn }, invoice, true)).toBeNull();
expect(await autoPushIssued({ getConfig: async () => Promise.reject(new Error("db")), push: pushFn }, invoice, true)).toBeNull();
expect(pushFn).not.toHaveBeenCalled();
});
it("pushes with the configured submit setting and reports the result", async () => {
const pushFn = vi.fn(async () => push({ number: invoice.number }));
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true }), push: pushFn }, invoice, true);
expect(pushFn).toHaveBeenCalledWith(7);
expect(notice?.kind).toBe("success");
});
it("never throws: a rejected push becomes an error notice", async () => {
const notice = await autoPushIssued(
{ getConfig: async () => config({ autoPushOnIssue: true }), push: async () => Promise.reject("ERPNext is not set up yet") },
invoice,
false,
);
expect(notice).toMatchObject({ kind: "error" });
expect(notice?.subtitle).toContain("not set up");
});
it("explains a missing connection test instead of pushing", async () => {
const pushFn = vi.fn();
const notice = await autoPushIssued({ getConfig: async () => config({ autoPushOnIssue: true, lastDetectResult: null }), push: pushFn }, invoice, true);
expect(pushFn).not.toHaveBeenCalled();
expect(notice?.subtitle).toMatch(/Run Test connection first/);
});
});
describe("warnings", () => {
it("ranks errors before warnings before notes", () => {
expect(warningSeverity("gst_accounts_not_configured")).toBe("error");
expect(warningSeverity("ic_not_detected")).toBe("warning");
expect(warningSeverity("versions_unknown")).toBe("info");
const sorted = sortWarnings([
{ code: "versions_unknown", message: "a" },
{ code: "company_missing", message: "b" },
{ code: "ic_not_detected", message: "c" },
]);
expect(sorted.map((w) => w.code)).toEqual(["company_missing", "ic_not_detected", "versions_unknown"]);
});
});
describe("settings form", () => {
it("validates the address like the backend", () => {
expect(baseUrlProblem("")).toMatch(/Enter/);
expect(baseUrlProblem("https://erp.example.com")).toBeNull();
expect(baseUrlProblem("erp.example.com")).toBeNull();
expect(baseUrlProblem("http://erp.example.com")).toMatch(/https/);
expect(baseUrlProblem("http://localhost:8000")).toBeNull();
expect(baseUrlProblem("localhost:8000")).toBeNull();
expect(baseUrlProblem("http://site.test")).toBeNull();
expect(baseUrlProblem("http://127.0.0.1:8000")).toBeNull();
expect(baseUrlProblem("https://user:pw@erp.example.com")).toMatch(/credentials/);
expect(baseUrlProblem("ftp://erp.example.com")).toMatch(/https/);
});
it("builds the input without write-only fields and fills the UOM defaults", () => {
const input = toInput(config({ uomMap: { hour: "Hr" } }));
expect(input).not.toHaveProperty("apiSecretSet");
expect(input).not.toHaveProperty("lastDetectResult");
expect(input.apiSecret).toBe("");
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
});
it("needs a secret only when none is stored or it is being cleared", () => {
const form = toInput(config());
expect(connectionProblems(form, true)).toEqual({});
expect(connectionProblems(form, false).apiSecret).toBeTruthy();
expect(connectionProblems({ ...form, clearSecret: true }, true).apiSecret).toBeTruthy();
expect(connectionProblems({ ...form, clearSecret: true, apiSecret: "new" }, true)).toEqual({});
expect(connectionProblems({ ...form, apiKey: " " }, true).apiKey).toBeTruthy();
});
it("validates a save: untouched is fine, series mode needs a series, mirror needs v2 naming", () => {
const empty = toInput(config({ baseUrl: "", apiKey: "", apiSecretSet: false }));
expect(validateConfigForm(empty, false, null)).toEqual({});
const form = toInput(config());
expect(validateConfigForm(form, true, test)).toEqual({});
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "" }, true, test).namingSeries).toBeTruthy();
expect(validateConfigForm({ ...form, namingMode: "series", namingSeries: "SINV-.YY.-" }, true, test)).toEqual({});
const old = { ...test, features: { v2Naming: false, indiaCompliance: true } };
expect(validateConfigForm(form, true, old).namingMode).toBeTruthy();
expect(validateConfigForm(form, true, null).namingMode).toBeUndefined();
});
it("keeps a saved value visible when the loaded list lacks it", () => {
const items = [{ name: "A", label: "A", detail: "" }];
expect(withStoredValue(items, "A")).toBe(items);
expect(withStoredValue(items, "")).toBe(items);
const out = withStoredValue(items, "Sales - AC");
expect(out[0]).toMatchObject({ name: "Sales - AC" });
expect(out).toHaveLength(2);
});
});
describe("selection and dates", () => {
it("leaves cancelled invoices out of a send", () => {
const rows = [
{ id: 1, status: "issued" },
{ id: 2, status: "cancelled" },
{ id: 3, status: "issued" },
];
expect(sendableSelection(rows)).toEqual({ send: [rows[0], rows[2]], skipped: 1 });
expect(sendableSelection([])).toEqual({ send: [], skipped: 0 });
});
it("formats the sync time defensively", () => {
expect(formatSyncedAt(null)).toBe("");
expect(formatSyncedAt("not a date")).toBe("not a date");
expect(formatSyncedAt("2026-04-01T10:30:00+00:00")).toMatch(/2026/);
});
});
+390
View File
@@ -0,0 +1,390 @@
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
import type {
ErpnextConfig,
ErpnextConfigInput,
ErpnextConnectionTest,
ErpnextOptionItem,
ErpnextOptions,
ErpnextPaymentPushResult,
ErpnextPushResult,
ErpnextSyncStatus,
ErpnextWarning,
} from "./erpnext";
export type TagColor = "red" | "green" | "blue" | "gray" | "purple" | "cool-gray" | "warm-gray";
export type NoticeKind = "success" | "info" | "warning" | "error";
export const SETTINGS_LINK_TEXT = "Settings, Integrations";
// ---- configuration state ----
/** Enough saved to try a push: address, key and a stored secret. */
export function isConfigured(cfg: Pick<ErpnextConfig, "baseUrl" | "apiKey" | "apiSecretSet"> | null | undefined): boolean {
return Boolean(cfg && cfg.baseUrl.trim() && cfg.apiKey.trim() && cfg.apiSecretSet);
}
/** True when a connection test result is stored (the backend refuses a registered vendor's push without one). */
export function hasDetectResult(result: ErpnextConnectionTest | null | undefined): boolean {
return Boolean(result && typeof result === "object" && Object.keys(result).length > 0);
}
export const NOT_CONFIGURED_REASON = "ERPNext is not set up. Enter the address, API key and secret in Settings, Integrations.";
export const RUN_TEST_REASON = "Run Test connection first (Settings, Integrations).";
/**
* Why an invoice cannot be sent right now, or null. The backend applies the same registered-vendor rule using the
* supplier snapshot frozen on the invoice; the current GST registration setting is the proxy here.
*/
export function pushDisabledReason(opts: {
cfg: ErpnextConfig | null;
vendorRegistered: boolean;
invoiceStatus?: string;
}): string | null {
if (!isConfigured(opts.cfg)) return NOT_CONFIGURED_REASON;
if (opts.invoiceStatus !== undefined && opts.invoiceStatus !== "issued") {
return opts.invoiceStatus === "cancelled"
? "Cancelled invoices are never sent to ERPNext."
: "Only issued invoices are sent to ERPNext.";
}
if (opts.vendorRegistered && !hasDetectResult(opts.cfg?.lastDetectResult)) return RUN_TEST_REASON;
return null;
}
/** A payment can be posted only against a submitted invoice, once. */
export function paymentPushDisabledReason(opts: {
cfg: ErpnextConfig | null;
sync: ErpnextSyncStatus | undefined;
entry: string | null | undefined;
}): string | null {
if (!isConfigured(opts.cfg)) return NOT_CONFIGURED_REASON;
if (opts.entry) return `Already sent as ${opts.entry}.`;
if (!opts.sync || opts.sync.status !== "synced") return "Send the invoice to ERPNext first.";
if (opts.sync.remoteDocstatus !== 1) return "Submit the invoice in ERPNext first.";
return null;
}
// ---- sync status ----
export interface SyncTag {
label: string;
type: TagColor;
/** Hover text: the stored error for a failure. */
tooltip?: string;
}
export function syncTag(s: ErpnextSyncStatus | undefined): SyncTag {
if (!s || s.status === "none") return { label: "Not sent", type: "gray" };
switch (s.status) {
case "synced":
return s.remoteDocstatus === 1
? { label: "Submitted", type: "green", tooltip: s.remoteName || undefined }
: { label: "Draft in ERPNext", type: "blue", tooltip: s.remoteName || undefined };
case "error":
return { label: "Error", type: "red", tooltip: s.lastError || "The last send failed." };
case "conflict":
return { label: "Conflict", type: "purple", tooltip: s.lastError || "ERPNext already has a different document with this name." };
case "pending":
return { label: "Sending", type: "warm-gray" };
default:
return { label: "Not sent", type: "gray" };
}
}
export function indexSyncStatuses(list: ErpnextSyncStatus[]): Map<number, ErpnextSyncStatus> {
return new Map(list.map((s) => [s.invoiceId, s]));
}
/** Rows that have an ERPNext document to open. */
export const canOpenInErpnext = (s: ErpnextSyncStatus | undefined): boolean => Boolean(s && s.remoteName);
/** Stored sync time (RFC 3339) for display; the raw text if it cannot be parsed, blank if there is none. */
export function formatSyncedAt(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
/** From a selection: the issued invoices that can be sent, and how many were left out (cancelled or draft). */
export function sendableSelection<T extends { status: string }>(rows: T[]): { send: T[]; skipped: number } {
const send = rows.filter((r) => r.status === "issued");
return { send, skipped: rows.length - send.length };
}
// ---- push results ----
export interface Notice {
kind: NoticeKind;
title: string;
subtitle?: string;
}
const docState = (docstatus: number) => (docstatus === 1 ? "submitted" : "draft");
/** One push outcome as a toast. Warnings turn a success into a warning toast so they are read. */
export function describePush(r: ErpnextPushResult): Notice {
if (r.ok) {
const where = `${r.remoteName} (${docState(r.remoteDocstatus)})`;
if (r.warnings.length > 0) {
return { kind: "warning", title: `Invoice ${r.number} sent to ERPNext with notes`, subtitle: [where, ...r.warnings].join(" ") };
}
if (r.noOp) return { kind: "info", title: `Invoice ${r.number} is already up to date in ERPNext`, subtitle: where };
return { kind: "success", title: `Invoice ${r.number} sent to ERPNext`, subtitle: where };
}
if (r.status === "refused" || r.errorKind === "precondition" || r.errorKind === "config") {
return { kind: "warning", title: `Invoice ${r.number || r.invoiceId} was not sent`, subtitle: r.error ?? undefined };
}
if (r.status === "conflict") {
return { kind: "error", title: `Invoice ${r.number} conflicts with ERPNext`, subtitle: r.error ?? undefined };
}
return { kind: "error", title: `Could not send invoice ${r.number || r.invoiceId}`, subtitle: r.error ?? undefined };
}
export function describePaymentPush(r: ErpnextPaymentPushResult): Notice {
if (r.ok) {
return r.alreadySynced
? { kind: "info", title: "Payment was already sent", subtitle: r.entryName ?? undefined }
: { kind: "success", title: "Payment sent to ERPNext", subtitle: r.entryName ?? undefined };
}
return { kind: "error", title: "Could not send the payment", subtitle: r.error ?? undefined };
}
/** A rejected invoke (for example "ERPNext is not set up yet") turned into a result row, so a batch carries on. */
export function failedPush(invoiceId: number, number: string, error: unknown): ErpnextPushResult {
return {
invoiceId,
number,
ok: false,
status: "error",
remoteName: "",
remoteDocstatus: 0,
created: false,
noOp: false,
attached: false,
error: error instanceof Error ? error.message : String(error),
errorKind: "other",
warnings: [],
};
}
export interface BatchSummary {
total: number;
sent: number;
upToDate: number;
failed: number;
withNotes: number;
kind: NoticeKind;
title: string;
}
export function summarizeBatch(results: ErpnextPushResult[]): BatchSummary {
const failed = results.filter((r) => !r.ok).length;
const upToDate = results.filter((r) => r.ok && r.noOp && r.warnings.length === 0).length;
const sent = results.filter((r) => r.ok).length - upToDate;
const withNotes = results.filter((r) => r.ok && r.warnings.length > 0).length;
const kind: NoticeKind = failed > 0 ? (failed === results.length ? "error" : "warning") : withNotes > 0 ? "warning" : "success";
const parts = [`${sent} sent`];
if (upToDate > 0) parts.push(`${upToDate} already up to date`);
if (failed > 0) parts.push(`${failed} failed`);
return { total: results.length, sent, upToDate, failed, withNotes, kind, title: parts.join(", ") };
}
// ---- auto-push after issue ----
export interface AutoPushDeps {
getConfig(): Promise<ErpnextConfig>;
push(id: number, submit?: boolean): Promise<ErpnextPushResult>;
}
/**
* Pushes a just-issued invoice when "send on issue" is on. Never throws and never blocks the issue: anything that
* goes wrong comes back as a notice. Returns null when nothing was attempted (off, or not set up).
*/
export async function autoPushIssued(
deps: AutoPushDeps,
invoice: { id: number; number: string },
vendorRegistered: boolean,
): Promise<Notice | null> {
let cfg: ErpnextConfig;
try {
cfg = await deps.getConfig();
} catch {
return null;
}
if (!cfg.autoPushOnIssue || !isConfigured(cfg)) return null;
const blocked = pushDisabledReason({ cfg, vendorRegistered });
if (blocked) return { kind: "warning", title: `Invoice ${invoice.number} was not sent to ERPNext`, subtitle: blocked };
try {
return describePush(await deps.push(invoice.id));
} catch (e) {
return describePush(failedPush(invoice.id, invoice.number, e));
}
}
// ---- connection test display ----
const ERROR_CODES = new Set([
"company_missing",
"company_address_missing",
"income_account_missing",
"tax_accounts_missing",
"naming_series_missing",
"naming_series_too_long",
"number_invalid",
"mirror_unsupported",
"gst_accounts_not_configured",
]);
const INFO_CODES = new Set(["versions_unknown", "gst_settings_unreadable"]);
/** How loudly a test warning is shown: the ones that would make invoices land wrong are errors. */
export function warningSeverity(code: string): "error" | "warning" | "info" {
if (ERROR_CODES.has(code)) return "error";
if (INFO_CODES.has(code)) return "info";
return "warning";
}
export function sortWarnings(list: ErpnextWarning[]): ErpnextWarning[] {
const rank = { error: 0, warning: 1, info: 2 } as const;
return [...list].sort((a, b) => rank[warningSeverity(a.code)] - rank[warningSeverity(b.code)]);
}
export function versionsLine(test: ErpnextConnectionTest): string {
const v = test.versions;
const parts = [
`Frappe ${v.frappe ?? "unknown"}`,
`ERPNext ${v.erpnext ?? "unknown"}`,
test.indiaCompliance ? `India Compliance ${v.indiaCompliance ?? "installed"}` : "India Compliance not detected",
];
return parts.join(" · ");
}
// ---- settings form ----
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const;
export const DEFAULT_UOM_MAP: Record<string, string> = {
second: "Second",
minute: "Minute",
hour: "Hour",
session: "Nos",
unit: "Nos",
};
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
for (const [k, v] of Object.entries(map ?? {})) if (v.trim()) out[k] = v;
return out;
}
/** What the form edits: the saved config minus the write-only fields, plus the secret being typed. */
export function toInput(cfg: ErpnextConfig): ErpnextConfigInput {
const { apiSecretSet: _set, lastDetectResult: _last, ...rest } = cfg;
void _set;
void _last;
return { ...rest, uomMap: withDefaultUoms(rest.uomMap), apiSecret: "", clearSecret: false };
}
/** Mirror mode needs a Frappe that keeps a posted name; an untested site defaults to it (the backend default). */
export function mirrorSupported(test: ErpnextConnectionTest | null): boolean {
return test ? test.features.v2Naming : true;
}
export function isLocalDevHost(host: string): boolean {
const h = host.replace(/\.$/, "").toLowerCase();
return h === "localhost" || h === "127.0.0.1" || h.endsWith(".localhost") || h.endsWith(".test");
}
/** Same rules as the backend's address check, so a bad address is caught before the round trip. */
export function baseUrlProblem(raw: string): string | null {
const text = raw.trim();
if (!text) return "Enter the address of your ERPNext site.";
const withScheme = text.includes("://") ? text : `${isLocalDevHost(text.split(/[/:]/)[0] ?? "") ? "http" : "https"}://${text}`;
let url: URL;
try {
url = new URL(withScheme);
} catch {
return "This is not a valid address, e.g. https://erp.example.com";
}
if (url.protocol !== "https:" && url.protocol !== "http:") return "Use an https:// address.";
if (url.protocol === "http:" && !isLocalDevHost(url.hostname)) {
return "Use an https:// address. Plain http is only allowed for localhost, 127.0.0.1, *.localhost and *.test.";
}
if (url.username || url.password) return "Do not put credentials in the address; use the API key and secret fields.";
return null;
}
export interface FormProblems {
baseUrl?: string;
apiKey?: string;
apiSecret?: string;
company?: string;
namingSeries?: string;
namingMode?: string;
}
/** Problems that stop a Test connection (address, key, secret). */
export function connectionProblems(form: ErpnextConfigInput, secretStored: boolean): FormProblems {
const p: FormProblems = {};
const url = baseUrlProblem(form.baseUrl);
if (url) p.baseUrl = url;
if (!form.apiKey.trim()) p.apiKey = "Enter the API key.";
if (!form.apiSecret.trim() && (form.clearSecret || !secretStored)) p.apiSecret = "Enter the API secret.";
return p;
}
/** Problems that stop a Save. An empty form (nothing set up) saves fine; a half-filled one does not. */
export function validateConfigForm(form: ErpnextConfigInput, secretStored: boolean, test: ErpnextConnectionTest | null): FormProblems {
const touched = form.baseUrl.trim() || form.apiKey.trim() || form.apiSecret.trim() || secretStored;
if (!touched) return {};
const p = connectionProblems(form, secretStored);
if (form.namingMode === "series" && !form.namingSeries.trim()) p.namingSeries = "Choose or enter the naming series.";
if (form.namingMode === "mirror" && !mirrorSupported(test)) {
p.namingMode = "This ERPNext version does not keep a posted invoice number. Use ERPNext series.";
}
return p;
}
export const hasProblems = (p: FormProblems): boolean => Object.keys(p).length > 0;
/** Options for a dropdown, with the saved value kept visible when the loaded list does not contain it. */
export function withStoredValue(items: ErpnextOptionItem[], stored: string): ErpnextOptionItem[] {
const value = stored.trim();
if (!value || items.some((i) => i.name === value)) return items;
return [{ name: value, label: value, detail: "saved value, not in the loaded list" }, ...items];
}
export const optionText = (i: ErpnextOptionItem): string => (i.detail ? `${i.label} (${i.detail})` : i.label);
export const OPTION_LIST_LABELS: Record<string, string> = {
companies: "Companies",
companyAddresses: "Company addresses",
incomeAccounts: "Income accounts",
taxAccounts: "Tax accounts",
taxTemplates: "Tax templates",
namingSeries: "Naming series",
itemGroups: "Item groups",
uoms: "Units of measure",
costCenters: "Cost centers",
priceLists: "Price lists",
customerGroups: "Customer groups",
territories: "Territories",
};
/** Per-list load errors keyed by list name; the other lists stay usable. */
export function optionErrors(options: ErpnextOptions | null): Map<string, string> {
return new Map((options?.errors ?? []).map((e) => [e.list, e.message]));
}
export const emptyOptions = (): ErpnextOptions => ({
companies: [],
companyAddresses: [],
incomeAccounts: [],
taxAccounts: [],
taxTemplates: [],
namingSeries: [],
itemGroups: [],
uoms: [],
costCenters: [],
priceLists: [],
customerGroups: [],
territories: [],
errors: [],
});
+1 -1
View File
@@ -154,7 +154,7 @@ export interface HistoryExportDeps {
now(): Date;
}
function joinPath(dir: string, name: string): string {
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}`;
+311
View File
@@ -0,0 +1,311 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
buildInvoiceDoc,
exportInvoicesV1,
invoiceExportName,
LINE_CSV_HEADERS,
loadInvoiceDocs,
rateString,
rupeesToPaise,
toInvoiceJson,
toLineItemsCsv,
type InvoiceExportDeps,
} from "./invoiceExportV1";
import type { Payment } from "./payments";
import { blankClient, type Client, type Invoice } from "./types";
const VENDOR = (patch: Record<string, unknown> = {}) =>
JSON.stringify({
vendorName: "Test Vendor",
vendorAddress: "1 Main Rd\nPune",
vendorEmail: "a@example.com",
vendorPhone: "999",
vendorPan: "AAPFU0939F",
vendorGstin: "27AAPFU0939F1ZV",
vendorStateCode: "27",
logoPath: "/secret/logo.png",
signaturePath: "/secret/sig.png",
gstRegistration: "regular",
signatoryName: "Sample",
...patch,
});
const invoice = (patch: Partial<Invoice> = {}): Invoice => ({
id: 1,
number: "INV/2026-001",
seriesId: 1,
invoiceDate: "2026-04-01",
dueDate: "2026-05-01",
clientId: 5,
clientName: "Client Ltd",
clientAddress: "12 MG Road\nBengaluru 560001",
clientGstin: "29AABCU9603R1ZJ",
poNumber: "PO-9",
placeOfSupplyStateCode: "29",
subtotal: 7310.1,
discount: 10.1,
taxType: "igst",
taxRate: 18,
cgstAmount: 0,
sgstAmount: 0,
igstAmount: 1314,
total: 8614,
amountInWords: "Rupees Eight Thousand Six Hundred Fourteen Only",
bankAccountId: null,
bankSnapshot: "",
signaturePath: null,
notes: "Thank you",
status: "issued",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
docType: "tax_invoice",
reverseCharge: false,
vendorSnapshot: VENDOR(),
snapshotOrigin: "issued",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items: [
{ id: 1, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 5000.1, sortOrder: 0, hsnSac: "998391" },
{ id: 2, description: "Consulting", mode: "rate", rate: 1155.05, unit: "hour", quantity: 2, amount: 2310, sortOrder: 1, hsnSac: "" },
],
totalPaise: 861400,
...patch,
});
const client: Client = {
...blankClient(),
id: 5,
name: "Client Ltd",
stateCode: "29",
addressLine1: "12 MG Road",
addressLine2: "",
city: "Bengaluru",
pincode: "560001",
gstCategory: "registered_regular",
};
const payment = (patch: Partial<Payment> = {}): Payment => ({
id: 1,
invoiceId: 1,
paidOn: "2026-04-10",
amountPaise: 700000,
tdsPaise: 70000,
mode: "bank_transfer",
reference: "UTR123",
notes: "n",
createdAt: "x",
...patch,
});
describe("voiced.invoice.v1 document", () => {
it("matches the golden file for a fixture invoice", () => {
const text = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
const path = new URL("./__golden__/invoice-v1.golden.json", import.meta.url);
if (!existsSync(path)) writeFileSync(path, text);
expect(text).toBe(readFileSync(path, "utf8"));
});
it("is deterministic and keeps the key order", () => {
const a = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
const b = toInvoiceJson([buildInvoiceDoc(invoice(), [payment()], client)]);
expect(a).toBe(b);
const doc = buildInvoiceDoc(invoice(), [], null);
expect(Object.keys(doc).slice(0, 4)).toEqual(["schema", "number", "issue_date", "due_date"]);
expect(doc.schema).toBe("voiced.invoice.v1");
});
it("carries every amount as exact paise and a matching decimal string", () => {
const doc = buildInvoiceDoc(invoice(), [payment()], client);
// Plain float multiplication is off (0.29 * 100 is 28.999999999999996); the paise must be exact integers.
expect(0.29 * 100).not.toBe(29);
expect(doc.totals.subtotal_paise).toBe(731010);
expect(doc.totals.subtotal).toBe("7310.10");
expect(doc.totals.discount).toBe("10.10");
expect(doc.totals.taxable_value_paise).toBe(730000);
expect(doc.totals.tax).toBe("1314.00");
expect(doc.totals.total_paise).toBe(861400);
expect(doc.totals.total).toBe("8614.00");
expect(doc.items[0]).toMatchObject({ quantity: "1", unit: null, rate: "5000.10", taxable_value_paise: 500010, hsn_sac: "998391" });
expect(doc.items[1]).toMatchObject({ quantity: "2", unit: "hour", rate: "1155.05", rate_paise: 115505, hsn_sac: null });
expect(doc.payments[0]).toMatchObject({ amount: "7000.00", tds: "700.00", tds_paise: 70000 });
// Every *_paise field is a safe integer and agrees with its decimal.
const walk = (o: unknown, path: string[] = []) => {
if (!o || typeof o !== "object") return;
const rec = o as Record<string, unknown>;
for (const [k, v] of Object.entries(rec)) {
if (k.endsWith("_paise")) {
expect(Number.isSafeInteger(v), path.concat(k).join(".")).toBe(true);
const dec = rec[k.slice(0, -6)];
expect(dec, path.concat(k).join(".")).toBe(`${Math.trunc((v as number) / 100)}.${String(Math.abs(v as number) % 100).padStart(2, "0")}`);
} else walk(v, path.concat(k));
}
};
walk(JSON.parse(toInvoiceJson([doc])));
});
it("reports the tax heads: CGST+SGST at half rate, IGST whole, none for an unregistered vendor", () => {
const intra = buildInvoiceDoc(invoice({ taxType: "cgst_sgst", taxRate: 12.5, cgstAmount: 100.25, sgstAmount: 100.25, igstAmount: 0 }), [], client);
expect(intra.tax.cgst).toEqual({ rate: "6.25", amount_paise: 10025, amount: "100.25" });
expect(intra.tax.sgst).toEqual({ rate: "6.25", amount_paise: 10025, amount: "100.25" });
expect(intra.tax.igst).toEqual({ rate: "0", amount_paise: 0, amount: "0.00" });
expect(intra.tax.utgst.amount_paise).toBe(0);
expect(intra.totals.tax_paise).toBe(20050);
const inter = buildInvoiceDoc(invoice(), [], client);
expect(inter.tax.igst).toEqual({ rate: "18", amount_paise: 131400, amount: "1314.00" });
expect(inter.tax.cgst.rate).toBe("0");
});
it("moves the second head to UTGST when the supplier is in a union territory", () => {
const doc = buildInvoiceDoc(
invoice({ taxType: "cgst_sgst", taxRate: 18, cgstAmount: 90, sgstAmount: 90, igstAmount: 0, vendorSnapshot: VENDOR({ vendorStateCode: "04" }) }),
[],
client,
);
expect(doc.tax.sgst.amount_paise).toBe(0);
expect(doc.tax.utgst).toEqual({ rate: "9", amount_paise: 9000, amount: "90.00" });
expect(doc.supplier.state).toEqual({ code: "04", name: "Chandigarh" });
});
it("flags a cancelled invoice and keeps its figures", () => {
const doc = buildInvoiceDoc(invoice({ status: "cancelled", cancelledAt: "2026-04-05T10:00:00Z", cancelReason: "wrong client" }), [], client);
expect(doc).toMatchObject({ status: "cancelled", cancelled: true, cancelled_at: "2026-04-05T10:00:00Z", cancel_reason: "wrong client" });
expect(doc.totals.total).toBe("8614.00");
const live = buildInvoiceDoc(invoice(), [], client);
expect(live).toMatchObject({ cancelled: false, cancelled_at: null, cancel_reason: null });
});
it("exports an unregistered vendor's plain invoice without tax or GSTIN", () => {
const doc = buildInvoiceDoc(
invoice({
docType: "invoice",
taxType: "none",
taxRate: 0,
igstAmount: 0,
subtotal: 7300,
discount: 0,
total: 7300,
totalPaise: 730000,
clientGstin: "",
vendorSnapshot: VENDOR({ gstRegistration: "unregistered", vendorGstin: "" }),
}),
[],
null,
);
expect(doc.document_type).toBe("invoice");
expect(doc.supplier).toMatchObject({ gstin: null, gst_registration: "unregistered" });
expect(doc.totals.tax_paise).toBe(0);
expect(doc.tax.igst.rate).toBe("0");
expect(doc.client.gstin).toBeNull();
expect(doc.client.gst_category).toBe("unregistered");
expect(doc.client.address).toMatchObject({ line1: null, city: null, state: null });
});
it("does not leak local file paths from the supplier snapshot", () => {
const text = toInvoiceJson([buildInvoiceDoc(invoice(), [], client)]);
expect(text).not.toContain("/secret/");
expect(text).not.toContain("a@example.com");
});
it("survives a missing or broken supplier snapshot", () => {
for (const vendorSnapshot of ["", "not json", "null"]) {
const doc = buildInvoiceDoc(invoice({ vendorSnapshot }), [], client);
expect(doc.supplier).toMatchObject({ name: "", gstin: null, state: { code: "", name: "" } });
}
});
it("converts rupees and rates without noise", () => {
expect(rupeesToPaise(0.29)).toBe(29);
expect(rupeesToPaise(-0.001)).toBe(0);
expect(() => rupeesToPaise(Number.NaN)).toThrow();
expect(rateString(9)).toBe("9");
expect(rateString(2.5)).toBe("2.5");
expect(rateString(0.1 + 0.2)).toBe("0.3");
});
});
describe("line-items CSV", () => {
it("writes a BOM, CRLF, a header and one row per line with invoice amounts on the first row only", () => {
const csv = toLineItemsCsv([buildInvoiceDoc(invoice(), [], client)]);
expect(csv.startsWith("")).toBe(true);
const lines = csv.slice(1).split("\r\n");
expect(lines[0]).toBe(LINE_CSV_HEADERS.join(","));
expect(lines).toHaveLength(4); // header, 2 lines, trailing empty
const first = lines[1].split(",");
const second = lines[2].split(",");
const col = (name: string) => LINE_CSV_HEADERS.indexOf(name);
expect(first[col("invoice_total")]).toBe("8614.00");
expect(second[col("invoice_total")]).toBe("");
expect(first[col("invoice_igst")]).toBe("1314.00");
expect(second[col("taxable_value")]).toBe("2310.00");
expect(first[col("invoice_number")]).toBe("INV/2026-001");
});
it("quotes commas and quotes, and guards formulas", () => {
const doc = buildInvoiceDoc(invoice({ items: [{ id: 1, description: '=SUM(A1), "x"', mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 10, sortOrder: 0 }] }), [], client);
const csv = toLineItemsCsv([doc]);
expect(csv).toContain(`"'=SUM(A1), ""x"""`);
});
it("keeps an invoice with no items as one row", () => {
const csv = toLineItemsCsv([buildInvoiceDoc(invoice({ items: [] }), [], client)]);
expect(csv.slice(1).split("\r\n")).toHaveLength(3);
});
});
describe("saving", () => {
const deps = (over: Partial<InvoiceExportDeps> = {}): InvoiceExportDeps & { written: { path: string; text: string }[] } => {
const written: { path: string; text: string }[] = [];
return {
written,
getInvoice: async (id) => invoice({ id, number: `INV/2026-00${id}` }),
listPayments: async () => [payment()],
listClients: async () => [client],
getLastExportDir: async () => "/home/a/exports",
save: async ({ defaultPath }) => defaultPath,
writeExportFile: async (path, bytes) => {
written.push({ path, text: new TextDecoder().decode(bytes) });
return path;
},
now: () => new Date("2026-06-01T10:00:00Z"),
...over,
};
};
it("names the file after the invoice or the count", () => {
const one = [buildInvoiceDoc(invoice(), [], null)];
const now = new Date("2026-06-01T10:00:00Z");
expect(invoiceExportName(one, "json", now)).toBe("voiced-invoice-INV-2026-001.json");
expect(invoiceExportName(one, "csv", now)).toBe("voiced-invoice-INV-2026-001-lines.csv");
expect(invoiceExportName([...one, ...one], "json", now)).toBe("voiced-invoices-2-2026-06-01.json");
});
it("writes the chosen invoices in order, once each", async () => {
const d = deps();
const done = await exportInvoicesV1(d, [2, 1, 2], "json");
expect(done).toEqual({ path: "/home/a/exports/voiced-invoices-2-2026-06-01.json", count: 2 });
const docs = JSON.parse(d.written[0].text) as { number: string }[];
expect(docs.map((x) => x.number)).toEqual(["INV/2026-002", "INV/2026-001"]);
});
it("returns null when the dialog is cancelled and writes nothing", async () => {
const d = deps({ save: async () => null });
expect(await exportInvoicesV1(d, [1], "csv")).toBeNull();
expect(d.written).toHaveLength(0);
});
it("still exports when the client list cannot be read", async () => {
const docs = await loadInvoiceDocs(deps({ listClients: async () => Promise.reject(new Error("db")) }), [1]);
expect(docs).toHaveLength(1);
expect(docs[0].client.address.line1).toBeNull();
});
it("works without a remembered folder", async () => {
const d = deps({ getLastExportDir: async () => Promise.reject(new Error("none")) });
const done = await exportInvoicesV1(d, [1], "json");
expect(done?.path).toBe("voiced-invoice-INV-2026-001.json");
});
});
+364
View File
@@ -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 };
}
+3 -1
View File
@@ -23,9 +23,11 @@ export interface Payment {
reference: string;
notes: string;
createdAt: string;
/** Payment Entry name on ERPNext once the payment was sent. */
erpnextPaymentEntry?: string | null;
}
export type PaymentInput = Omit<Payment, "id" | "createdAt">;
export type PaymentInput = Omit<Payment, "id" | "createdAt" | "erpnextPaymentEntry">;
type TagType = "red" | "green" | "blue" | "gray" | "warm-gray" | "cool-gray" | "purple";
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab";
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, requestSettingsTab, saveSettingsTab } from "./settingsTab";
describe("settings tab persistence", () => {
it("maps stored ids to indexes and falls back to the first tab", () => {
@@ -14,4 +14,10 @@ describe("settings tab persistence", () => {
expect(() => saveSettingsTab(2)).not.toThrow();
expect(() => saveSettingsTab(99)).not.toThrow();
});
it("honours a requested tab once, then goes back to the stored one", () => {
requestSettingsTab("integrations");
expect(loadSettingsTab()).toBe(SETTINGS_TABS.findIndex((t) => t.id === "integrations"));
expect(loadSettingsTab()).toBe(0);
});
});
+14
View File
@@ -9,6 +9,7 @@ export const SETTINGS_TABS = [
{ id: "templates", label: "Templates & export" },
{ id: "fonts", label: "Fonts" },
{ id: "data", label: "Data" },
{ id: "integrations", label: "Integrations" },
] as const;
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
@@ -19,8 +20,21 @@ export function parseSettingsTab(value: string | null | undefined): number {
return index < 0 ? 0 : index;
}
/** A tab another screen asked for; used once by the next Settings mount, so it works without localStorage. */
let requested: SettingsTabId | null = null;
/** Makes the next opening of Settings land on `id` (for "Set up in Settings" links). */
export function requestSettingsTab(id: SettingsTabId): void {
requested = id;
}
/** The last Settings tab this viewer used. A per-viewer convenience only. */
export function loadSettingsTab(): number {
if (requested) {
const index = parseSettingsTab(requested);
requested = null;
return index;
}
try {
return parseSettingsTab(localStorage.getItem(KEY));
} catch {