Files
Voiced/src-tauri/src/commands/invoice.rs
T
xavierkandCommandCodeBot 875a12b2e6 Initial commit: Voiced invoice desktop app
Offline-first invoicing app for Test Vendor, built by Bongbetic.

- Tauri v2 (Rust) + React/TypeScript + IBM Carbon Design System UI
- SQLite (rusqlite) storage with a persistent invoice-number series
- Guided first-run setup, pre-filled vendor/bank details
- Fixed-amount or rate x quantity line items (per sec/min/hour/session/unit)
- GST as CGST+SGST or IGST, Indian amount-in-words
- Optional signature, with a computer-generated note as fallback
- Print-ready PDF export via @react-pdf/renderer
- Windows cross-compile via cargo-xwin (NSIS installer)

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-10-03 23:07:51 +05:30

256 lines
8.8 KiB
Rust

use crate::db::format_number;
use crate::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary};
use crate::AppState;
use rusqlite::{params, Connection, OptionalExtension, Row};
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";
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)?,
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
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)?,
})
})?;
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()),
}
}
#[tauri::command]
pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Invoice, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
let tx = conn.transaction().map_err(|e| e.to_string())?;
let series = 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::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
},
)
.optional()
.map_err(|e| e.to_string())?
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
let (series_id, prefix, padding, next_number) = series;
let number = format_number(&prefix, padding, next_number);
// 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,
input.client_gstin,
input.place_of_supply_state_code,
input.po_number,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(|e| e.to_string())?;
Some(tx.last_insert_rowid())
}
None => None,
};
let now = chrono::Utc::now().to_rfc3339();
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, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#,
params![
number,
series_id,
input.invoice_date,
input.due_date,
client_id,
input.client_name,
input.client_address,
input.client_gstin,
input.po_number,
input.place_of_supply_state_code,
input.subtotal,
input.discount,
input.tax_type,
input.tax_rate,
input.cgst_amount,
input.sgst_amount,
input.igst_amount,
input.total,
input.amount_in_words,
input.bank_account_id,
input.bank_snapshot,
input.signature_path,
input.notes,
now,
],
)
.map_err(|e| 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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.map_err(|e| e.to_string())?;
for (i, item) in input.items.iter().enumerate() {
stmt.execute(params![
invoice_id,
item.description,
item.mode,
item.rate,
item.unit,
item.quantity,
item.amount,
i as i64,
])
.map_err(|e| e.to_string())?;
}
}
// 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(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string())
}
#[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())
}
#[tauri::command]
pub fn delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}