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 1623b879e2
commit 92f952b136
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,