Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)

- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure.
- Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup.
- Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time.
- History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command.
- Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers.

Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
2026-10-04 18:13:57 +05:30
parent 9fdff4f263
commit 4cf019988d
16 changed files with 2513 additions and 7 deletions
+278
View File
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import {
backupFileName,
ensureZipExtension,
formatBytes,
formatWhen,
type BackupStatus,
type PendingRestore,
} from "../lib/backup";
import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider";
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
type RestoreStep = "confirm" | "staging" | "ready";
const joinPath = (dir: string, name: string) =>
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
export default function DataBackupPanel() {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
const [backingUp, setBackingUp] = useState(false);
const [restorePath, setRestorePath] = useState<string | null>(null);
const [step, setStep] = useState<RestoreStep>("confirm");
const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const refresh = useCallback(async () => {
try {
setStatus(await api.getBackupStatus());
} catch (e) {
toast.error("Could not load backup status", String(e));
}
}, [toast]);
useEffect(() => {
void refresh();
}, [refresh]);
const backUpNow = async () => {
setBackingUp(true);
try {
let dir = "";
try {
dir = await api.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
if (!chosen) return;
const summary = await api.createBackup(ensureZipExtension(chosen));
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
} catch (e) {
toast.error("Backup failed", String(e));
} finally {
setBackingUp(false);
}
};
const chooseRestoreFile = async () => {
try {
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
if (typeof picked !== "string") return;
setRestoreError(null);
setStaged(null);
setStep("confirm");
setRestorePath(picked);
} catch (e) {
toast.error("Could not open the file picker", String(e));
}
};
const closeRestore = () => {
if (step === "staging") return;
setRestorePath(null);
void refresh();
};
const stageRestore = async () => {
if (!restorePath) return;
setStep("staging");
setRestoreError(null);
try {
setStaged(await api.restoreBackup(restorePath));
setStep("ready");
} catch (e) {
setRestoreError(String(e));
setStep("confirm");
toast.error("This backup cannot be restored", String(e));
}
};
const restart = async () => {
setRestarting(true);
try {
await api.restartApp();
} catch (e) {
setRestarting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
}
};
const cancelPending = async () => {
try {
await api.cancelPendingRestore();
toast.info("Restore cancelled", "Your data was not changed.");
} catch (e) {
toast.error("Could not cancel the restore", String(e));
}
setRestorePath(null);
await refresh();
};
const toggleAuto = async (enabled: boolean) => {
try {
const saved = await api.setAutoBackup(enabled);
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
} catch (e) {
toast.error("Could not change automatic backup", String(e));
await refresh();
}
};
const openFolder = async (path: string) => {
try {
await api.openFile(path);
} catch (e) {
toast.error("Could not open the folder", String(e));
}
};
const pending = status?.pendingRestore ?? null;
return (
<section className="voiced-stack">
{pending ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="A restore is waiting for a restart"
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
/>
) : null}
{pending ? (
<div className="voiced-actions">
<Button size="md" onClick={() => void restart()} disabled={restarting}>
{restarting ? "Restarting…" : "Restart now"}
</Button>
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
Cancel restore
</Button>
</div>
) : null}
<Tile>
<h2 className="voiced-section-title">Back up</h2>
<p className="voiced-muted">
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
fonts. Keep a copy somewhere other than this computer.
</p>
<div className="voiced-actions">
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
{backingUp ? "Backing up…" : "Back up now"}
</Button>
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Restore from backup</h2>
<p className="voiced-muted">
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
never deleted.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
Restore from backup…
</Button>
</div>
{status?.lastRestore ? (
<p className="voiced-muted" role="status">
{status.lastRestore.ok
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
{status.lastRestore.safetyDir ? (
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
Show safety copy
</Button>
) : null}
</p>
) : null}
</Tile>
<Tile>
<h2 className="voiced-section-title">Automatic backup</h2>
<Toggle
id="autoBackup"
labelText="Back up automatically every day"
labelA="Off"
labelB="On"
toggled={status?.autoBackup ?? true}
disabled={!status}
onToggle={(checked: boolean) => void toggleAuto(checked)}
/>
<p className="voiced-muted">
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
do not need to click Save settings.
</p>
<p role="status">
{status === null
? "Loading…"
: status.lastAutoBackup
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
: "No automatic backup yet."}
</p>
<div className="voiced-actions">
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
Open backup folder
</Button>
</div>
</Tile>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
primaryButtonText={
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
}
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
primaryButtonDisabled={step === "staging" || restarting}
onRequestClose={closeRestore}
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
onSecondarySubmit={closeRestore}
>
<div className="voiced-stack">
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
{step === "ready" && staged ? (
<>
<p>
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
{formatWhen(staged.backupCreatedAt)}.
</p>
<p>
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
</p>
</>
) : (
<>
<p>
File: <strong>{restorePath}</strong>
</p>
<p>
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
contents of this backup.
</p>
<ul className="voiced-list">
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
<li>Your current data is moved to a safety copy in Voiced&rsquo;s backups folder before the swap. It is never deleted.</li>
<li>Voiced restarts to complete the restore.</li>
</ul>
</>
)}
</div>
</Modal>
</section>
);
}
+11
View File
@@ -12,6 +12,8 @@ import type {
} from "./types";
import type { LogoAsset } from "./logo";
import type { Payment, PaymentInput } from "./payments";
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
import type { LedgerRow } from "./historyExport";
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
export interface ArchiveStatus {
@@ -48,6 +50,7 @@ export const api = {
cancelInvoice: (id: number, reason: string) =>
invoke<Invoice>("cancel_invoice", { id, reason }),
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
@@ -102,4 +105,12 @@ export const api = {
getLastExportDir: () => invoke<string>("get_last_export_dir"),
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
openFile: (path: string) => invoke<void>("open_file", { path }),
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"),
};
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
describe("backup helpers", () => {
it("names backups by local date and time", () => {
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
});
it("adds the zip extension only when missing", () => {
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
});
it("formats sizes", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1536)).toBe("1.5 KB");
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
});
it("falls back to the raw text for an unparseable time", () => {
expect(formatWhen("not a date")).toBe("not a date");
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
});
});
+68
View File
@@ -0,0 +1,68 @@
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
export interface BackupSummary {
path: string;
createdAt: string;
schemaVersion: number;
fileCount: number;
totalBytes: number;
}
export interface PendingRestore {
backupName: string;
backupCreatedAt: string;
appVersion: string;
schemaVersion: number;
fileCount: number;
totalBytes: number;
stagedAt: string;
}
export interface LastRestore {
ok: boolean;
at: string;
backupName: string;
message: string;
safetyDir: string | null;
}
export interface BackupStatus {
autoBackup: boolean;
autoDir: string;
lastAutoBackup: string | null;
autoBackupCount: number;
pendingRestore: PendingRestore | null;
lastRestore: LastRestore | null;
}
const pad = (n: number) => String(n).padStart(2, "0");
/** `voiced-backup-20260304-1530.zip` in local time. */
export function backupFileName(now: Date = new Date()): string {
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
}
/** The save dialog does not always add the extension (notably on Linux). */
export function ensureZipExtension(path: string): string {
return /\.zip$/i.test(path) ? path : `${path}.zip`;
}
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let i = 0;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
}
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
export function formatWhen(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
}
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from "vitest";
import {
buildHistoryRows,
csvField,
exportHistory,
guardFormula,
HISTORY_COLUMNS,
historyExportName,
paiseToDecimal,
toCsv,
toJson,
type HistoryExportDeps,
type LedgerRow,
} from "./historyExport";
import { NO_FILTERS } from "./historyFilter";
import type { InvoiceSummary } from "./types";
const summary = (over: Partial<InvoiceSummary> = {}): InvoiceSummary => ({
id: 1,
number: "INV/2025-001",
invoiceDate: "2025-06-10",
dueDate: "2025-07-10",
clientName: "Acme, Inc.",
total: 0,
status: "issued",
createdAt: "",
poNumber: "PO-1",
totalPaise: 1_180_050,
paidPaise: 1_000_000,
tdsPaise: 50_000,
balancePaise: 130_050,
paymentStatus: "partially_paid",
...over,
});
const ledger = (over: Partial<LedgerRow> = {}): LedgerRow => ({
id: 1,
clientGstin: "29ABCDE1234F1Z5",
placeOfSupplyCode: "29",
docType: "tax_invoice",
hsnSac: ["9983", "9984", "9983"],
taxablePaise: 1_000_000,
cgstPaise: 90_025,
sgstPaise: 90_025,
igstPaise: 0,
reverseCharge: false,
...over,
});
const columnIndex = (key: string) => HISTORY_COLUMNS.findIndex((c) => c.key === key);
describe("paiseToDecimal", () => {
it("formats from integer paise without float math", () => {
expect(paiseToDecimal(0)).toBe("0.00");
expect(paiseToDecimal(5)).toBe("0.05");
expect(paiseToDecimal(100)).toBe("1.00");
expect(paiseToDecimal(123_450)).toBe("1234.50");
expect(paiseToDecimal(-5)).toBe("-0.05");
expect(paiseToDecimal(-123_456)).toBe("-1234.56");
// 1.005 * 100 style float traps cannot happen: 100_100_000_000_005 paise.
expect(paiseToDecimal(100_100_000_000_005)).toBe("1001000000000.05");
});
it("rejects non-integers", () => {
expect(() => paiseToDecimal(1.5)).toThrow();
expect(() => paiseToDecimal(Number.NaN)).toThrow();
});
});
describe("rows", () => {
it("builds the CA-friendly columns", () => {
const [row] = buildHistoryRows([summary()], [ledger()]);
const get = (key: string) => row[columnIndex(key)];
expect(HISTORY_COLUMNS.map((c) => c.header)).toEqual([
"Invoice No", "Date", "Due Date", "Client", "Client GSTIN", "Place of Supply Code", "Place of Supply",
"Document Type", "HSN/SAC", "Taxable Value", "CGST", "SGST", "IGST", "Total Tax", "Invoice Total",
"Received", "TDS", "Balance", "Status", "Payment Status", "PO Number", "Cancelled",
]);
expect(get("invoiceNo")).toBe("INV/2025-001");
expect(get("placeOfSupplyCode")).toBe("29");
expect(get("placeOfSupply")).toBe("Karnataka");
expect(get("documentType")).toBe("Tax Invoice");
expect(get("hsnSac")).toBe("9983; 9984");
expect(get("taxableValue")).toBe("10000.00");
expect(get("cgst")).toBe("900.25");
expect(get("igst")).toBe("0.00");
expect(get("totalTax")).toBe("1800.50");
expect(get("invoiceTotal")).toBe("11800.50");
expect(get("received")).toBe("10000.00");
expect(get("tds")).toBe("500.00");
expect(get("balance")).toBe("1300.50");
expect(get("status")).toBe("Issued");
expect(get("paymentStatus")).toBe("Partly paid");
expect(get("cancelled")).toBe("No");
});
it("flags cancelled invoices and leaves ledger fields blank when the row is missing", () => {
const [row] = buildHistoryRows([summary({ status: "cancelled", paymentStatus: "none" })], []);
expect(row[columnIndex("cancelled")]).toBe("Yes");
expect(row[columnIndex("status")]).toBe("Cancelled");
expect(row[columnIndex("paymentStatus")]).toBe("");
expect(row[columnIndex("taxableValue")]).toBe("");
expect(row[columnIndex("invoiceTotal")]).toBe("11800.50");
});
});
describe("CSV", () => {
it("quotes per RFC 4180", () => {
expect(csvField("plain")).toBe("plain");
expect(csvField("a,b")).toBe('"a,b"');
expect(csvField('say "hi"')).toBe('"say ""hi"""');
expect(csvField("two\nlines")).toBe('"two\nlines"');
expect(csvField("cr\rhere")).toBe('"cr\rhere"');
expect(csvField(" padded")).toBe('" padded"');
});
it("guards text cells against formula injection but leaves amounts numeric", () => {
for (const bad of ["=1+1", "+SUM(A1)", "-2+3", "@cmd", "\tTAB", "\rCR"]) {
expect(guardFormula(bad)).toBe(`'${bad}`);
}
expect(guardFormula("INV/2025-001")).toBe("INV/2025-001");
const cells = buildHistoryRows(
[summary({ clientName: "=HYPERLINK(\"http://x\")", poNumber: "-5", balancePaise: -2_500, paidPaise: 0 })],
[ledger()],
);
const csv = toCsv(cells);
const dataLine = csv.split("\r\n")[1];
expect(dataLine).toContain(`"'=HYPERLINK(""http://x"")"`);
expect(dataLine).toContain(",'-5,"); // PO number text is guarded
expect(dataLine).toContain(",-25.00,"); // the numeric balance is not
expect(dataLine).not.toContain("'-25.00");
});
it("has a BOM, CRLF line ends and a trailing newline", () => {
const csv = toCsv(buildHistoryRows([summary(), summary({ id: 2, number: "INV/2025-002" })], [ledger(), ledger({ id: 2 })]));
expect(csv.startsWith("Invoice No,Date,")).toBe(true);
expect(csv.endsWith("\r\n")).toBe(true);
expect(csv.split("\r\n")).toHaveLength(4); // header, two rows, empty after the last CRLF
expect(csv.replace(/\r\n/g, "").includes("\n")).toBe(false);
expect(new TextEncoder().encode(csv).slice(0, 3)).toEqual(new Uint8Array([0xef, 0xbb, 0xbf]));
});
it("an empty set is a header-only file", () => {
const csv = toCsv(buildHistoryRows([], []));
expect(csv.split("\r\n")).toEqual([expect.stringMatching(/^Invoice No,.*Cancelled$/), ""]);
});
});
describe("JSON", () => {
it("wraps the rows with schema, time and filters", () => {
const filters = { ...NO_FILTERS, fy: 2025 as const, search: "acme" };
const parsed = JSON.parse(toJson(buildHistoryRows([summary()], [ledger()]), filters, "2026-01-02T03:04:05.000Z"));
expect(parsed.schema).toBe("voiced.history.v1");
expect(parsed.exported_at).toBe("2026-01-02T03:04:05.000Z");
expect(parsed.filters).toEqual(filters);
expect(parsed.count).toBe(1);
expect(parsed.rows[0]).toMatchObject({ invoiceNo: "INV/2025-001", taxableValue: "10000.00", totalTax: "1800.50", cancelled: "No" });
});
it("an empty set has no rows", () => {
const parsed = JSON.parse(toJson([], NO_FILTERS, "t"));
expect(parsed.rows).toEqual([]);
expect(parsed.count).toBe(0);
});
});
describe("historyExportName", () => {
it("uses the financial year when one is picked", () => {
expect(historyExportName({ ...NO_FILTERS, fy: 2025 }, [], "csv")).toBe("voiced-history-FY2025-26.csv");
expect(historyExportName({ ...NO_FILTERS, fy: 2025, month: 4 }, [], "json")).toBe("voiced-history-FY2025-26-m04.json");
});
it("otherwise the date range of the rows", () => {
const rows = [summary({ invoiceDate: "2025-06-10" }), summary({ invoiceDate: "2025-04-02" })];
expect(historyExportName(NO_FILTERS, rows, "csv")).toBe("voiced-history-2025-04-02_to_2025-06-10.csv");
expect(historyExportName(NO_FILTERS, [rows[0]], "csv")).toBe("voiced-history-2025-06-10.csv");
expect(historyExportName(NO_FILTERS, [], "csv")).toBe("voiced-history-all.csv");
});
});
describe("exportHistory", () => {
const make = (chosen: string | null) => {
const written: { path: string; text: string }[] = [];
const saves: { defaultPath: string; format: string }[] = [];
const deps: HistoryExportDeps = {
listLedger: async () => [ledger()],
getLastExportDir: async () => "/home/a/Exports",
save: async (o) => {
saves.push(o);
return chosen;
},
writeExportFile: async (path, bytes) => {
written.push({ path, text: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes) });
return path;
},
now: () => new Date("2026-01-02T03:04:05Z"),
};
return { deps, written, saves };
};
it("writes exactly the rows given, through the save dialog", async () => {
const { deps, written, saves } = make("/home/a/Exports/out.csv");
const out = await exportHistory(deps, [summary()], { ...NO_FILTERS, fy: 2025 }, "csv");
expect(out).toEqual({ path: "/home/a/Exports/out.csv", count: 1 });
expect(saves[0]).toEqual({ defaultPath: "/home/a/Exports/voiced-history-FY2025-26.csv", format: "csv" });
expect(written).toHaveLength(1);
expect(written[0].text.startsWith("Invoice No")).toBe(true);
expect(written[0].text.split("\r\n")).toHaveLength(3);
});
it("writes nothing when the dialog is cancelled, and a header-only file for an empty set", async () => {
const cancelled = make(null);
expect(await exportHistory(cancelled.deps, [summary()], NO_FILTERS, "json")).toBeNull();
expect(cancelled.written).toEqual([]);
const empty = make("/x/e.json");
expect(await exportHistory(empty.deps, [], NO_FILTERS, "json")).toEqual({ path: "/x/e.json", count: 0 });
expect(JSON.parse(empty.written[0].text).rows).toEqual([]);
});
});
+183
View File
@@ -0,0 +1,183 @@
import { fyLabel } from "./fiscal";
import type { HistoryFilters } from "./historyFilter";
import { INDIAN_STATES, type InvoiceSummary } from "./types";
/** Per-invoice fields the History list does not carry (from the `list_invoice_ledger` command). Money is integer paise. */
export interface LedgerRow {
id: number;
clientGstin: string;
placeOfSupplyCode: string;
docType: string;
hsnSac: string[];
taxablePaise: number;
cgstPaise: number;
sgstPaise: number;
igstPaise: number;
reverseCharge: boolean;
}
export const HISTORY_SCHEMA = "voiced.history.v1";
/** 123450 -> "1234.50", -5 -> "-0.05". Integer arithmetic only; throws on anything that is not a safe integer. */
export function paiseToDecimal(paise: number): string {
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount in paise: ${paise}`);
const abs = Math.abs(paise);
const whole = Math.floor(abs / 100);
const frac = abs % 100;
return `${paise < 0 ? "-" : ""}${whole}.${frac < 10 ? "0" : ""}${frac}`;
}
/** Text cells are guarded against spreadsheet formula injection; amount cells are known numbers and stay numeric. */
type Kind = "text" | "amount";
interface Column {
key: string;
header: string;
kind: Kind;
value: (s: InvoiceSummary, l: LedgerRow | undefined) => string;
}
const DOC_TYPES: Record<string, string> = { tax_invoice: "Tax Invoice", invoice: "Invoice" };
const PAYMENT_STATUS: Record<string, string> = {
unpaid: "Unpaid",
partially_paid: "Partly paid",
paid: "Paid",
overdue: "Overdue",
none: "",
};
const STATUS: Record<string, string> = { issued: "Issued", cancelled: "Cancelled", draft: "Draft" };
/** An amount that comes from the ledger row; blank (not a made-up zero) if the row is missing. */
const ledgerAmount = (pick: (l: LedgerRow) => number) => (_s: InvoiceSummary, l: LedgerRow | undefined) =>
l ? paiseToDecimal(pick(l)) : "";
/**
* Column order and meaning for both formats. Cancelled invoices keep their figures (the document exists and
* keeps its number); filter on the Cancelled column to leave them out of a sum.
*/
export const HISTORY_COLUMNS: readonly Column[] = [
{ key: "invoiceNo", header: "Invoice No", kind: "text", value: (s) => s.number },
{ key: "date", header: "Date", kind: "text", value: (s) => s.invoiceDate },
{ key: "dueDate", header: "Due Date", kind: "text", value: (s) => s.dueDate },
{ key: "client", header: "Client", kind: "text", value: (s) => s.clientName },
{ key: "clientGstin", header: "Client GSTIN", kind: "text", value: (_s, l) => l?.clientGstin ?? "" },
{ key: "placeOfSupplyCode", header: "Place of Supply Code", kind: "text", value: (_s, l) => l?.placeOfSupplyCode ?? "" },
{
key: "placeOfSupply",
header: "Place of Supply",
kind: "text",
value: (_s, l) => INDIAN_STATES.find((st) => st.code === l?.placeOfSupplyCode)?.name ?? "",
},
{ key: "documentType", header: "Document Type", kind: "text", value: (_s, l) => (l ? (DOC_TYPES[l.docType] ?? l.docType) : "") },
{ key: "hsnSac", header: "HSN/SAC", kind: "text", value: (_s, l) => (l ? [...new Set(l.hsnSac)].join("; ") : "") },
{ key: "taxableValue", header: "Taxable Value", kind: "amount", value: ledgerAmount((l) => l.taxablePaise) },
{ key: "cgst", header: "CGST", kind: "amount", value: ledgerAmount((l) => l.cgstPaise) },
{ key: "sgst", header: "SGST", kind: "amount", value: ledgerAmount((l) => l.sgstPaise) },
{ key: "igst", header: "IGST", kind: "amount", value: ledgerAmount((l) => l.igstPaise) },
{ key: "totalTax", header: "Total Tax", kind: "amount", value: ledgerAmount((l) => l.cgstPaise + l.sgstPaise + l.igstPaise) },
{ key: "invoiceTotal", header: "Invoice Total", kind: "amount", value: (s) => paiseToDecimal(s.totalPaise) },
{ key: "received", header: "Received", kind: "amount", value: (s) => paiseToDecimal(s.paidPaise) },
{ key: "tds", header: "TDS", kind: "amount", value: (s) => paiseToDecimal(s.tdsPaise) },
{ key: "balance", header: "Balance", kind: "amount", value: (s) => paiseToDecimal(s.balancePaise) },
{ key: "status", header: "Status", kind: "text", value: (s) => STATUS[s.status] ?? s.status },
{ key: "paymentStatus", header: "Payment Status", kind: "text", value: (s) => PAYMENT_STATUS[s.paymentStatus] ?? s.paymentStatus },
{ key: "poNumber", header: "PO Number", kind: "text", value: (s) => s.poNumber },
{ key: "cancelled", header: "Cancelled", kind: "text", value: (s) => (s.status === "cancelled" ? "Yes" : "No") },
];
/** The cells of every exported row, in column order. `ledger` is looked up by invoice id. */
export function buildHistoryRows(summaries: InvoiceSummary[], ledger: LedgerRow[]): string[][] {
const byId = new Map(ledger.map((l) => [l.id, l]));
return summaries.map((s) => HISTORY_COLUMNS.map((c) => c.value(s, byId.get(s.id))));
}
/** A text cell that a spreadsheet would read as a formula gets a leading apostrophe. */
export function guardFormula(text: string): string {
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
}
/** RFC 4180 field: quoted when it holds a quote, comma or line break (or edge spaces), inner quotes doubled. */
export function csvField(text: string): string {
return /[",\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
/** UTF-8 BOM (so Excel reads it as UTF-8), CRLF line ends, header row always present. */
export function toCsv(cells: string[][]): string {
const lines = [HISTORY_COLUMNS.map((c) => csvField(c.header))];
for (const row of cells) {
lines.push(
row.map((value, i) => {
const column = HISTORY_COLUMNS[i];
return csvField(column.kind === "text" ? guardFormula(value) : value);
}),
);
}
return `${lines.map((l) => l.join(",")).join("\r\n")}\r\n`;
}
/**
* `{ schema, exported_at, filters, count, rows }`. Rows are objects keyed like the CSV columns; amounts are the same
* 2-decimal rupee strings as in the CSV (no binary floats anywhere).
*/
export function toJson(cells: string[][], filters: HistoryFilters, exportedAt: string): string {
const rows = cells.map((row) => Object.fromEntries(HISTORY_COLUMNS.map((c, i) => [c.key, row[i]])));
return `${JSON.stringify({ schema: HISTORY_SCHEMA, exported_at: exportedAt, filters, count: rows.length, rows }, null, 2)}\n`;
}
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
/** `voiced-history-FY2025-26.csv`, or the date range of the rows when no year is picked, or `all` when empty. */
export function historyExportName(
filters: HistoryFilters,
summaries: InvoiceSummary[],
ext: "csv" | "json",
): string {
let part: string;
if (filters.fy !== "all") {
part = fyLabel(filters.fy).replace(/^FY /, "FY");
if (filters.month !== "all") part += `-m${String(filters.month).padStart(2, "0")}`;
} else {
const dates = summaries.map((s) => s.invoiceDate).filter(Boolean).sort();
if (dates.length === 0) part = "all";
else part = dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]}_to_${dates[dates.length - 1]}`;
}
return `voiced-history-${safeName(part)}.${ext}`;
}
export type HistoryFormat = "csv" | "json";
export interface HistoryExportDeps {
listLedger(): Promise<LedgerRow[]>;
getLastExportDir(): Promise<string>;
save(opts: { defaultPath: string; format: HistoryFormat }): Promise<string | null>;
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
now(): Date;
}
function joinPath(dir: string, name: string): string {
if (!dir) return name;
if (/[\\/]$/.test(dir)) return `${dir}${name}`;
return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
}
/** Exports exactly `summaries` (the filtered, sorted rows) through the save dialog. Returns the path, or null if cancelled. */
export async function exportHistory(
deps: HistoryExportDeps,
summaries: InvoiceSummary[],
filters: HistoryFilters,
format: HistoryFormat,
): Promise<{ path: string; count: number } | null> {
const ledger = await deps.listLedger();
const cells = buildHistoryRows(summaries, ledger);
const text = format === "csv" ? toCsv(cells) : toJson(cells, filters, deps.now().toISOString());
let dir = "";
try {
dir = await deps.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const chosen = await deps.save({ defaultPath: joinPath(dir, historyExportName(filters, summaries, format)), format });
if (!chosen) return null;
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
return { path, count: cells.length };
}
+28
View File
@@ -541,6 +541,26 @@ body {
font-size: 0.75rem;
}
/* ---------- Settings: Data tab ---------- */
.voiced-section-title {
font-size: 1.25rem;
font-weight: 400;
margin-bottom: 0.5rem;
}
.voiced-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.voiced-list {
margin-left: 1.25rem;
list-style: disc;
}
/* ---------- Invoice history and detail ---------- */
.voiced-num {
text-align: right;
@@ -578,6 +598,14 @@ body {
margin-bottom: 1rem;
}
.voiced-history__export {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.voiced-history__row {
cursor: pointer;
}
+2 -3
View File
@@ -39,6 +39,7 @@ import {
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
import DataBackupPanel from "../components/DataBackupPanel";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider";
@@ -513,9 +514,7 @@ export default function AppSettings({
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>Backup and export arrive in the next step.</p>
</section>
<DataBackupPanel />
</TabPanel>
</TabPanels>
</Tabs>
+43
View File
@@ -19,7 +19,9 @@ import {
Tag,
Tile,
} from "@carbon/react";
import { save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { exportHistory, type HistoryFormat } from "../lib/historyExport";
import { formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { statusBadge } from "../lib/payments";
@@ -73,6 +75,7 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
const exp = useInvoiceExport({ settings, bank: null });
const [exportingHistory, setExportingHistory] = useState(false);
const load = async () => {
try {
@@ -123,6 +126,34 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
}
};
/** Exactly the rows the table is filtered to (every page, in the current sort order). */
const onExportHistory = async (format: HistoryFormat) => {
setExportingHistory(true);
try {
const done = await exportHistory(
{
listLedger: api.listInvoiceLedger,
getLastExportDir: api.getLastExportDir,
save: ({ defaultPath, format: fmt }) =>
save({
defaultPath,
filters: [fmt === "csv" ? { name: "CSV (Excel)", extensions: ["csv"] } : { name: "JSON", extensions: ["json"] }],
}),
writeExportFile: api.writeExportFile,
now: () => new Date(),
},
sorted,
filters,
format,
);
if (done) toast.success(`Exported ${done.count} invoices`, done.path);
} catch (e) {
toast.error("Could not export the invoices", String(e));
} finally {
setExportingHistory(false);
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoices</h1>
@@ -216,6 +247,18 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
) : null}
</div>
<div className="voiced-history__export">
<Button kind="tertiary" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("csv")}>
Export CSV
</Button>
<Button kind="ghost" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("json")}>
Export JSON
</Button>
<span className="voiced-muted">
{filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
</span>
</div>
{sorted.length === 0 ? (
<div className="voiced-empty">No invoices match these filters.</div>
) : (