Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)
- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure. - Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup. - Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time. - History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command. - Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers. Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
@@ -546,6 +546,83 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
||||
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InvoiceLedgerRow {
|
||||
pub id: i64,
|
||||
pub client_gstin: String,
|
||||
pub place_of_supply_code: String,
|
||||
pub doc_type: String,
|
||||
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
||||
pub hsn_sac: Vec<String>,
|
||||
/// Subtotal less discount.
|
||||
pub taxable_paise: i64,
|
||||
pub cgst_paise: i64,
|
||||
pub sgst_paise: i64,
|
||||
pub igst_paise: i64,
|
||||
pub reverse_charge: bool,
|
||||
}
|
||||
|
||||
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
|
||||
{
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let items = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
for item in items {
|
||||
let (id, code) = item.map_err(|e| e.to_string())?;
|
||||
let list = hsn_by_invoice.entry(id).or_default();
|
||||
if !code.is_empty() && !list.contains(&code) {
|
||||
list.push(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
||||
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
||||
FROM invoices ORDER BY id DESC",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let id: i64 = r.get(0)?;
|
||||
Ok(InvoiceLedgerRow {
|
||||
id,
|
||||
client_gstin: r.get(1)?,
|
||||
place_of_supply_code: r.get(2)?,
|
||||
doc_type: r.get(3)?,
|
||||
hsn_sac: Vec::new(),
|
||||
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
||||
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
||||
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
||||
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
||||
reverse_charge: r.get::<_, i64>(9)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|mut row| {
|
||||
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
||||
row
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_invoice_ledger_impl(&conn)
|
||||
}
|
||||
|
||||
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
||||
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -696,6 +773,28 @@ mod tests {
|
||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
||||
let mut conn = registered();
|
||||
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
||||
params![first.id],
|
||||
)
|
||||
.unwrap();
|
||||
let ledger = list_invoice_ledger_impl(&conn).unwrap();
|
||||
assert_eq!(ledger.len(), 1);
|
||||
let row = &ledger[0];
|
||||
assert_eq!(row.id, first.id);
|
||||
assert_eq!(row.place_of_supply_code, "27");
|
||||
assert_eq!(row.doc_type, "tax_invoice");
|
||||
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
||||
assert_eq!(row.taxable_paise, 731_000);
|
||||
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
|
||||
assert!(!row.reverse_charge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issues_numbered_tax_invoices_with_derived_totals() {
|
||||
let mut conn = registered();
|
||||
|
||||
Reference in New Issue
Block a user