Files
Voiced/src-tauri/src/commands/invoice.rs
T
xavierk e1edf02448 Add Rust logo pipeline: trim, metrics, knockout and Branding settings
Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled
to 1200 px and re-encoded as a content-addressed print PNG plus a white
knockout variant when the logo has alpha; aspect, ink density, mean colour
and kind (wordmark, mark, tall) are stored with the settings and frozen in
the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo
PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with
white and dark previews and a business-name toggle (migration M5).
2026-10-04 06:31:30 +05:30

984 lines
39 KiB
Rust

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<Invoice> {
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<Vec<InvoiceItem>> {
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<Invoice> {
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<AppState>) -> Result<String, String> {
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<i64>) -> Result<String, String> {
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<Invoice, String> {
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<AppState>,
input: InvoiceInput,
render_prefs: serde_json::Value,
) -> Result<Invoice, String> {
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<Invoice, String> {
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<String> = 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<AppState>, id: i64, reason: String) -> Result<Invoice, String> {
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<AppState>) -> Result<Vec<InvoiceSummary>, 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::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
#[tauri::command]
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
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<i64>,
payload: &serde_json::Value,
) -> Result<i64, String> {
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<Vec<DraftSummary>, 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::<rusqlite::Result<Vec<_>>>()
.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<serde_json::Value, String> {
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<AppState>,
id: Option<i64>,
payload: serde_json::Value,
) -> Result<i64, String> {
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<AppState>) -> Result<Vec<DraftSummary>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_drafts_impl(&conn)
}
#[tauri::command]
pub fn get_draft(state: State<AppState>, id: i64) -> Result<serde_json::Value, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
get_draft_impl(&conn, id)
}
#[tauri::command]
pub fn delete_draft(state: State<AppState>, 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<Invoice, String> {
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());
}
}