Merge origin/main (CI quality gates) into the 1.1.0 release

# Conflicts:
#	package-lock.json
#	src/views/NewInvoice.tsx
This commit is contained in:
2026-10-06 09:43:47 +05:30
52 changed files with 6100 additions and 1080 deletions
+190 -57
View File
@@ -1,11 +1,12 @@
use super::assets::relative_asset_path;
use super::payments::{derive_summary, summary_for, today_ist};
use super::series::{validate_series_format, MAX_NUMBER_LEN};
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
use crate::db::format_number;
use crate::gst::{self, TaxType};
use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
Settings,
};
use crate::units;
use crate::AppState;
@@ -150,7 +151,9 @@ fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
match item.mode.as_str() {
"rate" => {
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
return Err(format!("Line {n}: rate and quantity must be positive numbers"));
return Err(format!(
"Line {n}: rate and quantity must be positive numbers"
));
}
if item.quantity > MAX_LINE_RUPEES {
return Err(format!("Line {n}: quantity is too large"));
@@ -282,8 +285,9 @@ pub fn issue_invoice_impl(
.map_err(db)?
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
validate_series_format(&prefix, padding)
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?;
validate_series_format(&prefix, padding).map_err(|e| {
format!("The active invoice series cannot be used: {e}. Start a new series.")
})?;
let number = format_number(&prefix, padding, next_number);
if number.len() > MAX_NUMBER_LEN {
return Err(format!(
@@ -307,10 +311,16 @@ pub fn issue_invoice_impl(
};
let pos = input.place_of_supply_state_code.trim().to_string();
if !pos.is_empty() && !gst::state_exists(&pos) {
return Err(format!("Place of supply \"{pos}\" is not a valid GST state code"));
return Err(format!(
"Place of supply \"{pos}\" is not a valid GST state code"
));
}
let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos);
let derived = gst::derive_tax_type(
&settings.gst_registration,
&settings.vendor_state_code,
&pos,
);
if input.tax_type != derived.as_str() {
return Err(format!(
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
@@ -320,7 +330,11 @@ pub fn issue_invoice_impl(
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
));
}
let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate };
let tax_rate = if derived == TaxType::None {
0.0
} else {
input.tax_rate
};
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
return Err("Tax rate must be between 0 and 100".into());
}
@@ -341,9 +355,9 @@ pub fn issue_invoice_impl(
let snapshot = vendor_snapshot(&settings);
let bank = bank_snapshot(&tx, input.bank_account_id)?;
let signature_path = match input.signature_path.as_deref().map(str::trim) {
Some(p) if !p.is_empty() => Some(
relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?,
),
Some(p) if !p.is_empty() => {
Some(relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?)
}
_ => None,
};
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
@@ -456,8 +470,11 @@ pub fn issue_invoice_impl(
.map_err(db)?;
if let Some(draft_id) = input.draft_id {
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
.map_err(db)?;
tx.execute(
"DELETE FROM invoice_drafts WHERE id = ?1",
params![draft_id],
)
.map_err(db)?;
}
tx.commit().map_err(db)?;
@@ -475,9 +492,17 @@ pub fn issue_invoice(
}
/// Issued invoices are never deleted: cancelling keeps the row and its number.
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
pub fn cancel_invoice_impl(
conn: &mut Connection,
id: i64,
reason: &str,
) -> Result<Invoice, String> {
let payments: i64 = conn
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
.query_row(
"SELECT COUNT(*) FROM payments WHERE invoice_id = ?1",
params![id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
if payments > 0 {
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
@@ -493,7 +518,11 @@ pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Resu
.map_err(|e| e.to_string())?;
if changed == 0 {
let status: Option<String> = conn
.query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
.query_row(
"SELECT status FROM invoices WHERE id = ?1",
params![id],
|r| r.get(0),
)
.optional()
.map_err(|e| e.to_string())?;
return Err(match status {
@@ -536,7 +565,14 @@ pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, Stri
let status: String = row.get(6)?;
let due_date: String = row.get(3)?;
let total_paise = gst::rupees_to_paise(total);
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
let payment = derive_summary(
&status,
total_paise,
row.get(9)?,
row.get(10)?,
&due_date,
&today,
);
Ok(InvoiceSummary {
id: row.get(0)?,
number: row.get(1)?,
@@ -577,7 +613,8 @@ pub struct InvoiceLedgerRow {
}
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> =
std::collections::HashMap::new();
{
let mut stmt = conn
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
@@ -677,11 +714,17 @@ pub fn save_draft_impl(
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
let mut stmt = conn
.prepare("SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC")
.prepare(
"SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?))
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
@@ -693,7 +736,10 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
DraftSummary {
id,
updated_at,
client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from),
client_name: json
.get("clientName")
.and_then(|v| v.as_str())
.map(String::from),
total: json.get("total").and_then(|v| v.as_f64()),
}
})
@@ -702,7 +748,11 @@ pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String>
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
let text: String = conn
.query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0))
.query_row(
"SELECT payload FROM invoice_drafts WHERE id = ?1",
params![id],
|r| r.get(0),
)
.optional()
.map_err(|e| e.to_string())?
.ok_or_else(|| "Draft not found".to_string())?;
@@ -776,19 +826,28 @@ mod tests {
}
fn next_number(conn: &Connection) -> i64 {
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
.unwrap()
conn.query_row(
"SELECT next_number FROM invoice_series WHERE is_active = 1",
[],
|r| r.get(0),
)
.unwrap()
}
fn invoice_count(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
.unwrap()
}
#[test]
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
let mut conn = registered();
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
conn.execute(
"UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1",
params![first.id],
)
.unwrap();
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
params![first.id],
@@ -802,7 +861,10 @@ mod tests {
assert_eq!(row.doc_type, "tax_invoice");
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
assert_eq!(row.taxable_paise, 731_000);
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
assert_eq!(
(row.cgst_paise, row.sgst_paise, row.igst_paise),
(65_790, 65_790, 0)
);
assert!(!row.reverse_charge);
}
@@ -910,7 +972,10 @@ mod tests {
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
)
.unwrap_err();
assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}");
assert!(
err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""),
"{err}"
);
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
assert!(err.contains("does not match"), "{err}");
assert_eq!(next_number(&conn), 1);
@@ -920,7 +985,11 @@ mod tests {
#[test]
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
let mut conn = crate::db::open_in_memory().unwrap();
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap();
conn.execute(
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'",
[],
)
.unwrap();
let inv = issue(
&mut conn,
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
@@ -977,7 +1046,8 @@ mod tests {
assert!(inv.amount_in_words.contains("Eight Thousand"));
// Later settings edits do not touch the stored snapshot.
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap();
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", [])
.unwrap();
let again = fetch_invoice(&conn, inv.id).unwrap();
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
}
@@ -985,7 +1055,9 @@ mod tests {
#[test]
fn bank_snapshot_is_built_from_the_bank_row() {
let mut conn = registered();
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
let bank_id: i64 = conn
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
.unwrap();
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
assert_eq!(bank["bankName"], "Test Bank");
@@ -1004,15 +1076,22 @@ mod tests {
params![format!("INV/{year}-001")],
)
.unwrap();
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", [])
.unwrap();
conn.execute(
"INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')",
[],
)
.unwrap();
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
assert!(err.contains("already exists"), "{err}");
assert_eq!(next_number(&conn), 1);
assert_eq!(invoice_count(&conn), 1);
// The client insert and the draft delete were rolled back too.
let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap();
let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap();
let clients: i64 = conn
.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0))
.unwrap();
let drafts: i64 = conn
.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0))
.unwrap();
assert_eq!((clients, drafts), (0, 1));
}
@@ -1027,10 +1106,19 @@ mod tests {
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
"greater than zero",
),
(json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"),
(json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"),
(
json!({"items": [{"mode": "fixed", "amount": -1.0}]}),
"zero or more",
),
(
json!({"items": [{"mode": "weird", "amount": 1.0}]}),
"unknown line type",
),
(json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"),
(json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"),
(
json!({"placeOfSupplyStateCode": "99"}),
"not a valid GST state code",
),
(json!({"taxRate": 150.0}), "between 0 and 100"),
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
];
@@ -1049,31 +1137,58 @@ mod tests {
#[test]
fn supplier_settings_are_validated() {
let mut conn = registered();
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit"));
conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN"));
conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29"));
conn.execute(
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'",
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("check digit"));
conn.execute("UPDATE app_settings SET vendor_gstin = ''", [])
.unwrap();
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("needs a GSTIN"));
conn.execute(
"UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'",
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("state code 29"));
conn.execute(
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN"));
conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", [])
.unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code"));
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("PAN"));
conn.execute(
"UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'",
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("state code"));
assert_eq!(next_number(&conn), 1);
}
#[test]
fn legacy_series_that_break_the_rules_are_refused() {
let mut conn = registered();
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap();
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", [])
.unwrap();
let err = issue(&mut conn, input(json!({}))).unwrap_err();
assert!(err.contains("active invoice series"), "{err}");
conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap();
conn.execute(
"UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3",
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({}))).is_err());
// A counter that outgrows its padding can push the number past 16 characters.
conn.execute(
@@ -1081,7 +1196,9 @@ mod tests {
[],
)
.unwrap();
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16"));
assert!(issue(&mut conn, input(json!({})))
.unwrap_err()
.contains("longer than 16"));
assert_eq!(invoice_count(&conn), 0);
}
@@ -1109,7 +1226,10 @@ mod tests {
&json!({"theme": "plain"}),
)
.unwrap();
assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png"));
assert_eq!(
inv.signature_path.as_deref(),
Some("assets/signature-1.png")
);
let prefs: String = conn
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
.unwrap();
@@ -1130,8 +1250,14 @@ mod tests {
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
assert!(err.contains("cancelled"), "{err}");
assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found"));
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap();
assert!(cancel_invoice_impl(&mut conn, 999, "")
.unwrap_err()
.contains("not found"));
conn.execute(
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
params![inv.id],
)
.unwrap();
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
// The number is never reused.
@@ -1143,10 +1269,16 @@ mod tests {
#[test]
fn drafts_round_trip() {
let conn = crate::db::open_in_memory().unwrap();
let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
let a =
save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
assert_ne!(a, b);
let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap();
let c = save_draft_impl(
&conn,
Some(a),
&json!({"clientName": "Acme 2", "total": 10}),
)
.unwrap();
assert_eq!(c, a);
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
@@ -1159,7 +1291,8 @@ mod tests {
let blank = list.iter().find(|d| d.id == b).unwrap();
assert_eq!((blank.client_name.clone(), blank.total), (None, None));
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap();
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a])
.unwrap();
assert!(get_draft_impl(&conn, a).is_err());
}
}