rusqlite_migration with a VACUUM INTO backup before upgrading; M2 adds GST registration, snapshot, cancellation and draft columns and backfills snapshots for existing invoices. Startup failures now show a native dialog instead of exiting silently. Adds single-instance and window-state plugins, minimum window 960x600, embedBootstrapper, DMABUF workaround on Linux and xbps dependency updates.
597 lines
23 KiB
Rust
597 lines
23 KiB
Rust
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<Connection, DbError> {
|
|
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<bool> {
|
|
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 `<backup_dir>/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<PathBuf, DbError> {
|
|
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<PathBuf> {
|
|
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, 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<String> = 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());
|
|
}
|
|
}
|