Add versioned DB migrations, startup error dialog and native basics
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.
This commit is contained in:
Generated
+46
-1
@@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
@@ -2887,6 +2887,16 @@ dependencies = [
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite_migration"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03451d4dc523c0a96d9e412c574670b3f5f184c3988b77a435876be25477bbd3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rusqlite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3658,6 +3668,37 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.12.1"
|
||||
@@ -4196,12 +4237,16 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"rusqlite",
|
||||
"rusqlite_migration",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
+10
-1
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||
authors = ["Bongbetic"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
rust-version = "1.95"
|
||||
|
||||
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
|
||||
[lib]
|
||||
@@ -21,11 +21,20 @@ tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
rusqlite_migration = "2.6"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
base64 = "0.22"
|
||||
|
||||
# Desktop-only plugins (not available on mobile targets).
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
# Production builds embed the frontend and serve it over the custom asset
|
||||
# protocol. Without this, the app expects a dev server on localhost:1420.
|
||||
|
||||
@@ -6,6 +6,25 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"window-state:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{ "path": "$DOCUMENT/**" },
|
||||
{ "path": "$DOWNLOAD/**" },
|
||||
{ "path": "$DESKTOP/**" },
|
||||
{ "path": "$HOME/**" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "opener:allow-reveal-item-in-dir",
|
||||
"allow": [
|
||||
{ "path": "$DOCUMENT/**" },
|
||||
{ "path": "$DOWNLOAD/**" },
|
||||
{ "path": "$DESKTOP/**" },
|
||||
{ "path": "$HOME/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ use tauri::State;
|
||||
|
||||
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
||||
default_tax_type, payment_terms_days, currency, onboarded, theme";
|
||||
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
||||
signatory_designation";
|
||||
|
||||
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||
Ok(Settings {
|
||||
@@ -25,6 +26,10 @@ fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||
currency: row.get(13)?,
|
||||
onboarded: row.get::<_, i64>(14)? != 0,
|
||||
theme: row.get(15)?,
|
||||
gst_registration: row.get(16)?,
|
||||
default_hsn_sac: row.get(17)?,
|
||||
signatory_name: row.get(18)?,
|
||||
signatory_designation: row.get(19)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,7 +53,8 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
||||
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
|
||||
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||
onboarded = ?15, theme = ?16
|
||||
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
||||
signatory_name = ?19, signatory_designation = ?20
|
||||
WHERE id = 1"#,
|
||||
params![
|
||||
settings.vendor_name,
|
||||
@@ -67,6 +73,10 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
||||
settings.currency,
|
||||
if settings.onboarded { 1 } else { 0 },
|
||||
settings.theme,
|
||||
settings.gst_registration,
|
||||
settings.default_hsn_sac,
|
||||
settings.signatory_name,
|
||||
settings.signatory_designation,
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
+428
-7
@@ -1,7 +1,29 @@
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::Path;
|
||||
use rusqlite_migration::{Migrations, M};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The full schema. Every statement is idempotent so it doubles as the migration step.
|
||||
/// 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),
|
||||
@@ -99,17 +121,129 @@ 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);
|
||||
"#;
|
||||
|
||||
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
/// 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")?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
|
||||
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.
|
||||
/// 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))?;
|
||||
@@ -119,7 +253,7 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
(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, 'cgst_sgst', 30, 'INR', 0, 'g10')"#,
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
||||
params![
|
||||
"Test Vendor",
|
||||
"1 Test Road, Bangalore - 560001",
|
||||
@@ -173,3 +307,290 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
||||
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, "Test Vendor");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+71
-15
@@ -3,32 +3,84 @@ mod db;
|
||||
mod models;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Connection>,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Show a native error dialog. Release builds have no console on Windows, so
|
||||
/// without this a startup failure would look like the app silently not opening.
|
||||
fn show_startup_error(app: &tauri::App, error: &dyn std::fmt::Display, data_dir: Option<&Path>) {
|
||||
let location = data_dir
|
||||
.map(|d| format!("\n\nData directory:\n{}", d.display()))
|
||||
.unwrap_or_default();
|
||||
eprintln!("Voiced could not start: {error}");
|
||||
app.dialog()
|
||||
.message(format!("{error}{location}"))
|
||||
.title("Voiced could not start")
|
||||
.kind(MessageDialogKind::Error)
|
||||
.blocking_show();
|
||||
}
|
||||
|
||||
fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>> {
|
||||
let data_dir = match app.path().app_data_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::create_dir_all(&data_dir) {
|
||||
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
|
||||
return Err(e.into());
|
||||
}
|
||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir));
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
Ok(AppState {
|
||||
db: Mutex::new(conn),
|
||||
data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
// 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")]
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
// The single-instance plugin must be registered before every other plugin.
|
||||
#[cfg(desktop)]
|
||||
let builder = builder
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
|
||||
let result = builder
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.setup(|app| {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("could not resolve the application data directory");
|
||||
std::fs::create_dir_all(&data_dir).expect("could not create the application data directory");
|
||||
|
||||
let conn = db::open(&data_dir.join("voiced.db")).expect("could not open the database");
|
||||
app.manage(AppState {
|
||||
db: Mutex::new(conn),
|
||||
data_dir,
|
||||
});
|
||||
let state = init_state(app)?;
|
||||
app.manage(state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -54,6 +106,10 @@ pub fn run() {
|
||||
commands::assets::remove_asset,
|
||||
commands::assets::save_binary_file,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the Voiced application");
|
||||
.run(tauri::generate_context!());
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("error while running the Voiced application: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ pub struct Settings {
|
||||
pub currency: String,
|
||||
pub onboarded: bool,
|
||||
pub theme: String,
|
||||
pub gst_registration: String,
|
||||
pub default_hsn_sac: String,
|
||||
pub signatory_name: String,
|
||||
pub signatory_designation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"title": "Voiced",
|
||||
"width": 1360,
|
||||
"height": 900,
|
||||
"minWidth": 1024,
|
||||
"minHeight": 720,
|
||||
"minWidth": 960,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
"type": "embedBootstrapper"
|
||||
},
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
|
||||
Reference in New Issue
Block a user