The new CI runs cargo fmt --check. QuantityInput also reported its validity only when it changed, so removing a line whose quantity box held bad text left that box counted as invalid and blocked issuing; it now reports valid when it unmounts, and no longer writes a ref during render. Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
1415 lines
57 KiB
Rust
1415 lines
57 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()`.
|
|
pub(crate) const LATEST_VERSION: i64 = 12;
|
|
|
|
#[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
|
|
/// `<local data dir>/fonts/<sha256>.<ttf|otf>`; 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;
|
|
"#;
|
|
|
|
/// Version 10: ERPNext integration. `erpnext_config` is a single row (inserted here, so reading it is a plain SELECT)
|
|
/// holding the connection, the company/account/master mapping and the toggles. `api_secret` is stored here and is never
|
|
/// returned to the webview. `erpnext_sync` tracks what was pushed per invoice; issued invoices themselves are untouched.
|
|
/// The remote names on clients, payments and item presets are nullable: NULL means "not linked yet".
|
|
const M10: &str = r#"
|
|
CREATE TABLE erpnext_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
base_url TEXT NOT NULL DEFAULT '',
|
|
api_key TEXT NOT NULL DEFAULT '',
|
|
api_secret TEXT NOT NULL DEFAULT '',
|
|
extra_ca_pem TEXT NOT NULL DEFAULT '',
|
|
company TEXT NOT NULL DEFAULT '',
|
|
company_address TEXT NOT NULL DEFAULT '',
|
|
naming_mode TEXT NOT NULL DEFAULT 'mirror' CHECK (naming_mode IN ('mirror', 'series')),
|
|
naming_series TEXT NOT NULL DEFAULT '',
|
|
income_account TEXT NOT NULL DEFAULT '',
|
|
cost_center TEXT NOT NULL DEFAULT '',
|
|
cgst_account TEXT NOT NULL DEFAULT '',
|
|
sgst_account TEXT NOT NULL DEFAULT '',
|
|
utgst_account TEXT NOT NULL DEFAULT '',
|
|
igst_account TEXT NOT NULL DEFAULT '',
|
|
tax_template_intra TEXT NOT NULL DEFAULT '',
|
|
tax_template_inter TEXT NOT NULL DEFAULT '',
|
|
payment_bank_account TEXT NOT NULL DEFAULT '',
|
|
tds_account TEXT NOT NULL DEFAULT '',
|
|
default_item_code TEXT NOT NULL DEFAULT '',
|
|
uom_map TEXT NOT NULL DEFAULT '{"second":"Second","minute":"Minute","hour":"Hour","session":"Nos","unit":"Nos"}',
|
|
customer_group TEXT NOT NULL DEFAULT '',
|
|
territory TEXT NOT NULL DEFAULT '',
|
|
selling_price_list TEXT NOT NULL DEFAULT '',
|
|
submit_on_push INTEGER NOT NULL DEFAULT 0,
|
|
attach_pdf INTEGER NOT NULL DEFAULT 1,
|
|
auto_push_on_issue INTEGER NOT NULL DEFAULT 0,
|
|
create_missing_customers INTEGER NOT NULL DEFAULT 1,
|
|
last_detect_result TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT
|
|
);
|
|
INSERT INTO erpnext_config (id) VALUES (1);
|
|
|
|
CREATE TABLE erpnext_sync (
|
|
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
|
remote_name TEXT NOT NULL DEFAULT '',
|
|
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error')),
|
|
last_error TEXT NOT NULL DEFAULT '',
|
|
payload_hash TEXT NOT NULL DEFAULT '',
|
|
synced_at TEXT
|
|
);
|
|
|
|
ALTER TABLE clients ADD COLUMN erpnext_customer TEXT;
|
|
ALTER TABLE clients ADD COLUMN erpnext_address TEXT;
|
|
ALTER TABLE payments ADD COLUMN erpnext_payment_entry TEXT;
|
|
ALTER TABLE item_presets ADD COLUMN erpnext_item_code TEXT;
|
|
"#;
|
|
|
|
/// Version 11: ERPNext push state. `erpnext_sync.status` gains `conflict` (the remote document exists with a different
|
|
/// total; we never overwrite it) and `attachment_sha256` records which archived PDF was uploaded so it is never attached
|
|
/// twice. SQLite cannot alter a CHECK constraint, so the table is rebuilt; M10 rows are copied over.
|
|
const M11: &str = r#"
|
|
CREATE TABLE erpnext_sync_new (
|
|
invoice_id INTEGER PRIMARY KEY REFERENCES invoices(id),
|
|
remote_name TEXT NOT NULL DEFAULT '',
|
|
remote_docstatus INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'synced', 'error', 'conflict')),
|
|
last_error TEXT NOT NULL DEFAULT '',
|
|
payload_hash TEXT NOT NULL DEFAULT '',
|
|
synced_at TEXT,
|
|
attachment_sha256 TEXT NOT NULL DEFAULT ''
|
|
);
|
|
INSERT INTO erpnext_sync_new (invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at)
|
|
SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync;
|
|
DROP TABLE erpnext_sync;
|
|
ALTER TABLE erpnext_sync_new RENAME TO erpnext_sync;
|
|
"#;
|
|
|
|
/// Version 12: `item_presets.unit` no longer lists five units. Units of measure now cover time, distance, area, weight and
|
|
/// volume, and a preset may carry a custom unit, so the allow-list becomes a length check and src/units.rs does the rest.
|
|
/// SQLite cannot alter a CHECK constraint, so the table is rebuilt; every M7/M10 row is copied over with its id.
|
|
const M12: &str = r#"
|
|
CREATE TABLE item_presets_new (
|
|
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 (length(trim(unit)) BETWEEN 1 AND 12),
|
|
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
erpnext_item_code TEXT
|
|
);
|
|
INSERT INTO item_presets_new (id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
|
|
SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets;
|
|
DROP TABLE item_presets;
|
|
ALTER TABLE item_presets_new RENAME TO item_presets;
|
|
"#;
|
|
|
|
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),
|
|
M::up(M10),
|
|
M::up(M11),
|
|
M::up(M12),
|
|
])
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
|
|
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
|
|
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
|
|
let quoted = target.to_string_lossy().replace('\'', "''");
|
|
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
|
|
}
|
|
|
|
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
|
conn.query_row(
|
|
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
|
[],
|
|
|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"));
|
|
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.
|
|
/// The defaults are neutral: no vendor, bank or tax identity is pre-filled, so the setup wizard
|
|
/// starts blank. The vendor is an unregistered supplier (no tax) until the wizard says otherwise.
|
|
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, '', '', '', '', '', '', '', 18, 'none', 30, 'INR', 0, 'g10')"#,
|
|
[],
|
|
)?;
|
|
}
|
|
|
|
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!("INV/{}-", year), now],
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Sample vendor and bank for command-logic unit tests (production seeds none). Fake values only.
|
|
#[cfg(test)]
|
|
pub(crate) fn seed_test_fixture(conn: &Connection) -> rusqlite::Result<()> {
|
|
conn.execute(
|
|
r#"UPDATE app_settings SET vendor_name = 'Test Vendor', vendor_address = '1 Test Road, Bangalore - 560001',
|
|
vendor_email = 'vendor@example.com', vendor_phone = '9000000000', vendor_pan = 'ABCDE1234F',
|
|
vendor_state_code = '29' WHERE id = 1"#,
|
|
[],
|
|
)?;
|
|
conn.execute(
|
|
r#"INSERT INTO bank_accounts (label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
|
VALUES ('Primary', 'Test Bank', 'Test Vendor', '000000000000', '000001', 'TEST0000001', 1)"#,
|
|
[],
|
|
)?;
|
|
let bank_id = conn.last_insert_rowid();
|
|
conn.execute(
|
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
|
params![bank_id],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// A migrated, seeded in-memory database for unit tests of the command logic.
|
|
#[cfg(test)]
|
|
pub fn open_in_memory() -> Result<Connection, DbError> {
|
|
let mut conn = Connection::open_in_memory()?;
|
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
|
migrations().to_latest(&mut conn)?;
|
|
seed(&conn)?;
|
|
seed_test_fixture(&conn)?;
|
|
Ok(conn)
|
|
}
|
|
|
|
/// `<data_dir>/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 `<data_dir>/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::<rusqlite::Result<_>>()?
|
|
};
|
|
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::<rusqlite::Result<_>>()?
|
|
};
|
|
for (id, text) in snapshots {
|
|
let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&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. "INV/2026-" + 1 -> "INV/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, "");
|
|
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<String>, Option<String>, 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<i64>) = 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<Row> = 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::<Result<_, _>>()
|
|
.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");
|
|
}
|
|
|
|
#[test]
|
|
fn m10_fresh_database_has_the_erpnext_tables_and_a_default_config_row() {
|
|
let conn = open_in_memory().unwrap();
|
|
let (naming, submit, attach, auto_push, create_customers, secret, uom): (String, i64, i64, i64, i64, String, String) = conn
|
|
.query_row(
|
|
"SELECT naming_mode, submit_on_push, attach_pdf, auto_push_on_issue, create_missing_customers, api_secret, uom_map
|
|
FROM erpnext_config WHERE id = 1",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
(naming.as_str(), submit, attach, auto_push, create_customers),
|
|
("mirror", 0, 1, 0, 1)
|
|
);
|
|
assert_eq!(secret, "");
|
|
assert!(serde_json::from_str::<serde_json::Value>(&uom).is_ok());
|
|
let rows: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM erpnext_config", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(rows, 1);
|
|
// Single row only.
|
|
assert!(conn
|
|
.execute("INSERT INTO erpnext_config (id) VALUES (2)", [])
|
|
.is_err());
|
|
conn.prepare("SELECT invoice_id, remote_name, remote_docstatus, status, last_error, payload_hash, synced_at FROM erpnext_sync")
|
|
.unwrap();
|
|
conn.prepare("SELECT erpnext_customer, erpnext_address FROM clients")
|
|
.unwrap();
|
|
conn.prepare("SELECT erpnext_payment_entry FROM payments")
|
|
.unwrap();
|
|
conn.prepare("SELECT erpnext_item_code FROM item_presets")
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn m10_upgrade_from_v9_keeps_existing_data_and_new_columns_are_null() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let v9 = 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),
|
|
];
|
|
Migrations::new(v9).to_latest(&mut conn).unwrap();
|
|
assert_eq!(user_version(&conn), 9);
|
|
conn.execute("INSERT INTO clients (name, gstin, created_at) VALUES ('Acme', '29ABCDE1234F1Z5', 'now')", []).unwrap();
|
|
conn.execute("INSERT INTO item_presets (description, rate_paise, created_at) VALUES ('Design', 500000, 'now')", []).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO payments (invoice_id, paid_on, amount_paise, mode, created_at) VALUES (1, '2026-04-10', 12345, 'upi', 'now')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
migrations().to_latest(&mut conn).unwrap();
|
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
|
|
|
let (name, gstin, customer, address): (String, String, Option<String>, Option<String>) =
|
|
conn.query_row(
|
|
"SELECT name, gstin, erpnext_customer, erpnext_address FROM clients",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
(name.as_str(), gstin.as_str(), customer, address),
|
|
("Acme", "29ABCDE1234F1Z5", None, None)
|
|
);
|
|
let (desc, rate, code): (String, i64, Option<String>) = conn
|
|
.query_row(
|
|
"SELECT description, rate_paise, erpnext_item_code FROM item_presets",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!((desc.as_str(), rate, code), ("Design", 500000, None));
|
|
let (paid, entry): (i64, Option<String>) = conn
|
|
.query_row(
|
|
"SELECT amount_paise, erpnext_payment_entry FROM payments",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!((paid, entry), (12345, None));
|
|
let number: String = conn
|
|
.query_row("SELECT number FROM invoices", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(number, "INV/2026-001");
|
|
|
|
// The config row exists with defaults, and a sync row can reference the invoice.
|
|
let enabled: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM erpnext_config WHERE id = 1",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(enabled, 1);
|
|
conn.execute("INSERT INTO erpnext_sync (invoice_id, remote_name, status) VALUES (1, 'INV/2026-001', 'synced')", []).unwrap();
|
|
assert!(conn
|
|
.execute(
|
|
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'bogus')",
|
|
[]
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn m11_rebuilds_the_sync_table_keeping_rows_and_allowing_conflict() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let v10 = 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),
|
|
M::up(M10),
|
|
];
|
|
Migrations::new(v10).to_latest(&mut conn).unwrap();
|
|
assert_eq!(user_version(&conn), 10);
|
|
conn.execute(
|
|
"INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES ('INV/2026-001', '2026-04-01', 'now', 'now')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO erpnext_sync (invoice_id, remote_name, remote_docstatus, status, payload_hash, synced_at)
|
|
VALUES (1, 'INV/2026-001', 1, 'synced', 'abc', '2026-04-02T00:00:00Z')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
assert!(conn
|
|
.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
|
.is_err());
|
|
|
|
migrations().to_latest(&mut conn).unwrap();
|
|
assert_eq!(user_version(&conn), LATEST_VERSION);
|
|
let row: (String, i64, String, String, String, Option<String>) = conn
|
|
.query_row(
|
|
"SELECT remote_name, remote_docstatus, status, payload_hash, attachment_sha256, synced_at FROM erpnext_sync WHERE invoice_id = 1",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
row,
|
|
(
|
|
"INV/2026-001".into(),
|
|
1,
|
|
"synced".into(),
|
|
"abc".into(),
|
|
String::new(),
|
|
Some("2026-04-02T00:00:00Z".into())
|
|
)
|
|
);
|
|
conn.execute("UPDATE erpnext_sync SET status = 'conflict'", [])
|
|
.unwrap();
|
|
assert!(conn
|
|
.execute("UPDATE erpnext_sync SET status = 'bogus'", [])
|
|
.is_err());
|
|
// The unique key still guards one row per invoice.
|
|
assert!(conn
|
|
.execute(
|
|
"INSERT INTO erpnext_sync (invoice_id, status) VALUES (1, 'error')",
|
|
[]
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn m12_rebuilds_item_presets_keeping_rows_and_allowing_new_units() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let v11 = 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),
|
|
M::up(M10),
|
|
M::up(M11),
|
|
];
|
|
Migrations::new(v11).to_latest(&mut conn).unwrap();
|
|
assert_eq!(user_version(&conn), 11);
|
|
conn.execute(
|
|
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code)
|
|
VALUES ('Studio', '998397', 'rate', 'hour', 150050, 3, 'then', 'ITEM-1'), ('Fee', '', 'fixed', 'unit', 500000, 0, 'then', NULL)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'km', 'now')", []).is_err());
|
|
|
|
migrations().to_latest(&mut conn).unwrap();
|
|
assert_eq!(user_version(&conn), 12);
|
|
let rows: Vec<(i64, String, String, String, String, i64, i64, String, Option<String>)> = conn
|
|
.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at, erpnext_item_code FROM item_presets 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)?, r.get(7)?, r.get(8)?))
|
|
})
|
|
.unwrap()
|
|
.collect::<Result<_, _>>()
|
|
.unwrap();
|
|
assert_eq!(
|
|
rows,
|
|
vec![
|
|
(
|
|
1,
|
|
"Studio".into(),
|
|
"998397".into(),
|
|
"rate".into(),
|
|
"hour".into(),
|
|
150050,
|
|
3,
|
|
"then".into(),
|
|
Some("ITEM-1".into())
|
|
),
|
|
(
|
|
2,
|
|
"Fee".into(),
|
|
"".into(),
|
|
"fixed".into(),
|
|
"unit".into(),
|
|
500000,
|
|
0,
|
|
"then".into(),
|
|
None
|
|
),
|
|
]
|
|
);
|
|
// New units and custom text are allowed; an empty or over-long unit is not; ids keep counting up.
|
|
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Travel', 'km', 'now')", []).unwrap();
|
|
conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('Parcel', 'bundle', 'now')", []).unwrap();
|
|
assert!(conn
|
|
.execute(
|
|
"INSERT INTO item_presets (description, unit, created_at) VALUES ('X', '', 'now')",
|
|
[]
|
|
)
|
|
.is_err());
|
|
assert!(conn.execute("INSERT INTO item_presets (description, unit, created_at) VALUES ('X', 'thirteen char', 'now')", []).is_err());
|
|
let last: i64 = conn
|
|
.query_row("SELECT MAX(id) FROM item_presets", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(last, 4);
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|
|
}
|