Files
Voiced/src-tauri/src/commands/invoice.rs
T
xavierk d1976499a2 Merge origin/main (CI quality gates) into the 1.1.0 release
# Conflicts:
#	package-lock.json
#	src/views/NewInvoice.tsx
2026-10-06 09:43:47 +05:30

1299 lines
48 KiB
Rust

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 crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
Settings,
};
use crate::units;
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(),
payment: PaymentSummary::default(),
total_paise: 0,
})
}
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)?;
invoice.total_paise = gst::rupees_to_paise(invoice.total);
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
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;
/// The unit written to the database. A fixed-amount line has no unit of its own, so whatever the form held is not kept.
fn stored_unit(item: &InvoiceItem) -> String {
if item.mode == "rate" {
units::normalize(&item.unit)
} else {
"unit".to_string()
}
}
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"));
}
units::validate(&item.unit).map_err(|e| format!("Line {n}: {e}"))?;
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, 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,
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
],
)
.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,
stored_unit(item),
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 payments: i64 = conn
.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());
}
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())?;
list_invoices_impl(&conn)
}
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
/// (`derive_summary`) so list and detail agree.
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
let today = today_ist();
let mut stmt = conn
.prepare(
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
FROM invoices i
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
ORDER BY i.id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
let total: f64 = row.get(5)?;
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,
);
Ok(InvoiceSummary {
id: row.get(0)?,
number: row.get(1)?,
invoice_date: row.get(2)?,
due_date,
client_name: row.get(4)?,
total,
status,
created_at: row.get(7)?,
po_number: row.get(8)?,
total_paise,
payment,
})
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceLedgerRow {
pub id: i64,
pub client_gstin: String,
pub place_of_supply_code: String,
pub doc_type: String,
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
pub hsn_sac: Vec<String>,
/// Subtotal less discount.
pub taxable_paise: i64,
pub cgst_paise: i64,
pub sgst_paise: i64,
pub igst_paise: i64,
pub reverse_charge: bool,
}
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 stmt = conn
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
.map_err(|e| e.to_string())?;
let items = stmt
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?;
for item in items {
let (id, code) = item.map_err(|e| e.to_string())?;
let list = hsn_by_invoice.entry(id).or_default();
if !code.is_empty() && !list.contains(&code) {
list.push(code);
}
}
}
let mut stmt = conn
.prepare(
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
cgst_amount, sgst_amount, igst_amount, reverse_charge
FROM invoices ORDER BY id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
let id: i64 = r.get(0)?;
Ok(InvoiceLedgerRow {
id,
client_gstin: r.get(1)?,
place_of_supply_code: r.get(2)?,
doc_type: r.get(3)?,
hsn_sac: Vec::new(),
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
cgst_paise: gst::rupees_to_paise(r.get(6)?),
sgst_paise: gst::rupees_to_paise(r.get(7)?),
igst_paise: gst::rupees_to_paise(r.get(8)?),
reverse_charge: r.get::<_, i64>(9)? != 0,
})
})
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|mut row| {
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
row
})
.collect())
}
#[tauri::command]
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_invoice_ledger_impl(&conn)
}
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
fetch_invoice(conn, id).map_err(|e| e.to_string())
}
#[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())?;
get_invoice_impl(&conn, id)
}
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 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(
"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],
)
.unwrap();
let ledger = list_invoice_ledger_impl(&conn).unwrap();
assert_eq!(ledger.len(), 1);
let row = &ledger[0];
assert_eq!(row.id, first.id);
assert_eq!(row.place_of_supply_code, "27");
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!(!row.reverse_charge);
}
#[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 lines_can_use_any_unit_and_fractional_quantities() {
let mut conn = registered();
let inv = issue(
&mut conn,
input(json!({
"items": [
{"description": "Travel", "mode": "rate", "rate": 18.0, "quantity": 12.75, "unit": "km"},
{"description": "Studio", "mode": "rate", "rate": 1200.0, "quantity": 4.0 + 1.0 / 3.0, "unit": "HOUR"},
{"description": "Parcels", "mode": "rate", "rate": 99.5, "quantity": 3.0, "unit": " bundle "},
{"description": "Fee", "mode": "fixed", "amount": 10.0, "unit": "not a unit;"},
],
})),
)
.unwrap();
let units: Vec<&str> = inv.items.iter().map(|i| i.unit.as_str()).collect();
assert_eq!(units, ["km", "hour", "bundle", "unit"]);
let amounts: Vec<f64> = inv.items.iter().map(|i| i.amount).collect();
// 18 x 12.75 = 229.50; 1200 x 4h20m = 5200.00 (one rounding, on the exact quantity); 99.50 x 3 = 298.50.
assert_eq!(amounts, [229.5, 5200.0, 298.5, 10.0]);
}
#[test]
fn a_rate_line_with_a_bad_unit_is_refused_and_the_counter_stays() {
let mut conn = registered();
for bad in ["", " ", "thirteen char", "x;y", "\u{20b9}"] {
let err = issue(
&mut conn,
input(json!({"items": [{"description": "Work", "mode": "rate", "rate": 10.0, "quantity": 1.0, "unit": bad}]})),
)
.unwrap_err();
assert!(err.starts_with("Line 1: "), "{bad:?}: {err}");
}
assert_eq!(next_number(&conn), 1);
assert_eq!(invoice_count(&conn), 0);
}
#[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"], "Test Bank");
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());
}
}