Add issue/cancel lifecycle, GST derivation and integer-paise totals

issue_invoice allocates the number, freezes vendor and bank details and
stores server-computed totals in one transaction. Issued invoices are
cancelled, not deleted. Tax heads derive from supplier state and place of
supply (a disagreeing choice is rejected); unregistered suppliers issue a
plain Invoice. Adds GSTIN checksum validation, canonical India Compliance
state list, server-side drafts, series validation, relative and
magic-byte-checked asset paths, and GST settings. PDF re-exports use the
frozen vendor snapshot.
This commit is contained in:
2026-10-04 04:15:58 +05:30
parent ecf57c9221
commit a00c816635
19 changed files with 2403 additions and 327 deletions
+145
View File
@@ -303,6 +303,91 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
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)?;
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. "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)
@@ -593,4 +678,64 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
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");
}
}
}