use super::assets::relative_asset_path; 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 crate::models::{ BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings, }; use crate::AppState; use chrono::NaiveDate; use rusqlite::{named_params, params, Connection, OptionalExtension, Row}; use std::path::Path; use tauri::State; const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name, client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words, bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at, doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at, cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, ''), archived_fingerprint, archived_at"; fn map_invoice(row: &Row) -> rusqlite::Result { Ok(Invoice { id: row.get(0)?, number: row.get(1)?, series_id: row.get(2)?, invoice_date: row.get(3)?, due_date: row.get(4)?, client_id: row.get(5)?, client_name: row.get(6)?, client_address: row.get(7)?, client_gstin: row.get(8)?, po_number: row.get(9)?, place_of_supply_state_code: row.get(10)?, subtotal: row.get(11)?, discount: row.get(12)?, tax_type: row.get(13)?, tax_rate: row.get(14)?, cgst_amount: row.get(15)?, sgst_amount: row.get(16)?, igst_amount: row.get(17)?, total: row.get(18)?, amount_in_words: row.get(19)?, bank_account_id: row.get(20)?, bank_snapshot: row.get(21)?, signature_path: row.get(22)?, notes: row.get(23)?, status: row.get(24)?, created_at: row.get(25)?, updated_at: row.get(26)?, doc_type: row.get(27)?, reverse_charge: row.get::<_, i64>(28)? != 0, vendor_snapshot: row.get(29)?, snapshot_origin: row.get(30)?, cancelled_at: row.get(31)?, cancel_reason: row.get(32)?, archived_pdf_sha256: row.get(33)?, render_prefs: row.get(34)?, archived_fingerprint: row.get(35)?, archived_at: row.get(36)?, items: Vec::new(), }) } fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result> { let mut stmt = conn.prepare( "SELECT id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC", )?; let rows = stmt.query_map(params![invoice_id], |row| { Ok(InvoiceItem { id: Some(row.get(0)?), description: row.get(1)?, mode: row.get(2)?, rate: row.get(3)?, unit: row.get(4)?, quantity: row.get(5)?, amount: row.get(6)?, sort_order: row.get(7)?, hsn_sac: row.get(8)?, }) })?; rows.collect() } fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result { let mut invoice = conn.query_row( &format!("SELECT {} FROM invoices WHERE id = ?1", INVOICE_COLS), params![id], map_invoice, )?; invoice.items = fetch_items(conn, id)?; Ok(invoice) } #[tauri::command] pub fn peek_next_invoice_number(state: State) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let series: Option<(String, i64, i64)> = conn .query_row( "SELECT prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional() .map_err(|e| e.to_string())?; match series { Some((prefix, padding, next)) => Ok(format_number(&prefix, padding, next)), None => Err("No active invoice series. Create one under Series.".into()), } } fn paise_to_rupees(paise: i64) -> f64 { paise as f64 / 100.0 } fn parse_date(label: &str, value: &str) -> Result<(), String> { NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d") .map(|_| ()) .map_err(|_| format!("{label} must be a date like 2026-04-01")) } /// Largest rupee amount accepted for a single line, far below anything that could /// overflow integer paise arithmetic. const MAX_LINE_RUPEES: f64 = 1e11; fn validate_items(items: &[InvoiceItem]) -> Result<(), String> { if items.is_empty() { return Err("Add at least one line item".into()); } for (i, item) in items.iter().enumerate() { let n = i + 1; let finite_ok = |v: f64| v.is_finite() && (0.0..=MAX_LINE_RUPEES).contains(&v); 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")); } if item.quantity > MAX_LINE_RUPEES { return Err(format!("Line {n}: quantity is too large")); } let amount = gst::line_amount_paise(item); if amount <= 0 { return Err(format!("Line {n}: amount must be greater than zero")); } if amount as f64 / 100.0 > MAX_LINE_RUPEES { return Err(format!("Line {n}: amount is too large")); } } "fixed" => { if !finite_ok(item.amount) { return Err(format!("Line {n}: amount must be zero or more")); } } other => return Err(format!("Line {n}: unknown line type \"{other}\"")), } } Ok(()) } /// Checks that the supplier details in settings can legally produce this document. fn validate_supplier(settings: &Settings) -> Result<(), String> { if !gst::state_exists(&settings.vendor_state_code) { return Err(format!( "Supplier state code \"{}\" is not a valid GST state code. Fix it in Settings.", settings.vendor_state_code )); } match settings.gst_registration.as_str() { "unregistered" => Ok(()), "regular" => { let gstin = settings.vendor_gstin.trim().to_ascii_uppercase(); if gstin.is_empty() { return Err("A registered supplier needs a GSTIN. Add it in Settings.".into()); } gst::validate_gstin(&gstin).map_err(|e| format!("Supplier GSTIN: {e}"))?; if gstin[0..2] != settings.vendor_state_code { return Err(format!( "Supplier GSTIN starts with state code {} but the supplier state is {}", &gstin[0..2], settings.vendor_state_code )); } let pan = settings.vendor_pan.trim(); if !pan.is_empty() && !gst::gstin_matches_pan(&gstin, pan) { return Err("Supplier GSTIN does not contain the supplier PAN".into()); } Ok(()) } other => Err(format!("Unsupported GST registration type \"{other}\"")), } } /// Supplier details frozen onto the invoice. Built from the stored settings, never /// from anything the webview sent. An unregistered supplier has no GSTIN to print. fn vendor_snapshot(settings: &Settings) -> String { let gstin = if settings.gst_registration == "unregistered" { "" } else { settings.vendor_gstin.trim() }; serde_json::json!({ "vendorName": settings.vendor_name, "vendorAddress": settings.vendor_address, "vendorEmail": settings.vendor_email, "vendorPhone": settings.vendor_phone, "vendorPan": settings.vendor_pan, "vendorGstin": gstin, "vendorStateCode": settings.vendor_state_code, "logoPath": settings.logo_path, "logoKnockoutPath": settings.logo_knockout_path, "logoMeta": settings.logo_meta, "logoIncludesName": settings.logo_includes_name, "signaturePath": settings.signature_path, "gstRegistration": settings.gst_registration, "signatoryName": settings.signatory_name, "signatoryDesignation": settings.signatory_designation, }) .to_string() } fn bank_snapshot(conn: &Connection, bank_id: Option) -> Result { let Some(id) = bank_id else { return Ok(String::new()); }; let bank: BankAccount = 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, ) .optional() .map_err(|e| e.to_string())? .ok_or_else(|| "The selected bank account no longer exists".to_string())?; serde_json::to_string(&bank).map_err(|e| e.to_string()) } /// Validate, price and store an invoice in one transaction. The series counter only /// moves if everything succeeded. pub fn issue_invoice_impl( conn: &mut Connection, data_dir: &Path, input: InvoiceInput, render_prefs: &serde_json::Value, ) -> Result { let db = |e: rusqlite::Error| e.to_string(); let tx = conn.transaction().map_err(db)?; let settings: Settings = tx .query_row( &format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS), [], map_settings, ) .map_err(db)?; let (series_id, prefix, padding, next_number): (i64, String, i64, i64) = tx .query_row( "SELECT id, prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), ) .optional() .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."))?; let number = format_number(&prefix, padding, next_number); if number.len() > MAX_NUMBER_LEN { return Err(format!( "Invoice number {number} is longer than {MAX_NUMBER_LEN} characters. Start a new series." )); } validate_supplier(&settings)?; parse_date("Invoice date", &input.invoice_date)?; if !input.due_date.trim().is_empty() { parse_date("Due date", &input.due_date)?; } validate_items(&input.items)?; let client_gstin = match input.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 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")); } 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 {}", input.tax_type, derived.as_str(), settings.vendor_state_code, if pos.is_empty() { &settings.vendor_state_code } else { &pos }, )); } 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()); } let rate_bp = (tax_rate * 100.0).round() as i64; let stored_pos = if pos.is_empty() && derived != TaxType::None { settings.vendor_state_code.clone() } else { pos }; let totals = gst::compute_totals( &input.items, gst::rupees_to_paise(input.discount), derived, rate_bp, ); let words = gst::amount_in_words(totals.total); 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}"))?, ), _ => None, }; let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?; let now = chrono::Utc::now().to_rfc3339(); // Persist the client first when the user asked to save a new one. let client_id = match input.client_id { 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)", params![ input.client_name, input.client_address, client_gstin, stored_pos, input.po_number, now ], ) .map_err(db)?; Some(tx.last_insert_rowid()) } None => None, }; tx.execute( r#"INSERT INTO invoices (number, series_id, invoice_date, due_date, client_id, client_name, client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words, bank_account_id, bank_snapshot, signature_path, notes, status, doc_type, reverse_charge, vendor_snapshot, render_prefs, snapshot_origin, created_at, updated_at) VALUES (:number, :series_id, :invoice_date, :due_date, :client_id, :client_name, :client_address, :client_gstin, :po_number, :pos, :subtotal, :discount, :tax_type, :tax_rate, :cgst, :sgst, :igst, :total, :words, :bank_id, :bank_snapshot, :signature_path, :notes, 'issued', :doc_type, :reverse_charge, :vendor_snapshot, :render_prefs, 'issued', :now, :now)"#, named_params! { ":number": number, ":series_id": series_id, ":invoice_date": input.invoice_date.trim(), ":due_date": input.due_date.trim(), ":client_id": client_id, ":client_name": input.client_name, ":client_address": input.client_address, ":client_gstin": client_gstin, ":po_number": input.po_number, ":pos": stored_pos, ":subtotal": paise_to_rupees(totals.subtotal), ":discount": paise_to_rupees(totals.discount), ":tax_type": derived.as_str(), ":tax_rate": tax_rate, ":cgst": paise_to_rupees(totals.cgst), ":sgst": paise_to_rupees(totals.sgst), ":igst": paise_to_rupees(totals.igst), ":total": paise_to_rupees(totals.total), ":words": words, ":bank_id": input.bank_account_id, ":bank_snapshot": bank, ":signature_path": signature_path, ":notes": input.notes, ":doc_type": gst::doc_type(&settings.gst_registration), ":reverse_charge": input.reverse_charge, ":vendor_snapshot": snapshot, ":render_prefs": prefs, ":now": now, }, ) .map_err(|e| { if e.to_string().contains("UNIQUE") { format!("Invoice number {number} already exists. Start a new series or fix the counter.") } else { e.to_string() } })?; let invoice_id = tx.last_insert_rowid(); { let mut stmt = tx .prepare( "INSERT INTO invoice_items (invoice_id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", ) .map_err(db)?; for (i, item) in input.items.iter().enumerate() { stmt.execute(params![ invoice_id, item.description, item.mode, item.rate, item.unit, item.quantity, paise_to_rupees(gst::line_amount_paise(item)), i as i64, item.hsn_sac.trim(), ]) .map_err(db)?; } } // Advance the serial for the next invoice in this series. tx.execute( "UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1", params![series_id], ) .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.commit().map_err(db)?; fetch_invoice(conn, invoice_id).map_err(db) } #[tauri::command] pub fn issue_invoice( state: State, input: InvoiceInput, render_prefs: serde_json::Value, ) -> Result { let mut conn = state.db.lock().map_err(|e| e.to_string())?; issue_invoice_impl(&mut conn, &state.data_dir, input, &render_prefs) } /// 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 { let now = chrono::Utc::now().to_rfc3339(); let changed = conn .execute( "UPDATE invoices SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2, updated_at = ?1 WHERE id = ?3 AND status = 'issued'", params![now, reason.trim(), id], ) .map_err(|e| e.to_string())?; if changed == 0 { let status: Option = conn .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 { None => "Invoice not found".to_string(), Some(s) => format!("Only an issued invoice can be cancelled (this one is {s})"), }); } fetch_invoice(conn, id).map_err(|e| e.to_string()) } #[tauri::command] pub fn cancel_invoice(state: State, id: i64, reason: String) -> Result { let mut conn = state.db.lock().map_err(|e| e.to_string())?; cancel_invoice_impl(&mut conn, id, &reason) } #[tauri::command] pub fn list_invoices(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT id, number, invoice_date, due_date, client_name, total, status, created_at FROM invoices ORDER BY id DESC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], |row| { Ok(InvoiceSummary { id: row.get(0)?, number: row.get(1)?, invoice_date: row.get(2)?, due_date: row.get(3)?, client_name: row.get(4)?, total: row.get(5)?, status: row.get(6)?, created_at: row.get(7)?, }) }) .map_err(|e| e.to_string())? .collect::>>() .map_err(|e| e.to_string())?; Ok(rows) } #[tauri::command] pub fn get_invoice(state: State, id: i64) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; fetch_invoice(&conn, id).map_err(|e| e.to_string()) } pub fn save_draft_impl( conn: &Connection, id: Option, payload: &serde_json::Value, ) -> Result { let text = serde_json::to_string(payload).map_err(|e| e.to_string())?; let now = chrono::Utc::now().to_rfc3339(); match id { Some(id) => { let changed = conn .execute( "UPDATE invoice_drafts SET payload = ?1, updated_at = ?2 WHERE id = ?3", params![text, now, id], ) .map_err(|e| e.to_string())?; if changed == 0 { return Err("Draft not found".into()); } Ok(id) } None => { conn.execute( "INSERT INTO invoice_drafts (payload, updated_at) VALUES (?1, ?2)", params![text, now], ) .map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } } } pub fn list_drafts_impl(conn: &Connection) -> Result, String> { let mut stmt = conn .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)?)) }) .map_err(|e| e.to_string())? .collect::>>() .map_err(|e| e.to_string())?; Ok(rows .into_iter() .map(|(id, payload, updated_at)| { let json: serde_json::Value = serde_json::from_str(&payload).unwrap_or_default(); DraftSummary { id, updated_at, client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from), total: json.get("total").and_then(|v| v.as_f64()), } }) .collect()) } pub fn get_draft_impl(conn: &Connection, id: i64) -> Result { let text: String = conn .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())?; serde_json::from_str(&text).map_err(|e| format!("Draft is corrupted: {e}")) } #[tauri::command] pub fn save_draft( state: State, id: Option, payload: serde_json::Value, ) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; save_draft_impl(&conn, id, &payload) } #[tauri::command] pub fn list_drafts(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; list_drafts_impl(&conn) } #[tauri::command] pub fn get_draft(state: State, id: i64) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; get_draft_impl(&conn, id) } #[tauri::command] pub fn delete_draft(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } #[cfg(test)] mod tests { use super::*; use serde_json::json; use tempfile::tempdir; fn registered() -> Connection { let conn = crate::db::open_in_memory().unwrap(); conn.execute( "UPDATE app_settings SET gst_registration = 'regular', vendor_gstin = '27AAPFU0939F1ZV', vendor_state_code = '27', vendor_pan = 'AAPFU0939F', default_tax_type = 'cgst_sgst'", [], ) .unwrap(); conn } fn input(extra: serde_json::Value) -> InvoiceInput { let mut base = json!({ "invoiceDate": "2026-04-01", "dueDate": "2026-05-01", "clientName": "Client Ltd", "taxType": "cgst_sgst", "taxRate": 18.0, "items": [{"description": "Design", "mode": "fixed", "amount": 7310.0}], }); for (k, v) in extra.as_object().unwrap() { base[k] = v.clone(); } serde_json::from_value(base).unwrap() } fn issue(conn: &mut Connection, input: InvoiceInput) -> Result { issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})) } fn next_number(conn: &Connection) -> i64 { 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() } #[test] fn issues_numbered_tax_invoices_with_derived_totals() { let mut conn = registered(); let year = chrono::Local::now().format("%Y"); let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap(); assert_eq!(first.number, format!("INV/{year}-001")); assert_eq!(first.status, "issued"); assert_eq!(first.doc_type, "tax_invoice"); assert_eq!(first.snapshot_origin, "issued"); assert_eq!(first.subtotal, 7310.0); assert_eq!(first.cgst_amount, 657.9); assert_eq!(first.sgst_amount, 657.9); assert_eq!(first.igst_amount, 0.0); assert_eq!(first.total, 8625.8); assert_eq!( first.amount_in_words, "Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only" ); assert_eq!(first.place_of_supply_state_code, "27"); assert_eq!(first.items.len(), 1); assert_eq!(first.items[0].amount, 7310.0); let second = issue(&mut conn, input(json!({}))).unwrap(); assert_eq!(second.number, format!("INV/{year}-002")); // An empty place of supply is stored as the supplier's own state. assert_eq!(second.place_of_supply_state_code, "27"); assert_eq!(next_number(&conn), 3); } #[test] fn inter_state_supply_is_igst_with_hsn_and_reverse_charge() { let mut conn = registered(); let inv = issue( &mut conn, input(json!({ "placeOfSupplyStateCode": "29", "taxType": "igst", "taxRate": 5.0, "reverseCharge": true, "discount": 10.0, "items": [ {"description": "Hours", "mode": "rate", "rate": 1200.0, "quantity": 1.5, "unit": "hour", "hsnSac": " 998314 "}, {"description": "Fee", "mode": "fixed", "amount": 33.33}, ], })), ) .unwrap(); assert_eq!(inv.subtotal, 1833.33); assert_eq!(inv.discount, 10.0); // (1833.33 - 10.00) = 1823.33 -> 5% = 91.1665 -> 91.17 assert_eq!(inv.igst_amount, 91.17); assert_eq!(inv.cgst_amount, 0.0); assert_eq!(inv.total, 1914.5); assert!(inv.reverse_charge); assert_eq!(inv.items[0].hsn_sac, "998314"); assert_eq!(inv.items[0].amount, 1800.0); assert_eq!(inv.items[1].hsn_sac, ""); } #[test] fn tax_type_mismatch_is_rejected_and_the_counter_stays() { let mut conn = registered(); let err = issue( &mut conn, input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})), ) .unwrap_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); assert_eq!(invoice_count(&conn), 0); } #[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(); let inv = issue( &mut conn, input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})), ) .unwrap(); assert_eq!(inv.doc_type, "invoice"); assert_eq!(inv.tax_type, "none"); assert_eq!(inv.tax_rate, 0.0); assert_eq!(inv.total, 7310.0); assert_eq!(inv.cgst_amount + inv.sgst_amount + inv.igst_amount, 0.0); // An unregistered supplier's snapshot must not carry a GSTIN. let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap(); assert_eq!(snap["vendorGstin"], ""); assert_eq!(snap["gstRegistration"], "unregistered"); let err = issue(&mut conn, input(json!({"taxType": "igst"}))).unwrap_err(); assert!(err.contains("does not match \"none\""), "{err}"); } #[test] fn vendor_snapshot_comes_from_settings_not_input() { let mut conn = registered(); conn.execute( "UPDATE app_settings SET vendor_name = 'Real Name', logo_path = 'assets/logo-1.png', logo_knockout_path = 'assets/logo-1-knockout.png', logo_meta = '{\"kind\":\"wordmark\"}', logo_includes_name = 'yes', signatory_name = 'A Signer', signatory_designation = 'Partner'", [], ) .unwrap(); // Fields the webview might still send are ignored. let inv = issue( &mut conn, input(json!({ "vendorName": "Forged", "vendorSnapshot": "{\"vendorName\":\"Forged\"}", "total": 1.0, "subtotal": 1.0, "cgstAmount": 99.0, "amountInWords": "Free", "bankSnapshot": "{\"bankName\":\"Forged\"}", })), ) .unwrap(); let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap(); assert_eq!(snap["vendorName"], "Real Name"); assert_eq!(snap["vendorGstin"], "27AAPFU0939F1ZV"); assert_eq!(snap["vendorStateCode"], "27"); assert_eq!(snap["logoPath"], "assets/logo-1.png"); assert_eq!(snap["logoKnockoutPath"], "assets/logo-1-knockout.png"); assert_eq!(snap["logoMeta"], "{\"kind\":\"wordmark\"}"); assert_eq!(snap["logoIncludesName"], "yes"); assert!(snap["signaturePath"].is_null()); assert_eq!(snap["gstRegistration"], "regular"); assert_eq!(snap["signatoryName"], "A Signer"); assert_eq!(snap["signatoryDesignation"], "Partner"); assert_eq!(inv.total, 8625.8); assert_eq!(inv.bank_snapshot, ""); 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(); let again = fetch_invoice(&conn, inv.id).unwrap(); assert_eq!(again.vendor_snapshot, inv.vendor_snapshot); } #[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 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"], "State Bank of India"); let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err(); assert!(err.contains("bank account"), "{err}"); } #[test] fn failure_after_validation_leaves_the_series_untouched() { let mut conn = registered(); // Occupy the next number so the INSERT hits the UNIQUE constraint mid-transaction. let year = chrono::Local::now().format("%Y"); conn.execute( "INSERT INTO invoices (number, invoice_date, created_at, updated_at) VALUES (?1, '2026-01-01', 'now', 'now')", params![format!("INV/{year}-001")], ) .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(); assert_eq!((clients, drafts), (0, 1)); } #[test] fn validation_failures_do_not_consume_a_number() { let mut conn = registered(); let bad = [ (json!({"items": []}), "at least one line"), (json!({"invoiceDate": "01/04/2026"}), "Invoice date"), (json!({"dueDate": "soon"}), "Due date"), ( 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!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"), (json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"), (json!({"taxRate": 150.0}), "between 0 and 100"), (json!({"signaturePath": "../voiced.db"}), "Signature image"), ]; for (extra, needle) in bad { let err = issue(&mut conn, input(extra)).unwrap_err(); assert!(err.contains(needle), "expected {needle:?} in {err:?}"); } assert_eq!(next_number(&conn), 1); assert_eq!(invoice_count(&conn), 0); // A valid client GSTIN, lowercase, is accepted and normalised. let ok = issue(&mut conn, input(json!({"clientGstin": "29aagcb7383j1z4"}))).unwrap(); assert_eq!(ok.client_gstin, "29AAGCB7383J1Z4"); } #[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 = '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_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(); 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(); assert!(issue(&mut conn, input(json!({}))).is_err()); // A counter that outgrows its padding can push the number past 16 characters. conn.execute( "UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLM', padding = 3, next_number = 10000", [], ) .unwrap(); assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16")); assert_eq!(invoice_count(&conn), 0); } #[test] fn issuing_deletes_the_draft() { let mut conn = registered(); let draft = save_draft_impl(&conn, None, &json!({"clientName": "X"})).unwrap(); let other = save_draft_impl(&conn, None, &json!({"clientName": "Y"})).unwrap(); issue(&mut conn, input(json!({"draftId": draft}))).unwrap(); assert!(get_draft_impl(&conn, draft).is_err()); assert!(get_draft_impl(&conn, other).is_ok()); } #[test] fn signature_path_is_stored_relative() { let dir = tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("assets")).unwrap(); std::fs::write(dir.path().join("assets/signature-1.png"), b"x").unwrap(); let mut conn = registered(); let abs = dir.path().join("assets/signature-1.png"); let inv = issue_invoice_impl( &mut conn, dir.path(), input(json!({"signaturePath": abs.to_str().unwrap()})), &json!({"theme": "plain"}), ) .unwrap(); 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(); assert_eq!(prefs, "{\"theme\":\"plain\"}"); assert_eq!(inv.render_prefs, prefs); } #[test] fn cancelling_keeps_the_number_and_only_works_once() { let mut conn = registered(); let inv = issue(&mut conn, input(json!({}))).unwrap(); let cancelled = cancel_invoice_impl(&mut conn, inv.id, " duplicate ").unwrap(); assert_eq!(cancelled.status, "cancelled"); assert_eq!(cancelled.number, inv.number); assert_eq!(cancelled.cancel_reason, "duplicate"); assert!(cancelled.cancelled_at.is_some()); assert_eq!(cancelled.total, inv.total); 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, inv.id, "").is_err()); // The number is never reused. let next = issue(&mut conn, input(json!({}))).unwrap(); assert_ne!(next.number, inv.number); assert_eq!(next_number(&conn), 3); } #[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 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(); assert_eq!(c, a); assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err()); assert_eq!(get_draft_impl(&conn, a).unwrap()["clientName"], "Acme 2"); let list = list_drafts_impl(&conn).unwrap(); assert_eq!(list.len(), 2); let acme = list.iter().find(|d| d.id == a).unwrap(); assert_eq!(acme.client_name.as_deref(), Some("Acme 2")); assert_eq!(acme.total, Some(10.0)); 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(); assert!(get_draft_impl(&conn, a).is_err()); } }