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
+20
View File
@@ -4188,6 +4188,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4307,6 +4313,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"uuid",
"zip",
]
[[package]]
@@ -5182,6 +5189,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.14.2",
"memchr",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
+1
View File
@@ -28,6 +28,7 @@ thiserror = "2"
base64 = "0.22"
sha2 = "0.10"
flate2 = "1"
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
percent-encoding = "2"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
File diff suppressed because it is too large Load Diff
+99
View File
@@ -546,6 +546,83 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
Ok(rows)
}
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceLedgerRow {
pub id: i64,
pub client_gstin: String,
pub place_of_supply_code: String,
pub doc_type: String,
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
pub hsn_sac: Vec<String>,
/// Subtotal less discount.
pub taxable_paise: i64,
pub cgst_paise: i64,
pub sgst_paise: i64,
pub igst_paise: i64,
pub reverse_charge: bool,
}
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
{
let mut stmt = conn
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
.map_err(|e| e.to_string())?;
let items = stmt
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?;
for item in items {
let (id, code) = item.map_err(|e| e.to_string())?;
let list = hsn_by_invoice.entry(id).or_default();
if !code.is_empty() && !list.contains(&code) {
list.push(code);
}
}
}
let mut stmt = conn
.prepare(
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
cgst_amount, sgst_amount, igst_amount, reverse_charge
FROM invoices ORDER BY id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
let id: i64 = r.get(0)?;
Ok(InvoiceLedgerRow {
id,
client_gstin: r.get(1)?,
place_of_supply_code: r.get(2)?,
doc_type: r.get(3)?,
hsn_sac: Vec::new(),
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
cgst_paise: gst::rupees_to_paise(r.get(6)?),
sgst_paise: gst::rupees_to_paise(r.get(7)?),
igst_paise: gst::rupees_to_paise(r.get(8)?),
reverse_charge: r.get::<_, i64>(9)? != 0,
})
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|mut row| {
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
row
})
.collect())
}
#[tauri::command]
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_invoice_ledger_impl(&conn)
}
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
fetch_invoice(conn, id).map_err(|e| e.to_string())
}
@@ -696,6 +773,28 @@ mod tests {
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
}
#[test]
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
let mut conn = registered();
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
params![first.id],
)
.unwrap();
let ledger = list_invoice_ledger_impl(&conn).unwrap();
assert_eq!(ledger.len(), 1);
let row = &ledger[0];
assert_eq!(row.id, first.id);
assert_eq!(row.place_of_supply_code, "27");
assert_eq!(row.doc_type, "tax_invoice");
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
assert_eq!(row.taxable_paise, 731_000);
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
assert!(!row.reverse_charge);
}
#[test]
fn issues_numbered_tax_invoices_with_derived_totals() {
let mut conn = registered();
+1
View File
@@ -1,5 +1,6 @@
pub mod archive;
pub mod assets;
pub mod backup;
pub mod clients;
pub mod files;
pub mod fonts;
+17 -4
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
const LATEST_VERSION: i64 = 8;
pub(crate) const LATEST_VERSION: i64 = 9;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -264,6 +264,12 @@ CREATE TABLE payments (
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
"#;
/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
const M9: &str = r#"
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![
M::up(SCHEMA),
@@ -274,6 +280,7 @@ fn migrations() -> Migrations<'static> {
M::up(M6),
M::up(M7),
M::up(M8),
M::up(M9),
])
}
@@ -294,7 +301,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
Ok(conn)
}
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
let quoted = target.to_string_lossy().replace('\'', "''");
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
}
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
conn.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
[],
@@ -312,8 +326,7 @@ fn backup_before_migration(
fs::create_dir_all(backup_dir)?;
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
let quoted = target.to_string_lossy().replace('\'', "''");
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
vacuum_into(conn, &target)?;
// A failed prune must not block startup; the backup itself already succeeded.
let _ = prune_backups(backup_dir, MAX_BACKUPS);
Ok(target)
+20
View File
@@ -56,6 +56,15 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
return Err(e.into());
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => {
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
}
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
}
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
Ok(conn) => conn,
Err(e) => {
@@ -71,6 +80,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
eprintln!("Could not derive the logo images: {e}");
}
commands::backup::spawn_auto_backup(dirs);
Ok(AppState {
db: Mutex::new(conn),
data_dir,
@@ -80,6 +90,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
commands::backup::wait_if_relaunched();
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
// combinations. Respect an explicit user setting, otherwise turn it off.
#[cfg(target_os = "linux")]
@@ -136,6 +149,13 @@ pub fn run() {
commands::payments::record_payment,
commands::payments::list_payments,
commands::payments::delete_payment,
commands::backup::create_backup,
commands::backup::restore_backup,
commands::backup::cancel_pending_restore,
commands::backup::get_backup_status,
commands::backup::set_auto_backup,
commands::backup::restart_app,
commands::invoice::list_invoice_ledger,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri,
+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>
) : (