Add issue/cancel lifecycle, GST derivation and integer-paise totals

issue_invoice allocates the number, freezes vendor and bank details and
stores server-computed totals in one transaction. Issued invoices are
cancelled, not deleted. Tax heads derive from supplier state and place of
supply (a disagreeing choice is rejected); unregistered suppliers issue a
plain Invoice. Adds GSTIN checksum validation, canonical India Compliance
state list, server-side drafts, series validation, relative and
magic-byte-checked asset paths, and GST settings. PDF re-exports use the
frozen vendor snapshot.
This commit is contained in:
2026-10-04 04:15:58 +05:30
parent d6ec6b2a17
commit 5597fb791d
19 changed files with 2403 additions and 327 deletions
+195 -55
View File
@@ -1,8 +1,10 @@
use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use tauri::State;
const FORMAT_ERROR: &str = "Use a PNG or JPEG image";
fn safe_kind(kind: &str) -> String {
kind.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
@@ -10,47 +12,88 @@ fn safe_kind(kind: &str) -> 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",
/// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and
/// silently drops everything else, so nothing else is accepted.
/// Returns (extension, MIME type).
pub fn sniff_image(bytes: &[u8]) -> Result<(&'static str, &'static str), String> {
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
Ok(("png", "image/png"))
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Ok(("jpg", "image/jpeg"))
} else {
Err(FORMAT_ERROR.to_string())
}
}
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> {
let dir = state.data_dir.join("assets");
fn assets_dir(data_dir: &Path) -> Result<PathBuf, String> {
let dir = 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())
/// Resolve a stored asset path (relative to the data dir, or a legacy absolute one)
/// to a real file inside `<data_dir>/assets`. `..`, symlink escapes and anything
/// outside the assets directory are rejected.
pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
let raw = Path::new(path);
if path.trim().is_empty() {
return Err("Asset path is empty".into());
}
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
return Err("Asset path must not contain '..'".into());
}
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
let root = data_dir
.join("assets")
.canonicalize()
.map_err(|e| format!("Asset store is unavailable: {e}"))?;
let canonical = full
.canonicalize()
.map_err(|e| format!("Could not open asset {path}: {e}"))?;
if canonical.starts_with(&root) && canonical != root {
Ok(canonical)
} else {
Err("Asset path is outside the asset store".into())
}
}
/// The form stored in the database: `assets/<file>` relative to the data dir, with
/// forward slashes on every platform.
pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String> {
let canonical = resolve_asset(data_dir, path)?;
let base = data_dir.canonicalize().map_err(|e| e.to_string())?;
let rel = canonical.strip_prefix(&base).map_err(|e| e.to_string())?;
Ok(rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/"))
}
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
let (ext, _) = sniff_image(bytes)?;
let dir = assets_dir(data_dir)?;
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext);
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
Ok(format!("assets/{file}"))
}
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
let file = resolve_asset(data_dir, path)?;
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
let (_, mime) = sniff_image(&bytes)?;
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(bytes)))
}
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
let raw = Path::new(path);
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
// Removing something that is already gone is fine, but an escape attempt is not.
if std::fs::symlink_metadata(&full).is_err() {
return Ok(());
}
let target = resolve_asset(data_dir, path)?;
std::fs::remove_file(target).map_err(|e| e.to_string())
}
/// Copy a user-picked file into the app's asset store and return the stored path.
@@ -61,56 +104,153 @@ pub fn import_asset(
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)
write_asset(&state.data_dir, &kind, &bytes)
}
/// Used when the frontend already holds the bytes (base64) instead of a path.
/// The file name is ignored: the type comes from the bytes.
#[tauri::command]
pub fn save_asset_bytes(
state: State<AppState>,
kind: String,
file_name: String,
#[allow(unused_variables)] 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)
write_asset(&state.data_dir, &kind, &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)
))
pub fn read_asset_data_uri(state: State<AppState>, path: String) -> Result<String, String> {
asset_data_uri(&state.data_dir, &path)
}
#[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(())
remove_asset_file(&state.data_dir, &path)
}
/// 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> {
if path.trim().is_empty() {
return Err("No file path given".into());
}
let bytes = STANDARD
.decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?;
if let Some(parent) = Path::new(&path).parent() {
// A missing parent is created; if that fails the write below reports the real error.
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, bytes).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
#[test]
fn sniffing_accepts_only_png_and_jpeg() {
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image");
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
assert!(sniff_image(b"").is_err());
}
#[test]
fn write_ignores_the_name_and_stores_a_relative_path() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}");
assert!(dir.path().join(&stored).is_file());
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
let uri = asset_data_uri(dir.path(), &stored).unwrap();
assert!(uri.starts_with("data:image/jpeg;base64,"));
}
#[test]
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
let dir = tempdir().unwrap();
let assets = dir.path().join("assets");
std::fs::create_dir_all(&assets).unwrap();
std::fs::write(assets.join("liar.jpg"), PNG).unwrap();
let uri = asset_data_uri(dir.path(), "assets/liar.jpg").unwrap();
assert!(uri.starts_with("data:image/png;base64,"));
}
#[test]
fn resolver_accepts_relative_and_inside_absolute_paths() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
let by_relative = resolve_asset(dir.path(), &stored).unwrap();
let absolute = dir.path().join(&stored);
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
assert_eq!(by_relative, by_absolute);
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored);
}
#[test]
fn resolver_rejects_escapes() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
std::fs::write(dir.path().join("voiced.db"), b"secret").unwrap();
let outside = tempdir().unwrap();
std::fs::write(outside.path().join("x.png"), PNG).unwrap();
assert!(resolve_asset(dir.path(), "../voiced.db").is_err());
assert!(resolve_asset(dir.path(), "assets/../voiced.db").is_err());
assert!(resolve_asset(dir.path(), "voiced.db").is_err());
assert!(resolve_asset(dir.path(), "assets").is_err());
assert!(resolve_asset(dir.path(), "").is_err());
let abs_outside = outside.path().join("x.png");
assert!(resolve_asset(dir.path(), abs_outside.to_str().unwrap()).is_err());
let abs_db = dir.path().join("voiced.db");
assert!(resolve_asset(dir.path(), abs_db.to_str().unwrap()).is_err());
assert!(resolve_asset(dir.path(), "assets/missing.png").is_err());
assert!(resolve_asset(dir.path(), &stored).is_ok());
}
#[cfg(unix)]
#[test]
fn resolver_rejects_symlink_escapes() {
let dir = tempdir().unwrap();
let _ = write_asset(dir.path(), "logo", PNG).unwrap();
let outside = tempdir().unwrap();
let target = outside.path().join("x.png");
std::fs::write(&target, PNG).unwrap();
std::os::unix::fs::symlink(&target, dir.path().join("assets/link.png")).unwrap();
std::os::unix::fs::symlink(outside.path(), dir.path().join("assets/dir")).unwrap();
assert!(resolve_asset(dir.path(), "assets/link.png").is_err());
assert!(resolve_asset(dir.path(), "assets/dir/x.png").is_err());
assert!(remove_asset_file(dir.path(), "assets/link.png").is_err());
assert!(target.exists());
}
#[test]
fn remove_deletes_inside_and_tolerates_missing() {
let dir = tempdir().unwrap();
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
remove_asset_file(dir.path(), &stored).unwrap();
assert!(!dir.path().join(&stored).exists());
remove_asset_file(dir.path(), &stored).unwrap();
std::fs::write(dir.path().join("voiced.db"), b"x").unwrap();
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
assert!(dir.path().join("voiced.db").exists());
}
#[test]
fn save_binary_file_rejects_empty_path() {
assert!(save_binary_file(" ".into(), "AAAA".into()).is_err());
}
}
+781 -64
View File
@@ -1,13 +1,23 @@
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::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary};
use crate::gst::{self, TaxType};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
};
use crate::AppState;
use rusqlite::{params, Connection, OptionalExtension, Row};
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";
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";
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
Ok(Invoice {
@@ -38,13 +48,20 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
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)?,
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
"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| {
@@ -57,6 +74,7 @@ fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<Invoi
quantity: row.get(5)?,
amount: row.get(6)?,
sort_order: row.get(7)?,
hsn_sac: row.get(8)?,
})
})?;
rows.collect()
@@ -90,31 +108,224 @@ pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String
}
}
#[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())?;
fn paise_to_rupees(paise: i64) -> f64 {
paise as f64 / 100.0
}
let series = tx
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,
"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::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
},
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()
.map_err(|e| e.to_string())?
.map_err(db)?
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
let (series_id, prefix, padding, next_number) = series;
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 {
@@ -126,65 +337,78 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
params![
input.client_name,
input.client_address,
input.client_gstin,
input.place_of_supply_state_code,
client_gstin,
stored_pos,
input.po_number,
chrono::Utc::now().to_rfc3339()
now
],
)
.map_err(|e| e.to_string())?;
.map_err(db)?;
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,
],
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| e.to_string())?;
.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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.map_err(|e| e.to_string())?;
.map_err(db)?;
for (i, item) in input.items.iter().enumerate() {
stmt.execute(params![
invoice_id,
@@ -193,10 +417,11 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
item.rate,
item.unit,
item.quantity,
item.amount,
paise_to_rupees(gst::line_amount_paise(item)),
i as i64,
item.hsn_sac.trim(),
])
.map_err(|e| e.to_string())?;
.map_err(db)?;
}
}
@@ -205,11 +430,55 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
"UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
params![series_id],
)
.map_err(|e| e.to_string())?;
.map_err(db)?;
tx.commit().map_err(|e| e.to_string())?;
if let Some(draft_id) = input.draft_id {
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
.map_err(db)?;
}
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string())
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]
@@ -246,10 +515,458 @@ pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, 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 delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
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())?;
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id])
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',
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!(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\"}");
}
#[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());
}
}
+129 -21
View File
@@ -1,7 +1,7 @@
use crate::db::format_number;
use crate::models::InvoiceSeries;
use crate::AppState;
use rusqlite::{params, Row};
use rusqlite::{params, Connection, Row};
use tauri::State;
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> {
@@ -52,32 +52,140 @@ pub fn list_series(state: State<AppState>) -> Result<Vec<InvoiceSeries>, String>
Ok(rows)
}
/// Longest invoice number GST allows (India Compliance enforces the same limit).
pub const MAX_NUMBER_LEN: usize = 16;
/// A series prefix starts with a letter or digit and then uses only letters, digits,
/// '-' and '/'; the prefix plus the number padding must fit in 16 characters.
pub fn validate_series_format(prefix: &str, padding: i64) -> Result<(), String> {
let mut chars = prefix.chars();
match chars.next() {
None => return Err("Series prefix cannot be empty".into()),
Some(c) if !c.is_ascii_alphanumeric() => {
return Err("Series prefix must start with a letter or digit".into())
}
Some(_) => {}
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') {
return Err(
"Series prefix may only contain letters, digits, '-' and '/' (no spaces)".into(),
);
}
let length = prefix.len() as i64 + padding.max(1);
if length > MAX_NUMBER_LEN as i64 {
return Err(format!(
"Series prefix plus number padding is {length} characters; invoice numbers can be at most {MAX_NUMBER_LEN}"
));
}
Ok(())
}
/// Deactivates the current series and starts a fresh one, resetting the counter to 1.
/// The deactivate and insert happen in one transaction so a failure never leaves the
/// app without an active series.
pub fn start_new_series_impl(
conn: &mut Connection,
prefix: &str,
padding: i64,
) -> Result<InvoiceSeries, String> {
let prefix = prefix.trim();
let padding = padding.clamp(1, 8);
validate_series_format(prefix, padding)?;
let tx = conn.transaction().map_err(|e| e.to_string())?;
tx.execute("UPDATE invoice_series SET is_active = 0", [])
.map_err(|e| e.to_string())?;
tx.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 = tx.last_insert_rowid();
let series = tx
.query_row(
&format!("{} WHERE id = ?1", SERIES_SELECT),
params![id],
map_series,
)
.map_err(|e| e.to_string())?;
tx.commit().map_err(|e| e.to_string())?;
Ok(series)
}
#[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 mut conn = state.db.lock().map_err(|e| e.to_string())?;
start_new_series_impl(&mut conn, &prefix, padding)
}
#[cfg(test)]
mod tests {
use super::*;
fn conn() -> Connection {
crate::db::open_in_memory().unwrap()
}
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())
#[test]
fn prefix_charset_and_length_rules() {
assert!(validate_series_format("INV/2026-", 3).is_ok());
assert!(validate_series_format("INV", 1).is_ok());
assert!(validate_series_format("", 3).is_err());
assert!(validate_series_format("-AP", 3).is_err());
assert!(validate_series_format("/AP", 3).is_err());
assert!(validate_series_format("AP 2026", 3).is_err());
assert!(validate_series_format("AP_26", 3).is_err());
assert!(validate_series_format("AP.26", 3).is_err());
assert!(validate_series_format("ÄP", 3).is_err());
// 8 + 8 = 16 fits, 9 + 8 = 17 does not.
assert!(validate_series_format("ABCDEFGH", 8).is_ok());
let err = validate_series_format("ABCDEFGHI", 8).unwrap_err();
assert!(err.contains("17") && err.contains("16"), "{err}");
}
#[test]
fn new_series_deactivates_the_old_one() {
let mut c = conn();
let s = start_new_series_impl(&mut c, " FY27/ ", 4).unwrap();
assert_eq!(s.prefix, "FY27/");
assert_eq!(s.next_invoice_number, "FY27/0001");
let active: i64 = c
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(active, 1);
}
#[test]
fn invalid_series_leaves_the_active_one_alone() {
let mut c = conn();
let before: i64 = c
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
.unwrap();
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
let after: i64 = c
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(before, after);
}
#[test]
fn failed_insert_rolls_back_the_deactivation() {
let mut c = conn();
// A trigger forces the INSERT to fail after the UPDATE has run.
c.execute_batch(
"CREATE TRIGGER no_new_series BEFORE INSERT ON invoice_series
BEGIN SELECT RAISE(ABORT, 'blocked'); END;",
)
.unwrap();
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
let active: i64 = c
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
.unwrap();
assert_eq!(active, 1);
}
}
+92 -8
View File
@@ -1,14 +1,14 @@
use crate::models::{BankAccount, Settings};
use crate::AppState;
use rusqlite::{params, Row};
use rusqlite::{params, Connection, Row};
use tauri::State;
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
pub(crate) 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, gst_registration, default_hsn_sac, signatory_name,
signatory_designation";
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
Ok(Settings {
vendor_name: row.get(0)?,
vendor_address: row.get(1)?,
@@ -84,7 +84,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
get_settings(state)
}
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
pub(crate) fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
Ok(BankAccount {
id: Some(row.get(0)?),
label: row.get(1)?,
@@ -169,10 +169,94 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
.map_err(|e| e.to_string())
}
/// Deletes a bank account. If it was the default, the lowest remaining account
/// becomes the default (or the default is cleared when none is left). Issued invoices
/// keep their own `bank_snapshot`, so they are unaffected.
pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
let tx = conn.transaction().map_err(|e| e.to_string())?;
let was_default: bool = tx
.query_row(
"SELECT COALESCE((SELECT is_default FROM bank_accounts WHERE id = ?1), 0) <> 0
OR COALESCE((SELECT default_bank_id FROM app_settings WHERE id = 1), 0) = ?1",
params![id],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
tx.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
if was_default {
let next: Option<i64> = tx
.query_row("SELECT MIN(id) FROM bank_accounts", [], |r| r.get(0))
.map_err(|e| e.to_string())?;
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
.map_err(|e| e.to_string())?;
if let Some(next) = next {
tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next])
.map_err(|e| e.to_string())?;
}
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next])
.map_err(|e| e.to_string())?;
}
tx.commit().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(())
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
delete_bank_impl(&mut conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
fn add_bank(conn: &Connection, name: &str, is_default: bool) -> i64 {
conn.execute(
"INSERT INTO bank_accounts (bank_name, is_default) VALUES (?1, ?2)",
params![name, is_default],
)
.unwrap();
conn.last_insert_rowid()
}
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
let setting = conn
.query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0))
.unwrap();
let mut stmt = conn
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
.unwrap();
let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect();
(setting, flagged)
}
#[test]
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
let mut conn = crate::db::open_in_memory().unwrap();
// The seeded "Primary" bank is the default; add two more.
let seeded: i64 = conn
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
.unwrap();
let b = add_bank(&conn, "B", false);
let c = add_bank(&conn, "C", false);
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
delete_bank_impl(&mut conn, seeded).unwrap();
assert_eq!(default_state(&conn), (Some(b), vec![b]));
delete_bank_impl(&mut conn, b).unwrap();
assert_eq!(default_state(&conn), (Some(c), vec![c]));
delete_bank_impl(&mut conn, c).unwrap();
assert_eq!(default_state(&conn), (None, vec![]));
}
#[test]
fn deleting_a_non_default_bank_keeps_the_default() {
let mut conn = crate::db::open_in_memory().unwrap();
let seeded: i64 = conn
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
.unwrap();
let b = add_bank(&conn, "B", false);
delete_bank_impl(&mut conn, b).unwrap();
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
}
}