Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled to 1200 px and re-encoded as a content-addressed print PNG plus a white knockout variant when the logo has alpha; aspect, ink density, mean colour and kind (wordmark, mark, tall) are stored with the settings and frozen in the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with white and dark previews and a business-name toggle (migration M5).
329 lines
12 KiB
Rust
329 lines
12 KiB
Rust
use crate::models::{BankAccount, Settings};
|
|
use crate::AppState;
|
|
use rusqlite::{params, Connection, Row};
|
|
use tauri::State;
|
|
|
|
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, render_prefs, last_export_dir, logo_original_path, logo_knockout_path, logo_meta,
|
|
logo_includes_name";
|
|
|
|
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
|
Ok(Settings {
|
|
vendor_name: row.get(0)?,
|
|
vendor_address: row.get(1)?,
|
|
vendor_email: row.get(2)?,
|
|
vendor_phone: row.get(3)?,
|
|
vendor_pan: row.get(4)?,
|
|
vendor_gstin: row.get(5)?,
|
|
vendor_state_code: row.get(6)?,
|
|
logo_path: row.get(7)?,
|
|
signature_path: row.get(8)?,
|
|
default_bank_id: row.get(9)?,
|
|
default_tax_rate: row.get(10)?,
|
|
default_tax_type: row.get(11)?,
|
|
payment_terms_days: row.get(12)?,
|
|
currency: row.get(13)?,
|
|
onboarded: row.get::<_, i64>(14)? != 0,
|
|
theme: row.get(15)?,
|
|
gst_registration: row.get(16)?,
|
|
default_hsn_sac: row.get(17)?,
|
|
signatory_name: row.get(18)?,
|
|
signatory_designation: row.get(19)?,
|
|
render_prefs: row.get(20)?,
|
|
last_export_dir: row.get(21)?,
|
|
logo_original_path: row.get(22)?,
|
|
logo_knockout_path: row.get(23)?,
|
|
logo_meta: row.get(24)?,
|
|
logo_includes_name: row.get(25)?,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_settings(state: State<AppState>) -> Result<Settings, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.query_row(
|
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
|
[],
|
|
map_settings,
|
|
)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Settings, String> {
|
|
if !matches!(settings.logo_includes_name.as_str(), "auto" | "yes" | "no") {
|
|
return Err("Logo includes name must be auto, yes or no".into());
|
|
}
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
r#"UPDATE app_settings SET
|
|
vendor_name = ?1, vendor_address = ?2, vendor_email = ?3, vendor_phone = ?4,
|
|
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
|
|
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
|
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
|
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
|
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21,
|
|
logo_original_path = ?22, logo_knockout_path = ?23, logo_meta = ?24,
|
|
logo_includes_name = ?25
|
|
WHERE id = 1"#,
|
|
params![
|
|
settings.vendor_name,
|
|
settings.vendor_address,
|
|
settings.vendor_email,
|
|
settings.vendor_phone,
|
|
settings.vendor_pan,
|
|
settings.vendor_gstin,
|
|
settings.vendor_state_code,
|
|
settings.logo_path,
|
|
settings.signature_path,
|
|
settings.default_bank_id,
|
|
settings.default_tax_rate,
|
|
settings.default_tax_type,
|
|
settings.payment_terms_days,
|
|
settings.currency,
|
|
if settings.onboarded { 1 } else { 0 },
|
|
settings.theme,
|
|
settings.gst_registration,
|
|
settings.default_hsn_sac,
|
|
settings.signatory_name,
|
|
settings.signatory_designation,
|
|
settings.render_prefs,
|
|
settings.logo_original_path,
|
|
settings.logo_knockout_path,
|
|
settings.logo_meta,
|
|
settings.logo_includes_name,
|
|
],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
drop(conn);
|
|
get_settings(state)
|
|
}
|
|
|
|
pub(crate) fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
|
|
Ok(BankAccount {
|
|
id: Some(row.get(0)?),
|
|
label: row.get(1)?,
|
|
bank_name: row.get(2)?,
|
|
account_name: row.get(3)?,
|
|
account_no: row.get(4)?,
|
|
branch: row.get(5)?,
|
|
ifsc: row.get(6)?,
|
|
is_default: row.get::<_, i64>(7)? != 0,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_banks(state: State<AppState>) -> Result<Vec<BankAccount>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
|
|
FROM bank_accounts ORDER BY is_default DESC, id ASC",
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map([], map_bank)
|
|
.map_err(|e| e.to_string())?
|
|
.collect::<rusqlite::Result<Vec<_>>>()
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(rows)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccount, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let is_default = if bank.is_default { 1 } else { 0 };
|
|
|
|
let id = match bank.id {
|
|
Some(id) => {
|
|
conn.execute(
|
|
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
|
|
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
|
|
params![
|
|
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
|
bank.branch, bank.ifsc, is_default, id
|
|
],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
id
|
|
}
|
|
None => {
|
|
conn.execute(
|
|
r#"INSERT INTO bank_accounts
|
|
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
|
|
params![
|
|
bank.label, bank.bank_name, bank.account_name, bank.account_no,
|
|
bank.branch, bank.ifsc, is_default
|
|
],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
conn.last_insert_rowid()
|
|
}
|
|
};
|
|
|
|
if is_default == 1 {
|
|
conn.execute(
|
|
"UPDATE bank_accounts SET is_default = 0 WHERE id <> ?1",
|
|
params![id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
|
|
params![id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
conn.query_row(
|
|
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
|
|
FROM bank_accounts WHERE id = ?1",
|
|
params![id],
|
|
map_bank,
|
|
)
|
|
.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 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 settings_carry_render_prefs() {
|
|
let conn = crate::db::open_in_memory().unwrap();
|
|
let read = |conn: &Connection| {
|
|
conn.query_row(
|
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
|
[],
|
|
map_settings,
|
|
)
|
|
.unwrap()
|
|
};
|
|
assert_eq!(read(&conn).render_prefs, "");
|
|
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
|
|
.unwrap();
|
|
let settings = read(&conn);
|
|
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
|
let json = serde_json::to_value(&settings).unwrap();
|
|
assert_eq!(json["renderPrefs"], "{\"version\":1}");
|
|
}
|
|
|
|
#[test]
|
|
fn settings_carry_logo_columns() {
|
|
let conn = crate::db::open_in_memory().unwrap();
|
|
let read = |conn: &Connection| {
|
|
conn.query_row(
|
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
|
[],
|
|
map_settings,
|
|
)
|
|
.unwrap()
|
|
};
|
|
let fresh = read(&conn);
|
|
assert_eq!(fresh.logo_original_path, None);
|
|
assert_eq!(fresh.logo_knockout_path, None);
|
|
assert_eq!(fresh.logo_meta, "");
|
|
assert_eq!(fresh.logo_includes_name, "auto");
|
|
conn.execute(
|
|
"UPDATE app_settings SET logo_original_path = 'assets/o.png', logo_knockout_path = 'assets/k.png',
|
|
logo_meta = '{}', logo_includes_name = 'no' WHERE id = 1",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
let json = serde_json::to_value(read(&conn)).unwrap();
|
|
assert_eq!(json["logoOriginalPath"], "assets/o.png");
|
|
assert_eq!(json["logoKnockoutPath"], "assets/k.png");
|
|
assert_eq!(json["logoMeta"], "{}");
|
|
assert_eq!(json["logoIncludesName"], "no");
|
|
}
|
|
|
|
#[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]));
|
|
}
|
|
}
|