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>
This commit is contained in:
2026-10-03 23:07:51 +05:30
co-authored by CommandCodeBot
commit 875a12b2e6
121 changed files with 13660 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::{Path, PathBuf};
use tauri::State;
fn safe_kind(kind: &str) -> String {
kind.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.to_lowercase()
}
fn sanitize_name(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
.collect()
}
fn extension_of(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_else(|| "png".to_string())
}
fn mime_for(ext: &str) -> &'static str {
match ext {
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"gif" => "image/gif",
_ => "image/png",
}
}
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> {
let dir = state.data_dir.join("assets");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> {
let dir = assets_dir(state)?;
let file = format!(
"{}-{}.{}",
safe_kind(kind),
uuid::Uuid::new_v4().simple(),
ext
);
let path = dir.join(file);
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
Ok(path.to_string_lossy().to_string())
}
/// Copy a user-picked file into the app's asset store and return the stored path.
#[tauri::command]
pub fn import_asset(
state: State<AppState>,
source_path: String,
kind: String,
) -> Result<String, String> {
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
let ext = extension_of(&source_path);
write_asset(&state, &kind, &ext, &bytes)
}
/// Used when the frontend already holds the bytes (base64) instead of a path.
#[tauri::command]
pub fn save_asset_bytes(
state: State<AppState>,
kind: String,
file_name: String,
data_base64: String,
) -> Result<String, String> {
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
let ext = extension_of(&sanitize_name(&file_name));
write_asset(&state, &kind, &ext, &bytes)
}
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
#[tauri::command]
pub fn read_asset_data_uri(path: String) -> Result<String, String> {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
let ext = extension_of(&path);
Ok(format!(
"data:{};base64,{}",
mime_for(&ext),
STANDARD.encode(bytes)
))
}
#[tauri::command]
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
let dir = assets_dir(&state)?;
let target = PathBuf::from(&path);
// Only allow deleting files inside the managed assets directory.
if target.starts_with(&dir) && target.exists() {
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
#[tauri::command]
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
if let Some(parent) = Path::new(&path).parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, bytes).map_err(|e| e.to_string())
}
+78
View File
@@ -0,0 +1,78 @@
use crate::models::Client;
use crate::AppState;
use rusqlite::{params, Row};
use tauri::State;
fn map_client(row: &Row) -> rusqlite::Result<Client> {
Ok(Client {
id: Some(row.get(0)?),
name: row.get(1)?,
address: row.get(2)?,
gstin: row.get(3)?,
state_code: row.get(4)?,
po_number: row.get(5)?,
created_at: row.get(6)?,
})
}
#[tauri::command]
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, name, address, gstin, state_code, po_number, created_at
FROM clients ORDER BY name COLLATE NOCASE ASC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_client)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
#[tauri::command]
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let id = match client.id {
Some(id) => {
conn.execute(
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6",
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id],
)
.map_err(|e| e.to_string())?;
id
}
None => {
conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
client.name,
client.address,
client.gstin,
client.state_code,
client.po_number,
chrono::Utc::now().to_rfc3339()
],
)
.map_err(|e| e.to_string())?;
conn.last_insert_rowid()
}
};
conn.query_row(
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1",
params![id],
map_client,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
+255
View File
@@ -0,0 +1,255 @@
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(())
}
+5
View File
@@ -0,0 +1,5 @@
pub mod assets;
pub mod clients;
pub mod invoice;
pub mod series;
pub mod settings;
+83
View File
@@ -0,0 +1,83 @@
use crate::db::format_number;
use crate::models::InvoiceSeries;
use crate::AppState;
use rusqlite::{params, Row};
use tauri::State;
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> {
let prefix: String = row.get(1)?;
let padding: i64 = row.get(2)?;
let next_number: i64 = row.get(3)?;
Ok(InvoiceSeries {
id: row.get(0)?,
next_invoice_number: format_number(&prefix, padding, next_number),
prefix,
padding,
next_number,
is_active: row.get::<_, i64>(4)? != 0,
created_at: row.get(5)?,
})
}
const SERIES_SELECT: &str =
"SELECT id, prefix, padding, next_number, is_active, created_at FROM invoice_series";
#[tauri::command]
pub fn get_active_series(state: State<AppState>) -> Result<Option<InvoiceSeries>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(&format!(
"{} WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
SERIES_SELECT
))
.map_err(|e| e.to_string())?;
let mut rows = stmt.query_map([], map_series).map_err(|e| e.to_string())?;
match rows.next() {
Some(r) => Ok(Some(r.map_err(|e| e.to_string())?)),
None => Ok(None),
}
}
#[tauri::command]
pub fn list_series(state: State<AppState>) -> Result<Vec<InvoiceSeries>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(&format!("{} ORDER BY id DESC", SERIES_SELECT))
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_series)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
/// Deactivates the current series and starts a fresh one, resetting the counter to 1.
#[tauri::command]
pub fn start_new_series(
state: State<AppState>,
prefix: String,
padding: i64,
) -> Result<InvoiceSeries, String> {
let prefix = prefix.trim().to_string();
if prefix.is_empty() {
return Err("Series prefix cannot be empty".into());
}
let padding = padding.clamp(1, 8);
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("UPDATE invoice_series SET is_active = 0", [])
.map_err(|e| e.to_string())?;
conn.execute(
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
VALUES (?1, ?2, 1, 1, ?3)",
params![prefix, padding, chrono::Utc::now().to_rfc3339()],
)
.map_err(|e| e.to_string())?;
let id = conn.last_insert_rowid();
conn.query_row(
&format!("{} WHERE id = ?1", SERIES_SELECT),
params![id],
map_series,
)
.map_err(|e| e.to_string())
}
+168
View File
@@ -0,0 +1,168 @@
use crate::models::{BankAccount, Settings};
use crate::AppState;
use rusqlite::{params, Row};
use tauri::State;
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme";
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
Ok(Settings {
vendor_name: row.get(0)?,
vendor_address: row.get(1)?,
vendor_email: row.get(2)?,
vendor_phone: row.get(3)?,
vendor_pan: row.get(4)?,
vendor_gstin: row.get(5)?,
vendor_state_code: row.get(6)?,
logo_path: row.get(7)?,
signature_path: row.get(8)?,
default_bank_id: row.get(9)?,
default_tax_rate: row.get(10)?,
default_tax_type: row.get(11)?,
payment_terms_days: row.get(12)?,
currency: row.get(13)?,
onboarded: row.get::<_, i64>(14)? != 0,
theme: row.get(15)?,
})
}
#[tauri::command]
pub fn get_settings(state: State<AppState>) -> Result<Settings, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.query_row(
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
[],
map_settings,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Settings, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute(
r#"UPDATE app_settings SET
vendor_name = ?1, vendor_address = ?2, vendor_email = ?3, vendor_phone = ?4,
vendor_pan = ?5, vendor_gstin = ?6, vendor_state_code = ?7, logo_path = ?8,
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
onboarded = ?15, theme = ?16
WHERE id = 1"#,
params![
settings.vendor_name,
settings.vendor_address,
settings.vendor_email,
settings.vendor_phone,
settings.vendor_pan,
settings.vendor_gstin,
settings.vendor_state_code,
settings.logo_path,
settings.signature_path,
settings.default_bank_id,
settings.default_tax_rate,
settings.default_tax_type,
settings.payment_terms_days,
settings.currency,
if settings.onboarded { 1 } else { 0 },
settings.theme,
],
)
.map_err(|e| e.to_string())?;
drop(conn);
get_settings(state)
}
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
Ok(BankAccount {
id: Some(row.get(0)?),
label: row.get(1)?,
bank_name: row.get(2)?,
account_name: row.get(3)?,
account_no: row.get(4)?,
branch: row.get(5)?,
ifsc: row.get(6)?,
is_default: row.get::<_, i64>(7)? != 0,
})
}
#[tauri::command]
pub fn list_banks(state: State<AppState>) -> Result<Vec<BankAccount>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
FROM bank_accounts ORDER BY is_default DESC, id ASC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], map_bank)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
#[tauri::command]
pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccount, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
let is_default = if bank.is_default { 1 } else { 0 };
let id = match bank.id {
Some(id) => {
conn.execute(
r#"UPDATE bank_accounts SET label=?1, bank_name=?2, account_name=?3,
account_no=?4, branch=?5, ifsc=?6, is_default=?7 WHERE id=?8"#,
params![
bank.label, bank.bank_name, bank.account_name, bank.account_no,
bank.branch, bank.ifsc, is_default, id
],
)
.map_err(|e| e.to_string())?;
id
}
None => {
conn.execute(
r#"INSERT INTO bank_accounts
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#,
params![
bank.label, bank.bank_name, bank.account_name, bank.account_no,
bank.branch, bank.ifsc, is_default
],
)
.map_err(|e| e.to_string())?;
conn.last_insert_rowid()
}
};
if is_default == 1 {
conn.execute(
"UPDATE bank_accounts SET is_default = 0 WHERE id <> ?1",
params![id],
)
.map_err(|e| e.to_string())?;
conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![id],
)
.map_err(|e| e.to_string())?;
}
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,
)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
Ok(())
}
+175
View File
@@ -0,0 +1,175 @@
use rusqlite::{params, Connection};
use std::path::Path;
/// The full schema. Every statement is idempotent so it doubles as the migration step.
const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
vendor_name TEXT NOT NULL DEFAULT '',
vendor_address TEXT NOT NULL DEFAULT '',
vendor_email TEXT NOT NULL DEFAULT '',
vendor_phone TEXT NOT NULL DEFAULT '',
vendor_pan TEXT NOT NULL DEFAULT '',
vendor_gstin TEXT NOT NULL DEFAULT '',
vendor_state_code TEXT NOT NULL DEFAULT '29',
logo_path TEXT,
signature_path TEXT,
default_bank_id INTEGER,
default_tax_rate REAL NOT NULL DEFAULT 18,
default_tax_type TEXT NOT NULL DEFAULT 'cgst_sgst',
payment_terms_days INTEGER NOT NULL DEFAULT 30,
currency TEXT NOT NULL DEFAULT 'INR',
onboarded INTEGER NOT NULL DEFAULT 0,
theme TEXT NOT NULL DEFAULT 'g10'
);
CREATE TABLE IF NOT EXISTS bank_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL DEFAULT '',
bank_name TEXT NOT NULL DEFAULT '',
account_name TEXT NOT NULL DEFAULT '',
account_no TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
ifsc TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS invoice_series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prefix TEXT NOT NULL,
padding INTEGER NOT NULL DEFAULT 3,
next_number INTEGER NOT NULL DEFAULT 1,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
gstin TEXT NOT NULL DEFAULT '',
state_code TEXT NOT NULL DEFAULT '',
po_number TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
number TEXT NOT NULL UNIQUE,
series_id INTEGER,
invoice_date TEXT NOT NULL,
due_date TEXT NOT NULL DEFAULT '',
client_id INTEGER,
client_name TEXT NOT NULL DEFAULT '',
client_address TEXT NOT NULL DEFAULT '',
client_gstin TEXT NOT NULL DEFAULT '',
po_number TEXT NOT NULL DEFAULT '',
place_of_supply_state_code TEXT NOT NULL DEFAULT '',
subtotal REAL NOT NULL DEFAULT 0,
discount REAL NOT NULL DEFAULT 0,
tax_type TEXT NOT NULL DEFAULT 'none',
tax_rate REAL NOT NULL DEFAULT 0,
cgst_amount REAL NOT NULL DEFAULT 0,
sgst_amount REAL NOT NULL DEFAULT 0,
igst_amount REAL NOT NULL DEFAULT 0,
total REAL NOT NULL DEFAULT 0,
amount_in_words TEXT NOT NULL DEFAULT '',
bank_account_id INTEGER,
bank_snapshot TEXT NOT NULL DEFAULT '',
signature_path TEXT,
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'issued',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invoice_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'fixed',
rate REAL NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT 'unit',
quantity REAL NOT NULL DEFAULT 1,
amount REAL NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
"#;
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.execute_batch(SCHEMA)?;
seed(&conn)?;
Ok(conn)
}
/// Insert the first-run defaults the very first time the app is opened.
/// Vendor details are pre-filled from the owner's existing invoice.
fn seed(conn: &Connection) -> rusqlite::Result<()> {
let settings_count: i64 =
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
if settings_count == 0 {
conn.execute(
r#"INSERT INTO app_settings
(id, vendor_name, vendor_address, vendor_email, vendor_phone,
vendor_pan, vendor_gstin, vendor_state_code, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'cgst_sgst', 30, 'INR', 0, 'g10')"#,
params![
"Test Vendor",
"1 Test Road, Bangalore - 560001",
"vendor@example.com",
"9000000000",
"ABCDE1234F",
"",
],
)?;
}
let bank_count: i64 =
conn.query_row("SELECT COUNT(*) FROM bank_accounts", [], |r| r.get(0))?;
if bank_count == 0 {
conn.execute(
r#"INSERT INTO bank_accounts
(label, bank_name, account_name, account_no, branch, ifsc, is_default)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)"#,
params![
"Primary",
"State Bank of India",
"Test Vendor",
"000000000000",
"000001",
"TEST0000001",
],
)?;
let bank_id = conn.last_insert_rowid();
conn.execute(
"UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1",
params![bank_id],
)?;
}
let series_count: i64 =
conn.query_row("SELECT COUNT(*) FROM invoice_series", [], |r| r.get(0))?;
if series_count == 0 {
let now = chrono::Utc::now().to_rfc3339();
let year = chrono::Local::now().format("%Y").to_string();
conn.execute(
r#"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
VALUES (?1, 3, 1, 1, ?2)"#,
params![format!("INV/{}-", year), now],
)?;
}
Ok(())
}
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
}
+59
View File
@@ -0,0 +1,59 @@
mod commands;
mod db;
mod models;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::Manager;
pub struct AppState {
pub db: Mutex<Connection>,
pub data_dir: PathBuf,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let data_dir = app
.path()
.app_data_dir()
.expect("could not resolve the application data directory");
std::fs::create_dir_all(&data_dir).expect("could not create the application data directory");
let conn = db::open(&data_dir.join("voiced.db")).expect("could not open the database");
app.manage(AppState {
db: Mutex::new(conn),
data_dir,
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::settings::get_settings,
commands::settings::save_settings,
commands::settings::list_banks,
commands::settings::save_bank,
commands::settings::delete_bank,
commands::series::get_active_series,
commands::series::list_series,
commands::series::start_new_series,
commands::clients::list_clients,
commands::clients::save_client,
commands::clients::delete_client,
commands::invoice::peek_next_invoice_number,
commands::invoice::create_invoice,
commands::invoice::list_invoices,
commands::invoice::get_invoice,
commands::invoice::delete_invoice,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri,
commands::assets::remove_asset,
commands::assets::save_binary_file,
])
.run(tauri::generate_context!())
.expect("error while running the Voiced application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
voiced_lib::run()
}
+203
View File
@@ -0,0 +1,203 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
pub vendor_name: String,
pub vendor_address: String,
pub vendor_email: String,
pub vendor_phone: String,
pub vendor_pan: String,
pub vendor_gstin: String,
pub vendor_state_code: String,
pub logo_path: Option<String>,
pub signature_path: Option<String>,
pub default_bank_id: Option<i64>,
pub default_tax_rate: f64,
pub default_tax_type: String,
pub payment_terms_days: i64,
pub currency: String,
pub onboarded: bool,
pub theme: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BankAccount {
pub id: Option<i64>,
#[serde(default)]
pub label: String,
#[serde(default)]
pub bank_name: String,
#[serde(default)]
pub account_name: String,
#[serde(default)]
pub account_no: String,
#[serde(default)]
pub branch: String,
#[serde(default)]
pub ifsc: String,
#[serde(default)]
pub is_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceSeries {
pub id: i64,
pub prefix: String,
pub padding: i64,
pub next_number: i64,
pub is_active: bool,
pub created_at: String,
/// The number the next invoice created under this series would receive.
pub next_invoice_number: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Client {
pub id: Option<i64>,
pub name: String,
#[serde(default)]
pub address: String,
#[serde(default)]
pub gstin: String,
#[serde(default)]
pub state_code: String,
#[serde(default)]
pub po_number: String,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceItem {
#[serde(default)]
pub id: Option<i64>,
#[serde(default)]
pub description: String,
/// "fixed" = a single amount; "rate" = rate x quantity.
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default)]
pub rate: f64,
/// second | minute | hour | session | unit
#[serde(default = "default_unit")]
pub unit: String,
#[serde(default)]
pub quantity: f64,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub sort_order: i64,
}
fn default_mode() -> String {
"fixed".to_string()
}
fn default_unit() -> String {
"unit".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceInput {
pub invoice_date: String,
#[serde(default)]
pub due_date: String,
#[serde(default)]
pub client_id: Option<i64>,
#[serde(default)]
pub client_name: String,
#[serde(default)]
pub client_address: String,
#[serde(default)]
pub client_gstin: String,
#[serde(default)]
pub po_number: String,
#[serde(default)]
pub place_of_supply_state_code: String,
#[serde(default)]
pub subtotal: f64,
#[serde(default)]
pub discount: f64,
#[serde(default = "default_tax_type")]
pub tax_type: String,
#[serde(default)]
pub tax_rate: f64,
#[serde(default)]
pub cgst_amount: f64,
#[serde(default)]
pub sgst_amount: f64,
#[serde(default)]
pub igst_amount: f64,
#[serde(default)]
pub total: f64,
#[serde(default)]
pub amount_in_words: String,
#[serde(default)]
pub bank_account_id: Option<i64>,
#[serde(default)]
pub bank_snapshot: String,
#[serde(default)]
pub signature_path: Option<String>,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub save_client: bool,
#[serde(default)]
pub items: Vec<InvoiceItem>,
}
fn default_tax_type() -> String {
"none".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Invoice {
pub id: i64,
pub number: String,
pub series_id: Option<i64>,
pub invoice_date: String,
pub due_date: String,
pub client_id: Option<i64>,
pub client_name: String,
pub client_address: String,
pub client_gstin: String,
pub po_number: String,
pub place_of_supply_state_code: String,
pub subtotal: f64,
pub discount: f64,
pub tax_type: String,
pub tax_rate: f64,
pub cgst_amount: f64,
pub sgst_amount: f64,
pub igst_amount: f64,
pub total: f64,
pub amount_in_words: String,
pub bank_account_id: Option<i64>,
pub bank_snapshot: String,
pub signature_path: Option<String>,
pub notes: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub items: Vec<InvoiceItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceSummary {
pub id: i64,
pub number: String,
pub invoice_date: String,
pub due_date: String,
pub client_name: String,
pub total: f64,
pub status: String,
pub created_at: String,
}