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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user