Add toasts, Settings tabs, Clients page and item presets (Phase E1)
- Global Carbon toast stack replaces scattered inline notices; errors persist until closed. - Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings. - New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot. - Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular. - New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control. - Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
@@ -1,8 +1,16 @@
|
||||
use crate::gst;
|
||||
use crate::models::Client;
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Row};
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"];
|
||||
|
||||
const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at,
|
||||
c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes,
|
||||
c.payment_terms_days,
|
||||
(SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)";
|
||||
|
||||
fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||
Ok(Client {
|
||||
id: Some(row.get(0)?),
|
||||
@@ -12,17 +20,51 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
|
||||
state_code: row.get(4)?,
|
||||
po_number: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
address_line1: row.get(7)?,
|
||||
address_line2: row.get(8)?,
|
||||
city: row.get(9)?,
|
||||
pincode: row.get(10)?,
|
||||
gst_category: row.get(11)?,
|
||||
default_notes: row.get(12)?,
|
||||
payment_terms_days: row.get(13)?,
|
||||
invoice_count: row.get(14)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
/// Composes the stored multi-line address from the structured parts: line 1, line 2, then
|
||||
/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
|
||||
pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String {
|
||||
let state_name = gst::STATES
|
||||
.iter()
|
||||
.find(|(c, _)| *c == state_code)
|
||||
.map(|(_, n)| *n)
|
||||
.unwrap_or("");
|
||||
let place = [city.trim(), state_name]
|
||||
.iter()
|
||||
.filter(|p| !p.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let pin = pincode.trim();
|
||||
let last = match (place.is_empty(), pin.is_empty()) {
|
||||
(false, false) => format!("{place} - {pin}"),
|
||||
(false, true) => place,
|
||||
(true, false) => pin.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
[line1.trim(), line2.trim(), last.as_str()]
|
||||
.iter()
|
||||
.filter(|l| !l.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, address, gstin, state_code, po_number, created_at
|
||||
FROM clients ORDER BY name COLLATE NOCASE ASC",
|
||||
)
|
||||
.prepare(&format!(
|
||||
"SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], map_client)
|
||||
@@ -32,29 +74,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
|
||||
let name = client.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Client name is required".into());
|
||||
}
|
||||
let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "NA" => String::new(),
|
||||
g => {
|
||||
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||
g.to_string()
|
||||
}
|
||||
};
|
||||
let state_code = client.state_code.trim().to_string();
|
||||
if !state_code.is_empty() && !gst::state_exists(&state_code) {
|
||||
return Err(format!("\"{state_code}\" is not a valid GST state code"));
|
||||
}
|
||||
if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code {
|
||||
return Err(format!(
|
||||
"GSTIN starts with state code {}, but the client's state is {}",
|
||||
&gstin[0..2],
|
||||
state_code
|
||||
));
|
||||
}
|
||||
if !CATEGORIES.contains(&client.gst_category.as_str()) {
|
||||
return Err(format!("Unknown GST category \"{}\"", client.gst_category));
|
||||
}
|
||||
let pincode = client.pincode.trim().to_string();
|
||||
if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) {
|
||||
return Err("Pincode must be 6 digits".into());
|
||||
}
|
||||
if let Some(days) = client.payment_terms_days {
|
||||
if !(0..=365).contains(&days) {
|
||||
return Err("Payment terms must be between 0 and 365 days".into());
|
||||
}
|
||||
}
|
||||
let line1 = client.address_line1.trim().to_string();
|
||||
let line2 = client.address_line2.trim().to_string();
|
||||
let city = client.city.trim().to_string();
|
||||
// The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept.
|
||||
let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() {
|
||||
client.address.clone()
|
||||
} else {
|
||||
compose_address(&line1, &line2, &city, &state_code, &pincode)
|
||||
};
|
||||
|
||||
let id = match client.id {
|
||||
Some(id) => {
|
||||
conn.execute(
|
||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
|
||||
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let n = conn
|
||||
.execute(
|
||||
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5,
|
||||
address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10,
|
||||
default_notes=?11, payment_terms_days=?12
|
||||
WHERE id=?13",
|
||||
params![
|
||||
name,
|
||||
address,
|
||||
gstin,
|
||||
state_code,
|
||||
client.po_number,
|
||||
line1,
|
||||
line2,
|
||||
city,
|
||||
pincode,
|
||||
client.gst_category,
|
||||
client.default_notes,
|
||||
client.payment_terms_days,
|
||||
id
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Err("Client not found".into());
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
|
||||
address_line1, address_line2, city, pincode, gst_category, default_notes,
|
||||
payment_terms_days)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
client.name,
|
||||
client.address,
|
||||
client.gstin,
|
||||
client.state_code,
|
||||
name,
|
||||
address,
|
||||
gstin,
|
||||
state_code,
|
||||
client.po_number,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
chrono::Utc::now().to_rfc3339(),
|
||||
line1,
|
||||
line2,
|
||||
city,
|
||||
pincode,
|
||||
client.gst_category,
|
||||
client.default_notes,
|
||||
client.payment_terms_days
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -62,17 +176,176 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
|
||||
}
|
||||
};
|
||||
conn.query_row(
|
||||
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
|
||||
&format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.id = ?1"),
|
||||
params![id],
|
||||
map_client,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
/// Deleting is allowed even when invoices reference the client: invoices carry their own
|
||||
/// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
|
||||
/// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first.
|
||||
pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_clients_impl(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
save_client_impl(&conn, client)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
delete_client_impl(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::open_in_memory;
|
||||
|
||||
fn blank(name: &str) -> Client {
|
||||
serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_address_orders_parts() {
|
||||
assert_eq!(
|
||||
compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"),
|
||||
"12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"
|
||||
);
|
||||
assert_eq!(compose_address("", "", "", "", ""), "");
|
||||
assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001");
|
||||
assert_eq!(compose_address("", "", "Pune", "", ""), "Pune");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_round_trip_composes_address() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut c = blank("Acme");
|
||||
c.address_line1 = "12 MG Road".into();
|
||||
c.address_line2 = "Floor 2".into();
|
||||
c.city = "Bengaluru".into();
|
||||
c.pincode = "560001".into();
|
||||
c.state_code = "29".into();
|
||||
c.gstin = "29abcde1234f1zw".into();
|
||||
c.gst_category = "registered_regular".into();
|
||||
c.default_notes = "Net 15".into();
|
||||
c.payment_terms_days = Some(15);
|
||||
c.po_number = "PO-9".into();
|
||||
let saved = save_client_impl(&conn, c).unwrap();
|
||||
assert!(saved.id.is_some());
|
||||
assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
|
||||
assert_eq!(saved.gstin, "29ABCDE1234F1ZW");
|
||||
let listed = list_clients_impl(&conn).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
let l = &listed[0];
|
||||
assert_eq!(l.address_line1, "12 MG Road");
|
||||
assert_eq!(l.gst_category, "registered_regular");
|
||||
assert_eq!(l.default_notes, "Net 15");
|
||||
assert_eq!(l.payment_terms_days, Some(15));
|
||||
assert_eq!(l.invoice_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_address_is_kept_when_no_structured_parts() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut c = blank("Legacy");
|
||||
c.address = "Old style\nfree text".into();
|
||||
let saved = save_client_impl(&conn, c).unwrap();
|
||||
assert_eq!(saved.address, "Old style\nfree text");
|
||||
assert_eq!(saved.gst_category, "unregistered");
|
||||
// Editing a field other than the address leaves it alone.
|
||||
let mut edit = saved.clone();
|
||||
edit.po_number = "PO-1".into();
|
||||
let again = save_client_impl(&conn, edit).unwrap();
|
||||
assert_eq!(again.address, "Old style\nfree text");
|
||||
assert_eq!(again.id, saved.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_changes_the_row_and_unknown_id_fails() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let saved = save_client_impl(&conn, blank("A")).unwrap();
|
||||
let mut edit = saved.clone();
|
||||
edit.name = "B".into();
|
||||
edit.gst_category = "sez".into();
|
||||
let again = save_client_impl(&conn, edit).unwrap();
|
||||
assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez"));
|
||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||
let mut ghost = blank("Ghost");
|
||||
ghost.id = Some(999);
|
||||
assert!(save_client_impl(&conn, ghost).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_bad_input() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "29ABCDE1234F1ZW".into();
|
||||
c.state_code = "27".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("state"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gstin = "NA".into();
|
||||
assert_eq!(save_client_impl(&conn, c).unwrap().gstin, "");
|
||||
|
||||
let mut c = blank("X");
|
||||
c.state_code = "99".into();
|
||||
assert!(save_client_impl(&conn, c).is_err());
|
||||
|
||||
let mut c = blank("X");
|
||||
c.gst_category = "weird".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("category"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.pincode = "56001".into();
|
||||
assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode"));
|
||||
|
||||
let mut c = blank("X");
|
||||
c.payment_terms_days = Some(400);
|
||||
assert!(save_client_impl(&conn, c).is_err());
|
||||
// Only the valid "NA" client above was stored; every rejected save left no row behind.
|
||||
assert_eq!(list_clients_impl(&conn).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_keeps_referencing_invoices_and_count_is_listed() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let saved = save_client_impl(&conn, blank("Referenced")).unwrap();
|
||||
let id = saved.id.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at)
|
||||
VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')",
|
||||
params![id],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1);
|
||||
|
||||
delete_client_impl(&conn, id).unwrap();
|
||||
assert!(list_clients_impl(&conn).unwrap().is_empty());
|
||||
let (name, addr): (String, String) = conn
|
||||
.query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,15 +338,16 @@ pub fn issue_invoice_impl(
|
||||
Some(id) => Some(id),
|
||||
None if input.save_client && !input.client_name.trim().is_empty() => {
|
||||
tx.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
input.client_name,
|
||||
input.client_address,
|
||||
client_gstin,
|
||||
stored_pos,
|
||||
input.po_number,
|
||||
now
|
||||
now,
|
||||
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
|
||||
],
|
||||
)
|
||||
.map_err(db)?;
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod files;
|
||||
pub mod fonts;
|
||||
pub mod invoice;
|
||||
pub mod logo;
|
||||
pub mod presets;
|
||||
pub mod raw;
|
||||
pub mod series;
|
||||
pub mod settings;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::models::ItemPreset;
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
const MODES: &[&str] = &["fixed", "rate"];
|
||||
const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"];
|
||||
/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow.
|
||||
const MAX_RATE_PAISE: i64 = 100_000_000_000;
|
||||
|
||||
const COLS: &str = "id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at";
|
||||
|
||||
fn map_preset(row: &Row) -> rusqlite::Result<ItemPreset> {
|
||||
Ok(ItemPreset {
|
||||
id: Some(row.get(0)?),
|
||||
description: row.get(1)?,
|
||||
hsn_sac: row.get(2)?,
|
||||
mode: row.get(3)?,
|
||||
unit: row.get(4)?,
|
||||
rate_paise: row.get(5)?,
|
||||
sort_order: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_presets_impl(conn: &Connection) -> Result<Vec<ItemPreset>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"SELECT {COLS} FROM item_presets ORDER BY sort_order ASC, description COLLATE NOCASE ASC, id ASC"
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], map_preset)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPreset, String> {
|
||||
let description = preset.description.trim().to_string();
|
||||
if description.is_empty() {
|
||||
return Err("Preset description is required".into());
|
||||
}
|
||||
if !MODES.contains(&preset.mode.as_str()) {
|
||||
return Err(format!("Unknown line type \"{}\"", preset.mode));
|
||||
}
|
||||
if !UNITS.contains(&preset.unit.as_str()) {
|
||||
return Err(format!("Unknown unit \"{}\"", preset.unit));
|
||||
}
|
||||
if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) {
|
||||
return Err("Amount is out of range".into());
|
||||
}
|
||||
let hsn = preset.hsn_sac.trim().to_string();
|
||||
let id = match preset.id {
|
||||
Some(id) => {
|
||||
let n = conn
|
||||
.execute(
|
||||
"UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6
|
||||
WHERE id=?7",
|
||||
params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Err("Preset not found".into());
|
||||
}
|
||||
id
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
description,
|
||||
hsn,
|
||||
preset.mode,
|
||||
preset.unit,
|
||||
preset.rate_paise,
|
||||
preset.sort_order,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
};
|
||||
conn.query_row(
|
||||
&format!("SELECT {COLS} FROM item_presets WHERE id = ?1"),
|
||||
params![id],
|
||||
map_preset,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_preset_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM item_presets WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_item_presets(state: State<AppState>) -> Result<Vec<ItemPreset>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_presets_impl(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_item_preset(state: State<AppState>, preset: ItemPreset) -> Result<ItemPreset, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
save_preset_impl(&conn, preset)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_item_preset(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
delete_preset_impl(&conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::open_in_memory;
|
||||
|
||||
fn preset(desc: &str) -> ItemPreset {
|
||||
serde_json::from_value(serde_json::json!({ "id": null, "description": desc })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crud_round_trip_in_integer_paise() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
let mut p = preset(" Voice-over session ");
|
||||
p.mode = "rate".into();
|
||||
p.unit = "hour".into();
|
||||
p.rate_paise = 150_050;
|
||||
p.hsn_sac = " 998397 ".into();
|
||||
let saved = save_preset_impl(&conn, p).unwrap();
|
||||
assert_eq!(saved.description, "Voice-over session");
|
||||
assert_eq!(saved.hsn_sac, "998397");
|
||||
assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050));
|
||||
|
||||
let mut edit = saved.clone();
|
||||
edit.rate_paise = 200_000;
|
||||
let again = save_preset_impl(&conn, edit).unwrap();
|
||||
assert_eq!(again.id, saved.id);
|
||||
assert_eq!(again.rate_paise, 200_000);
|
||||
|
||||
save_preset_impl(&conn, preset("Another")).unwrap();
|
||||
let listed = list_presets_impl(&conn).unwrap();
|
||||
assert_eq!(listed.len(), 2);
|
||||
// Same sort_order: alphabetical.
|
||||
assert_eq!(listed[0].description, "Another");
|
||||
|
||||
delete_preset_impl(&conn, saved.id.unwrap()).unwrap();
|
||||
assert_eq!(list_presets_impl(&conn).unwrap().len(), 1);
|
||||
// Deleting a missing id is a no-op.
|
||||
delete_preset_impl(&conn, 12345).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_bad_presets() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
assert!(save_preset_impl(&conn, preset(" ")).is_err());
|
||||
let mut p = preset("X");
|
||||
p.rate_paise = -1;
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
let mut p = preset("X");
|
||||
p.mode = "weird".into();
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
let mut p = preset("X");
|
||||
p.unit = "decade".into();
|
||||
assert!(save_preset_impl(&conn, p).is_err());
|
||||
let mut ghost = preset("Ghost");
|
||||
ghost.id = Some(77);
|
||||
assert!(save_preset_impl(&conn, ghost).is_err());
|
||||
assert!(list_presets_impl(&conn).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
+90
-2
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||
|
||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||
const LATEST_VERSION: i64 = 6;
|
||||
const LATEST_VERSION: i64 = 7;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
@@ -217,8 +217,36 @@ CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256);
|
||||
CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0;
|
||||
"#;
|
||||
|
||||
/// Version 7: structured client addresses, GST category and per-client defaults, plus saved item presets.
|
||||
/// `clients.address` stays the composed multi-line string (issued invoices and the PDF read it); the new
|
||||
/// parts are an editing aid. The address state is `clients.state_code`, which is also the default place
|
||||
/// of supply, so there is no second state column. `payment_terms_days` NULL means "use the settings default".
|
||||
/// `item_presets.rate_paise` is the fixed amount for mode 'fixed' and the per-unit rate for mode 'rate'.
|
||||
const M7: &str = r#"
|
||||
ALTER TABLE clients ADD COLUMN address_line1 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN address_line2 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN city TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN pincode TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN gst_category TEXT NOT NULL DEFAULT 'unregistered'
|
||||
CHECK (gst_category IN ('registered_regular', 'unregistered', 'composition', 'sez', 'overseas'));
|
||||
ALTER TABLE clients ADD COLUMN default_notes TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE clients ADD COLUMN payment_terms_days INTEGER;
|
||||
UPDATE clients SET gst_category = 'registered_regular' WHERE gstin NOT IN ('', 'NA');
|
||||
|
||||
CREATE TABLE item_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL,
|
||||
hsn_sac TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')),
|
||||
unit TEXT NOT NULL DEFAULT 'unit' CHECK (unit IN ('second', 'minute', 'hour', 'session', 'unit')),
|
||||
rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
fn migrations() -> Migrations<'static> {
|
||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)])
|
||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7)])
|
||||
}
|
||||
|
||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||
@@ -657,6 +685,66 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
assert_eq!(backups_in(&dir.path().join("backups")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_fresh_database_has_client_and_preset_columns() {
|
||||
let conn = open_in_memory().unwrap();
|
||||
conn.prepare(
|
||||
"SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients",
|
||||
)
|
||||
.unwrap();
|
||||
conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets")
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, created_at) VALUES ('X', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let (cat, terms): (String, Option<i64>) = conn
|
||||
.query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.unwrap();
|
||||
assert_eq!((cat.as_str(), terms), ("unregistered", None));
|
||||
// The category CHECK rejects unknown values.
|
||||
assert!(conn
|
||||
.execute("UPDATE clients SET gst_category = 'bogus'", [])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)];
|
||||
Migrations::new(all).to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 6);
|
||||
conn.execute(
|
||||
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
|
||||
VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'),
|
||||
('Plain', '', '', '', '', 'now'),
|
||||
('Na', '', 'NA', '', '', 'now')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
migrations().to_latest(&mut conn).unwrap();
|
||||
assert_eq!(user_version(&conn), 7);
|
||||
|
||||
type Row = (String, String, String, String, String, String, String);
|
||||
let rows: Vec<Row> = conn
|
||||
.prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients ORDER BY id")
|
||||
.unwrap()
|
||||
.query_map([], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(rows[0].1, "Line one\nLine two");
|
||||
assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1"));
|
||||
assert_eq!(rows[0].5, "registered_regular");
|
||||
assert_eq!(rows[0].6, "");
|
||||
assert_eq!(rows[1].5, "unregistered");
|
||||
assert_eq!(rows[2].5, "unregistered");
|
||||
}
|
||||
|
||||
fn build_v1_database(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||
|
||||
@@ -121,6 +121,9 @@ pub fn run() {
|
||||
commands::clients::list_clients,
|
||||
commands::clients::save_client,
|
||||
commands::clients::delete_client,
|
||||
commands::presets::list_item_presets,
|
||||
commands::presets::save_item_preset,
|
||||
commands::presets::delete_item_preset,
|
||||
commands::invoice::peek_next_invoice_number,
|
||||
commands::invoice::issue_invoice,
|
||||
commands::invoice::cancel_invoice,
|
||||
|
||||
@@ -95,6 +95,50 @@ pub struct Client {
|
||||
pub po_number: String,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub address_line1: String,
|
||||
#[serde(default)]
|
||||
pub address_line2: String,
|
||||
#[serde(default)]
|
||||
pub city: String,
|
||||
#[serde(default)]
|
||||
pub pincode: String,
|
||||
/// registered_regular | unregistered | composition | sez | overseas. Informational only.
|
||||
#[serde(default = "default_gst_category")]
|
||||
pub gst_category: String,
|
||||
/// Notes / payment terms text copied onto a new invoice when this client is picked.
|
||||
#[serde(default)]
|
||||
pub default_notes: String,
|
||||
/// None = use the settings default.
|
||||
#[serde(default)]
|
||||
pub payment_terms_days: Option<i64>,
|
||||
/// Number of invoices that reference this client; read-only, filled by list_clients.
|
||||
#[serde(default)]
|
||||
pub invoice_count: i64,
|
||||
}
|
||||
|
||||
fn default_gst_category() -> String {
|
||||
"unregistered".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemPreset {
|
||||
pub id: Option<i64>,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub hsn_sac: String,
|
||||
/// "fixed" = rate_paise is the line amount; "rate" = rate_paise is the rate per unit.
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(default = "default_unit")]
|
||||
pub unit: String,
|
||||
#[serde(default)]
|
||||
pub rate_paise: i64,
|
||||
#[serde(default)]
|
||||
pub sort_order: i64,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user