use rusqlite::{params, Connection}; use rusqlite_migration::{Migrations, M}; use std::fs; use std::path::{Path, PathBuf}; /// Number of pre-migration backups kept in the backup directory. const MAX_BACKUPS: usize = 10; /// Prefix shared by every pre-migration backup file name. const BACKUP_PREFIX: &str = "voiced-pre-v"; /// Highest schema version, i.e. the number of entries in `migrations()`. const LATEST_VERSION: i64 = 2; #[derive(Debug, thiserror::Error)] pub enum DbError { #[error("database error: {0}")] Sqlite(#[from] rusqlite::Error), #[error("database migration failed: {0}")] Migration(#[from] rusqlite_migration::Error), #[error("file system error: {0}")] Io(#[from] std::io::Error), } /// Version 1: the original schema. Every statement is idempotent, so databases /// created before migrations existed (user_version 0, all tables present) adopt it safely. const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS app_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), vendor_name TEXT NOT NULL DEFAULT '', vendor_address TEXT NOT NULL DEFAULT '', vendor_email TEXT NOT NULL DEFAULT '', vendor_phone TEXT NOT NULL DEFAULT '', vendor_pan TEXT NOT NULL DEFAULT '', vendor_gstin TEXT NOT NULL DEFAULT '', vendor_state_code TEXT NOT NULL DEFAULT '29', logo_path TEXT, signature_path TEXT, default_bank_id INTEGER, default_tax_rate REAL NOT NULL DEFAULT 18, default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst', payment_terms_days INTEGER NOT NULL DEFAULT 30, currency TEXT NOT NULL DEFAULT 'INR', onboarded INTEGER NOT NULL DEFAULT 0, theme TEXT NOT NULL DEFAULT 'g10' ); CREATE TABLE IF NOT EXISTS bank_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL DEFAULT '', bank_name TEXT NOT NULL DEFAULT '', account_name TEXT NOT NULL DEFAULT '', account_no TEXT NOT NULL DEFAULT '', branch TEXT NOT NULL DEFAULT '', ifsc TEXT NOT NULL DEFAULT '', is_default INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS invoice_series ( id INTEGER PRIMARY KEY AUTOINCREMENT, prefix TEXT NOT NULL, padding INTEGER NOT NULL DEFAULT 3, next_number INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', gstin TEXT NOT NULL DEFAULT '', state_code TEXT NOT NULL DEFAULT '', po_number TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS invoices ( id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT NOT NULL UNIQUE, series_id INTEGER, invoice_date TEXT NOT NULL, due_date TEXT NOT NULL DEFAULT '', client_id INTEGER, client_name TEXT NOT NULL DEFAULT '', client_address TEXT NOT NULL DEFAULT '', client_gstin TEXT NOT NULL DEFAULT '', po_number TEXT NOT NULL DEFAULT '', place_of_supply_state_code TEXT NOT NULL DEFAULT '', subtotal REAL NOT NULL DEFAULT 0, discount REAL NOT NULL DEFAULT 0, tax_type TEXT NOT NULL DEFAULT 'none', tax_rate REAL NOT NULL DEFAULT 0, cgst_amount REAL NOT NULL DEFAULT 0, sgst_amount REAL NOT NULL DEFAULT 0, igst_amount REAL NOT NULL DEFAULT 0, total REAL NOT NULL DEFAULT 0, amount_in_words TEXT NOT NULL DEFAULT '', bank_account_id INTEGER, bank_snapshot TEXT NOT NULL DEFAULT '', signature_path TEXT, notes TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'issued', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS invoice_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, description TEXT NOT NULL DEFAULT '', mode TEXT NOT NULL DEFAULT 'fixed', rate REAL NOT NULL DEFAULT 0, unit TEXT NOT NULL DEFAULT 'unit', quantity REAL NOT NULL DEFAULT 1, amount REAL NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id); CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); "#; /// Version 2: GST registration, invoice snapshots/cancellation fields, HSN/SAC and drafts. const M2: &str = r#" ALTER TABLE app_settings ADD COLUMN gst_registration TEXT NOT NULL DEFAULT 'unregistered'; ALTER TABLE app_settings ADD COLUMN default_hsn_sac TEXT NOT NULL DEFAULT ''; ALTER TABLE app_settings ADD COLUMN signatory_name TEXT NOT NULL DEFAULT ''; ALTER TABLE app_settings ADD COLUMN signatory_designation TEXT NOT NULL DEFAULT ''; UPDATE app_settings SET gst_registration = 'regular' WHERE vendor_gstin <> ''; -- An unregistered supplier may not charge GST, so new invoices default to no tax. UPDATE app_settings SET default_tax_type = 'none' WHERE gst_registration = 'unregistered'; ALTER TABLE invoices ADD COLUMN doc_type TEXT NOT NULL DEFAULT 'invoice'; ALTER TABLE invoices ADD COLUMN reverse_charge INTEGER NOT NULL DEFAULT 0; ALTER TABLE invoices ADD COLUMN vendor_snapshot TEXT; ALTER TABLE invoices ADD COLUMN render_prefs TEXT; ALTER TABLE invoices ADD COLUMN snapshot_origin TEXT NOT NULL DEFAULT 'issued'; ALTER TABLE invoices ADD COLUMN cancelled_at TEXT; ALTER TABLE invoices ADD COLUMN cancel_reason TEXT NOT NULL DEFAULT ''; ALTER TABLE invoices ADD COLUMN archived_pdf_sha256 TEXT; UPDATE invoices SET vendor_snapshot = ( SELECT json_object( 'vendorName', vendor_name, 'vendorAddress', vendor_address, 'vendorEmail', vendor_email, 'vendorPhone', vendor_phone, 'vendorPan', vendor_pan, 'vendorGstin', vendor_gstin, 'vendorStateCode', vendor_state_code, 'logoPath', logo_path, 'signaturePath', signature_path, 'gstRegistration', gst_registration, 'signatoryName', signatory_name, 'signatoryDesignation', signatory_designation ) FROM app_settings WHERE id = 1 ), snapshot_origin = 'backfilled', doc_type = CASE WHEN tax_type <> 'none' THEN 'tax_invoice' ELSE 'invoice' END; ALTER TABLE invoice_items ADD COLUMN hsn_sac TEXT NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS invoice_drafts ( id INTEGER PRIMARY KEY AUTOINCREMENT, payload TEXT NOT NULL, updated_at TEXT NOT NULL ); "#; fn migrations() -> Migrations<'static> { Migrations::new(vec![M::up(SCHEMA), M::up(M2)]) } /// Open (creating if needed) the database at `path` and bring it to the latest schema. /// Existing databases are backed up into `backup_dir` before any migration runs. pub fn open(path: &Path, backup_dir: &Path) -> Result { let mut conn = Connection::open(path)?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; if version < LATEST_VERSION && has_user_tables(&conn)? { backup_before_migration(&conn, backup_dir, version)?; } migrations().to_latest(&mut conn)?; seed(&conn)?; Ok(conn) } fn has_user_tables(conn: &Connection) -> rusqlite::Result { conn.query_row( "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')", [], |r| r.get(0), ) } /// Copy the database to `/voiced-pre-v{version}-{YYYYmmdd-HHMMSS}.db` /// and drop all but the newest `MAX_BACKUPS` backups. fn backup_before_migration( conn: &Connection, backup_dir: &Path, version: i64, ) -> Result { 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}'"))?; // A failed prune must not block startup; the backup itself already succeeded. let _ = prune_backups(backup_dir, MAX_BACKUPS); Ok(target) } /// Keep only the `keep` newest pre-migration backups, ordered by the timestamp /// embedded in the file name (the schema version prefix is ignored). fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> { let mut backups: Vec<(String, PathBuf)> = Vec::new(); for entry in fs::read_dir(backup_dir)? { let path = entry?.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; if let Some(stem) = name .strip_prefix(BACKUP_PREFIX) .and_then(|n| n.strip_suffix(".db")) { // "{version}-{YYYYmmdd}-{HHMMSS}" -> "{YYYYmmdd}-{HHMMSS}" let stamp = stem.split_once('-').map_or(stem, |(_, t)| t); backups.push((stamp.to_string(), path)); } } backups.sort(); let excess = backups.len().saturating_sub(keep); for (_, path) in backups.into_iter().take(excess) { fs::remove_file(path)?; } Ok(()) } /// Insert the first-run defaults the very first time the app is opened. /// Vendor details are pre-filled from the owner's existing invoice. The vendor /// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax). fn seed(conn: &Connection) -> rusqlite::Result<()> { let settings_count: i64 = conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?; if settings_count == 0 { conn.execute( r#"INSERT INTO app_settings (id, vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate, default_tax_type, payment_terms_days, currency, onboarded, theme) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#, params![ "Arun P", "13-B type House, Robertson Road, Frazer Town, Bangalore - 560005", "arunakira113@gmail.com", "9738284459", "CPOPP7388K", "", ], )?; } let bank_count: i64 = conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?; if bank_count == 0 { conn.execute( r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#, params![ "Primary", "State Bank of India", "Arun P", "54058708786", "017060", "SBIN0017060", ], )?; let bank_id = conn.last_insert_rowid(); conn.execute( "UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![bank_id], )?; } let series_count: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_series", [], |r| r.get(0))?; if series_count == 0 { let now = chrono::Utc::now().to_rfc3339(); let year = chrono::Local::now().format("%Y").to_string(); conn.execute( r#"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at) VALUES (?1, 3, 1, 1, ?2)"#, params![format!("AP/{}-", year), now], )?; } Ok(()) } /// Format an invoice number from a series definition, e.g. "AP/2026-" + 1 -> "AP/2026-001". pub fn format_number(prefix: &str, padding: i64, number: i64) -> String { format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; /// The schema as it shipped before migrations existed (user_version 0). const V1_SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS app_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), vendor_name TEXT NOT NULL DEFAULT '', vendor_address TEXT NOT NULL DEFAULT '', vendor_email TEXT NOT NULL DEFAULT '', vendor_phone TEXT NOT NULL DEFAULT '', vendor_pan TEXT NOT NULL DEFAULT '', vendor_gstin TEXT NOT NULL DEFAULT '', vendor_state_code TEXT NOT NULL DEFAULT '29', logo_path TEXT, signature_path TEXT, default_bank_id INTEGER, default_tax_rate REAL NOT NULL DEFAULT 18, default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst', payment_terms_days INTEGER NOT NULL DEFAULT 30, currency TEXT NOT NULL DEFAULT 'INR', onboarded INTEGER NOT NULL DEFAULT 0, theme TEXT NOT NULL DEFAULT 'g10' ); CREATE TABLE IF NOT EXISTS bank_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL DEFAULT '', bank_name TEXT NOT NULL DEFAULT '', account_name TEXT NOT NULL DEFAULT '', account_no TEXT NOT NULL DEFAULT '', branch TEXT NOT NULL DEFAULT '', ifsc TEXT NOT NULL DEFAULT '', is_default INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS invoice_series ( id INTEGER PRIMARY KEY AUTOINCREMENT, prefix TEXT NOT NULL, padding INTEGER NOT NULL DEFAULT 3, next_number INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', gstin TEXT NOT NULL DEFAULT '', state_code TEXT NOT NULL DEFAULT '', po_number TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS invoices ( id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT NOT NULL UNIQUE, series_id INTEGER, invoice_date TEXT NOT NULL, due_date TEXT NOT NULL DEFAULT '', client_id INTEGER, client_name TEXT NOT NULL DEFAULT '', client_address TEXT NOT NULL DEFAULT '', client_gstin TEXT NOT NULL DEFAULT '', po_number TEXT NOT NULL DEFAULT '', place_of_supply_state_code TEXT NOT NULL DEFAULT '', subtotal REAL NOT NULL DEFAULT 0, discount REAL NOT NULL DEFAULT 0, tax_type TEXT NOT NULL DEFAULT 'none', tax_rate REAL NOT NULL DEFAULT 0, cgst_amount REAL NOT NULL DEFAULT 0, sgst_amount REAL NOT NULL DEFAULT 0, igst_amount REAL NOT NULL DEFAULT 0, total REAL NOT NULL DEFAULT 0, amount_in_words TEXT NOT NULL DEFAULT '', bank_account_id INTEGER, bank_snapshot TEXT NOT NULL DEFAULT '', signature_path TEXT, notes TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'issued', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS invoice_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, description TEXT NOT NULL DEFAULT '', mode TEXT NOT NULL DEFAULT 'fixed', rate REAL NOT NULL DEFAULT 0, unit TEXT NOT NULL DEFAULT 'unit', quantity REAL NOT NULL DEFAULT 1, amount REAL NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id); CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); "#; fn user_version(conn: &Connection) -> i64 { conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap() } fn backups_in(dir: &Path) -> Vec { match fs::read_dir(dir) { Ok(rd) => rd.map(|e| e.unwrap().path()).collect(), Err(_) => Vec::new(), } } #[test] fn migrations_are_valid_and_latest_matches() { migrations().validate().unwrap(); let mut conn = Connection::open_in_memory().unwrap(); migrations().to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), LATEST_VERSION); } #[test] fn fresh_database_is_seeded_without_backup() { let dir = tempdir().unwrap(); let backups = dir.path().join("backups"); let conn = open(&dir.path().join("voiced.db"), &backups).unwrap(); assert_eq!(user_version(&conn), LATEST_VERSION); let (tax_type, gst_reg, name): (String, String, String) = conn .query_row( "SELECT default_tax_type, gst_registration, vendor_name FROM app_settings WHERE id = 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .unwrap(); assert_eq!(tax_type, "none"); assert_eq!(gst_reg, "unregistered"); assert_eq!(name, "Arun P"); assert!(backups_in(&backups).is_empty()); } fn build_v1_database(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap(); conn.execute( "INSERT INTO app_settings (id, vendor_name, vendor_gstin, default_tax_type, onboarded) VALUES (1, 'Acme Studio', '29ABCDE1234F1Z5', 'cgst_sgst', 1)", [], ) .unwrap(); conn.execute( "INSERT INTO bank_accounts (label, bank_name, account_no, is_default) VALUES ('Primary', 'Test Bank', '12345', 1)", [], ) .unwrap(); conn.execute( "INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at) VALUES ('AC/2026-', 3, 2, 1, '2026-01-01T00:00:00Z')", [], ) .unwrap(); conn.execute( "INSERT INTO invoices (number, invoice_date, tax_type, tax_rate, total, created_at, updated_at) VALUES ('AC/2026-001', '2026-01-02', 'cgst_sgst', 18, 118, 'now', 'now')", [], ) .unwrap(); conn.execute( "INSERT INTO invoice_items (invoice_id, description, rate, quantity, amount) VALUES (1, 'Design work', 100, 1, 100)", [], ) .unwrap(); assert_eq!(user_version(&conn), 0); } #[test] fn v1_database_upgrades_with_backup_and_backfill() { let dir = tempdir().unwrap(); let db_path = dir.path().join("voiced.db"); let backups = dir.path().join("backups"); build_v1_database(&db_path); let conn = open(&db_path, &backups).unwrap(); assert_eq!(user_version(&conn), LATEST_VERSION); // Backup exists and is a valid, un-migrated copy. let files = backups_in(&backups); assert_eq!(files.len(), 1); let name = files[0].file_name().unwrap().to_str().unwrap(); assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}"); let backup = Connection::open(&files[0]).unwrap(); assert_eq!(user_version(&backup), 0); let n: i64 = backup .query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)) .unwrap(); assert_eq!(n, 1); // Settings backfill. let (gst_reg, tax_type, vendor): (String, String, String) = conn .query_row( "SELECT gst_registration, default_tax_type, vendor_name FROM app_settings WHERE id = 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .unwrap(); assert_eq!(gst_reg, "regular"); assert_eq!(tax_type, "cgst_sgst"); assert_eq!(vendor, "Acme Studio"); // Invoice backfill. let (origin, snapshot, doc_type, number, total): (String, Option, String, String, f64) = conn .query_row( "SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), ) .unwrap(); assert_eq!(origin, "backfilled"); assert_eq!(doc_type, "tax_invoice"); assert_eq!(number, "AC/2026-001"); assert_eq!(total, 118.0); let json: serde_json::Value = serde_json::from_str(&snapshot.expect("snapshot")).unwrap(); assert_eq!(json["vendorName"], "Acme Studio"); assert_eq!(json["vendorGstin"], "29ABCDE1234F1Z5"); assert_eq!(json["gstRegistration"], "regular"); // Old data intact, new columns defaulted. let (desc, hsn): (String, String) = conn .query_row("SELECT description, hsn_sac FROM invoice_items", [], |r| { Ok((r.get(0)?, r.get(1)?)) }) .unwrap(); assert_eq!(desc, "Design work"); assert_eq!(hsn, ""); let (bank, next): (String, i64) = conn .query_row( "SELECT (SELECT bank_name FROM bank_accounts), (SELECT next_number FROM invoice_series)", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(bank, "Test Bank"); assert_eq!(next, 2); } #[test] fn reopening_up_to_date_database_makes_no_backup() { let dir = tempdir().unwrap(); let db_path = dir.path().join("voiced.db"); let backups = dir.path().join("backups"); build_v1_database(&db_path); drop(open(&db_path, &backups).unwrap()); assert_eq!(backups_in(&backups).len(), 1); drop(open(&db_path, &backups).unwrap()); assert_eq!(backups_in(&backups).len(), 1); } #[test] fn pruning_keeps_the_newest_ten() { let dir = tempdir().unwrap(); for i in 0..14 { // Mixed version prefixes: ordering must follow the timestamp only. let version = if i % 2 == 0 { 2 } else { 10 }; let name = format!("{BACKUP_PREFIX}{version}-202601{:02}-120000.db", i + 1); fs::write(dir.path().join(name), b"x").unwrap(); } fs::write(dir.path().join("unrelated.db"), b"x").unwrap(); prune_backups(dir.path(), MAX_BACKUPS).unwrap(); let mut names: Vec = backups_in(dir.path()) .iter() .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) .filter(|n| n.starts_with(BACKUP_PREFIX)) .collect(); names.sort(); assert_eq!(names.len(), MAX_BACKUPS); assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104"))); assert!(dir.path().join("unrelated.db").exists()); } }