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