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()`. pub(crate) const LATEST_VERSION: i64 = 9; #[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 ); "#; /// Version 3: the user's default page setup (RenderPrefsV1 JSON; empty means built-in defaults). const M3: &str = r#" ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT ''; "#; /// Version 4: the archived PDF's layout fingerprint and time, and the last export folder. const M4: &str = r#" ALTER TABLE invoices ADD COLUMN archived_fingerprint TEXT; ALTER TABLE invoices ADD COLUMN archived_at TEXT; ALTER TABLE app_settings ADD COLUMN last_export_dir TEXT NOT NULL DEFAULT ''; "#; /// Version 5: logo pipeline outputs. `logo_path` now points at the trimmed print image; the original /// is kept so it can be re-derived. Invoice snapshots issued earlier simply lack the new keys. const M5: &str = r#" ALTER TABLE app_settings ADD COLUMN logo_original_path TEXT; ALTER TABLE app_settings ADD COLUMN logo_knockout_path TEXT; ALTER TABLE app_settings ADD COLUMN logo_meta TEXT NOT NULL DEFAULT ''; ALTER TABLE app_settings ADD COLUMN logo_includes_name TEXT NOT NULL DEFAULT 'auto'; "#; /// Version 6: user-imported fonts for the three licensed template faces. The files live content-addressed under /// `/fonts/.`; invoices freeze the sha256 of the fonts they used in render_prefs. /// `hidden` retires a font that an issued invoice still references (never deleted while referenced). const M6: &str = r#" CREATE TABLE user_fonts ( id INTEGER PRIMARY KEY AUTOINCREMENT, face TEXT NOT NULL CHECK (face IN ('Now', 'Gotham', 'Open Sauce One')), family_name TEXT NOT NULL, full_name TEXT NOT NULL, style_name TEXT NOT NULL DEFAULT '', weight INTEGER NOT NULL CHECK (weight BETWEEN 100 AND 900), style TEXT NOT NULL CHECK (style IN ('normal', 'italic')), sha256 TEXT NOT NULL, file_name TEXT NOT NULL, format TEXT NOT NULL CHECK (format IN ('ttf', 'otf')), size INTEGER NOT NULL, fs_type INTEGER NOT NULL DEFAULT 0, has_rupee INTEGER NOT NULL DEFAULT 0, licence_ack_at TEXT NOT NULL, imported_at TEXT NOT NULL, hidden INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256); -- One visible font per face, weight and style; a hidden one keeps its row but frees the slot. CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0; "#; /// Version 7: structured client addresses, GST category and per-client defaults, plus saved item presets. /// `clients.address` stays the composed multi-line string (issued invoices and the PDF read it); the new /// parts are an editing aid. The address state is `clients.state_code`, which is also the default place /// of supply, so there is no second state column. `payment_terms_days` NULL means "use the settings default". /// `item_presets.rate_paise` is the fixed amount for mode 'fixed' and the per-unit rate for mode 'rate'. const M7: &str = r#" ALTER TABLE clients ADD COLUMN address_line1 TEXT NOT NULL DEFAULT ''; ALTER TABLE clients ADD COLUMN address_line2 TEXT NOT NULL DEFAULT ''; ALTER TABLE clients ADD COLUMN city TEXT NOT NULL DEFAULT ''; ALTER TABLE clients ADD COLUMN pincode TEXT NOT NULL DEFAULT ''; ALTER TABLE clients ADD COLUMN gst_category TEXT NOT NULL DEFAULT 'unregistered' CHECK (gst_category IN ('registered_regular', 'unregistered', 'composition', 'sez', 'overseas')); ALTER TABLE clients ADD COLUMN default_notes TEXT NOT NULL DEFAULT ''; ALTER TABLE clients ADD COLUMN payment_terms_days INTEGER; UPDATE clients SET gst_category = 'registered_regular' WHERE gstin NOT IN ('', 'NA'); CREATE TABLE item_presets ( id INTEGER PRIMARY KEY AUTOINCREMENT, description TEXT NOT NULL, hsn_sac TEXT NOT NULL DEFAULT '', mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')), unit TEXT NOT NULL DEFAULT 'unit' CHECK (unit IN ('second', 'minute', 'hour', 'session', 'unit')), rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0), sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); "#; /// Version 8: payments received against issued invoices. Amounts are integer paise. `amount_paise` is the cash /// received and `tds_paise` the tax the client deducted at source; both count towards settling the invoice. /// Payment status is derived from these rows and the due date, never stored, and the issued invoice row is untouched. const M8: &str = r#" CREATE TABLE payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, invoice_id INTEGER NOT NULL REFERENCES invoices(id), paid_on TEXT NOT NULL, amount_paise INTEGER NOT NULL DEFAULT 0 CHECK (amount_paise >= 0), tds_paise INTEGER NOT NULL DEFAULT 0 CHECK (tds_paise >= 0), mode TEXT NOT NULL CHECK (mode IN ('bank_transfer', 'upi', 'cash', 'cheque', 'other')), reference TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, CHECK (amount_paise + tds_paise > 0) ); 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), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7), M::up(M8), M::up(M9), ]) } /// 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) } /// 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 { 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")); 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) } /// 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(()) } /// A migrated, seeded in-memory database for unit tests of the command logic. #[cfg(test)] pub fn open_in_memory() -> Result { let mut conn = Connection::open_in_memory()?; conn.pragma_update(None, "foreign_keys", "ON")?; migrations().to_latest(&mut conn)?; seed(&conn)?; Ok(conn) } /// `/assets/x.png` -> `assets/x.png`; anything else is returned unchanged. /// Legacy databases stored absolute paths, which break as soon as the data dir moves. fn relativize(path: &str, data_dir: &Path) -> String { match Path::new(path).strip_prefix(data_dir.join("assets")) { Ok(rest) if !rest.as_os_str().is_empty() => { let mut out = String::from("assets"); for part in rest.components() { out.push('/'); out.push_str(&part.as_os_str().to_string_lossy()); } out } _ => path.to_string(), } } /// Rewrite absolute asset paths under `/assets/` to relative ones in /// settings, invoices and the invoice vendor snapshots. Safe to run on every start. pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::Result<()> { let tx = conn.unchecked_transaction()?; for (table, column, pk) in [ ("app_settings", "logo_path", "id"), ("app_settings", "signature_path", "id"), ("invoices", "signature_path", "id"), ] { let rows: Vec<(i64, String)> = { let mut stmt = tx.prepare(&format!( "SELECT {pk}, {column} FROM {table} WHERE {column} IS NOT NULL" ))?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; rows.collect::>()? }; for (id, path) in rows { let rel = relativize(&path, data_dir); if rel != path { tx.execute( &format!("UPDATE {table} SET {column} = ?1 WHERE {pk} = ?2"), params![rel, id], )?; } } } let snapshots: Vec<(i64, String)> = { let mut stmt = tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; rows.collect::>()? }; for (id, text) in snapshots { let Ok(mut json) = serde_json::from_str::(&text) else { continue; }; let mut changed = false; for key in ["logoPath", "signaturePath"] { if let Some(path) = json.get(key).and_then(|v| v.as_str()) { let rel = relativize(path, data_dir); if rel != path { json[key] = serde_json::Value::String(rel); changed = true; } } } if changed { tx.execute( "UPDATE invoices SET vendor_snapshot = ?1 WHERE id = ?2", params![json.to_string(), id], )?; } } tx.commit() } /// 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()); } #[test] fn m4_adds_archive_and_export_dir_columns() { let conn = open_in_memory().unwrap(); let dir: String = conn .query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0)) .unwrap(); assert_eq!(dir, ""); // The columns exist and are nullable on invoices (no rows needed to prove it). conn.prepare("SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices") .unwrap(); let notnull: i64 = conn .query_row( "SELECT SUM(\"notnull\") FROM pragma_table_info('invoices') WHERE name IN ('archived_fingerprint', 'archived_at')", [], |r| r.get(0), ) .unwrap(); assert_eq!(notnull, 0); } #[test] fn render_prefs_column_defaults_to_empty_and_round_trips() { let conn = open_in_memory().unwrap(); let initial: String = conn .query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) .unwrap(); assert_eq!(initial, ""); let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#; conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap(); let stored: String = conn .query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0)) .unwrap(); assert_eq!(stored, json); } #[test] fn logo_columns_default_and_existing_logo_is_untouched() { let conn = open_in_memory().unwrap(); let row: (Option, Option, String, String) = conn .query_row( "SELECT logo_original_path, logo_knockout_path, logo_meta, logo_includes_name FROM app_settings WHERE id = 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), ) .unwrap(); assert_eq!(row, (None, None, String::new(), "auto".to_string())); // A database at v4 with a logo keeps it; only the new columns appear. let dir = tempdir().unwrap(); let path = dir.path().join("voiced.db"); { let mut old = Connection::open(&path).unwrap(); Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)]) .to_latest(&mut old) .unwrap(); seed(&old).unwrap(); old.execute("UPDATE app_settings SET logo_path = 'assets/logo-old.png'", []).unwrap(); assert_eq!(user_version(&old), 4); } let conn = open(&path, &dir.path().join("backups")).unwrap(); assert_eq!(user_version(&conn), LATEST_VERSION); let (logo, meta): (String, String) = conn .query_row("SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| { Ok((r.get(0)?, r.get(1)?)) }) .unwrap(); assert_eq!((logo.as_str(), meta.as_str()), ("assets/logo-old.png", "")); assert_eq!(backups_in(&dir.path().join("backups")).len(), 1); } #[test] fn m7_fresh_database_has_client_and_preset_columns() { let conn = open_in_memory().unwrap(); conn.prepare( "SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients", ) .unwrap(); conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets") .unwrap(); conn.execute( "INSERT INTO clients (name, created_at) VALUES ('X', 'now')", [], ) .unwrap(); let (cat, terms): (String, Option) = conn .query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?))) .unwrap(); assert_eq!((cat.as_str(), terms), ("unregistered", None)); // The category CHECK rejects unknown values. assert!(conn .execute("UPDATE clients SET gst_category = 'bogus'", []) .is_err()); } #[test] fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() { let mut conn = Connection::open_in_memory().unwrap(); let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)]; Migrations::new(all).to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), 6); conn.execute( "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at) VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'), ('Plain', '', '', '', '', 'now'), ('Na', '', 'NA', '', '', 'now')", [], ) .unwrap(); migrations().to_latest(&mut conn).unwrap(); assert_eq!(user_version(&conn), LATEST_VERSION); type Row = (String, String, String, String, String, String, String); let rows: Vec = conn .prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients ORDER BY id") .unwrap() .query_map([], |r| { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)) }) .unwrap() .collect::>() .unwrap(); assert_eq!(rows[0].1, "Line one\nLine two"); assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1")); assert_eq!(rows[0].5, "registered_regular"); assert_eq!(rows[0].6, ""); assert_eq!(rows[1].5, "unregistered"); assert_eq!(rows[2].5, "unregistered"); } 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()); } #[test] fn asset_paths_become_relative_and_the_rewrite_is_idempotent() { let dir = tempdir().unwrap(); let data = dir.path(); let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap(); let abs_logo = data.join("assets").join("logo-1.png").to_string_lossy().into_owned(); let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned(); let foreign = "/somewhere/else/logo.png"; conn.execute( "UPDATE app_settings SET logo_path = ?1, signature_path = ?2", params![abs_logo, foreign], ) .unwrap(); let snapshot = serde_json::json!({ "vendorName": "Acme", "logoPath": abs_logo, "signaturePath": abs_sig, }) .to_string(); conn.execute( "INSERT INTO invoices (number, invoice_date, signature_path, vendor_snapshot, created_at, updated_at) VALUES ('A-1', '2026-01-01', ?1, ?2, 'now', 'now')", params![abs_sig, snapshot], ) .unwrap(); conn.execute( "INSERT INTO invoices (number, invoice_date, vendor_snapshot, created_at, updated_at) VALUES ('A-2', '2026-01-01', 'not json', 'now', 'now')", [], ) .unwrap(); for _ in 0..2 { relativize_asset_paths(&conn, data).unwrap(); let (logo, sig): (String, String) = conn .query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| { Ok((r.get(0)?, r.get(1)?)) }) .unwrap(); assert_eq!(logo, "assets/logo-1.png"); assert_eq!(sig, foreign); let (inv_sig, snap): (String, String) = conn .query_row( "SELECT signature_path, vendor_snapshot FROM invoices WHERE number = 'A-1'", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(inv_sig, "assets/signature-2.jpg"); let json: serde_json::Value = serde_json::from_str(&snap).unwrap(); assert_eq!(json["logoPath"], "assets/logo-1.png"); assert_eq!(json["signaturePath"], "assets/signature-2.jpg"); assert_eq!(json["vendorName"], "Acme"); let bad: String = conn .query_row("SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", [], |r| r.get(0)) .unwrap(); assert_eq!(bad, "not json"); } } }