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:
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,5 +1,6 @@
|
||||
pub mod archive;
|
||||
pub mod assets;
|
||||
pub mod backup;
|
||||
pub mod clients;
|
||||
pub mod files;
|
||||
pub mod fonts;
|
||||
|
||||
+17
-4
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user