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 d6ec6b2a17
commit 5597fb791d
19 changed files with 2403 additions and 327 deletions
+92 -8
View File
@@ -1,14 +1,14 @@
use crate::models::{BankAccount, Settings};
use crate::AppState;
use rusqlite::{params, Row};
use rusqlite::{params, Connection, Row};
use tauri::State;
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
pub(crate) 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, gst_registration, default_hsn_sac, signatory_name,
signatory_designation";
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
Ok(Settings {
vendor_name: row.get(0)?,
vendor_address: row.get(1)?,
@@ -84,7 +84,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
get_settings(state)
}
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
pub(crate) fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
Ok(BankAccount {
id: Some(row.get(0)?),
label: row.get(1)?,
@@ -169,10 +169,94 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
.map_err(|e| e.to_string())
}
/// Deletes a bank account. If it was the default, the lowest remaining account
/// becomes the default (or the default is cleared when none is left). Issued invoices
/// keep their own `bank_snapshot`, so they are unaffected.
pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
let tx = conn.transaction().map_err(|e| e.to_string())?;
let was_default: bool = tx
.query_row(
"SELECT COALESCE((SELECT is_default FROM bank_accounts WHERE id = ?1), 0) <> 0
OR COALESCE((SELECT default_bank_id FROM app_settings WHERE id = 1), 0) = ?1",
params![id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
if was_default {
let next: Option<i64> = tx
.query_row("SELECT MIN(id) FROM bank_accounts", [], |r| r.get(0))
.map_err(|e| e.to_string())?;
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
.map_err(|e| e.to_string())?;
if let Some(next) = next {
tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next])
.map_err(|e| e.to_string())?;
}
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next])
.map_err(|e| e.to_string())?;
}
tx.commit().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
delete_bank_impl(&mut conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
fn add_bank(conn: &Connection, name: &str, is_default: bool) -> i64 {
conn.execute(
"INSERT INTO bank_accounts (bank_name, is_default) VALUES (?1, ?2)",
params![name, is_default],
)
.unwrap();
conn.last_insert_rowid()
}
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
let setting = conn
.query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0))
.unwrap();
let mut stmt = conn
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
.unwrap();
let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect();
(setting, flagged)
}
#[test]
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
let mut conn = crate::db::open_in_memory().unwrap();
// The seeded "Primary" bank is the default; add two more.
let seeded: i64 = conn
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
.unwrap();
let b = add_bank(&conn, "B", false);
let c = add_bank(&conn, "C", false);
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
delete_bank_impl(&mut conn, seeded).unwrap();
assert_eq!(default_state(&conn), (Some(b), vec![b]));
delete_bank_impl(&mut conn, b).unwrap();
assert_eq!(default_state(&conn), (Some(c), vec![c]));
delete_bank_impl(&mut conn, c).unwrap();
assert_eq!(default_state(&conn), (None, vec![]));
}
#[test]
fn deleting_a_non_default_bank_keeps_the_default() {
let mut conn = crate::db::open_in_memory().unwrap();
let seeded: i64 = conn
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
.unwrap();
let b = add_bank(&conn, "B", false);
delete_bank_impl(&mut conn, b).unwrap();
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
}
}