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:
2026-10-04 17:29:46 +05:30
parent a81aca0ca0
commit 708fa7b610
30 changed files with 2119 additions and 204 deletions
+300 -27
View File
@@ -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"));
}
}
+4 -3
View File
@@ -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)?;
+1
View File
@@ -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;
+177
View File
@@ -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
View File
@@ -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();
+3
View File
@@ -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,
+44
View File
@@ -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)]