Author SHA1 Message Date
xavierk fbed18a0bd Add invoice drafts, read-only issued state and keyboard shortcuts
Views stay mounted so switching tabs no longer loses a half-typed invoice.
The form autosaves a draft after 1 s, drafts can be resumed or deleted,
and after issuing the form is read-only with Export again, Duplicate and
New invoice actions. A failed export keeps the issued invoice and offers a
retry instead of issuing again. Adds Ctrl+N/S/Enter/L and a shortcut sheet;
F5 and Ctrl+R are blocked in production builds.
2026-10-04 04:21:47 +05:30
xavierk 5597fb791d 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.
2026-10-04 04:15:58 +05:30
xavierk d6ec6b2a17 Add versioned DB migrations, startup error dialog and native basics
rusqlite_migration with a VACUUM INTO backup before upgrading; M2 adds GST
registration, snapshot, cancellation and draft columns and backfills
snapshots for existing invoices. Startup failures now show a native dialog
instead of exiting silently. Adds single-instance and window-state plugins,
minimum window 960x600, embedBootstrapper, DMABUF workaround on Linux and
xbps dependency updates.
2026-10-04 04:04:34 +05:30
33 changed files with 3865 additions and 419 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"tauri": "tauri",
"app:dev": "tauri dev",
"app:build": "tauri build --features custom-protocol",
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol"
"app:build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol",
"app:build:windows:offline": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --features custom-protocol --config '{\"bundle\":{\"windows\":{\"webviewInstallMode\":{\"type\":\"offlineInstaller\"}}}}'"
},
"dependencies": {
"@carbon/icons-react": "^11.89.0",
+1 -1
View File
@@ -71,7 +71,7 @@ mkdir -p "$REPO"
-l "proprietary" \
-H "https://git.bongbetic.com/xavierk/Voiced" \
-m "Xavier Karma <xavier@bongbetic.com>" \
-D "libwebkit2gtk41>=2.40_1" \
-D "libwebkit2gtk41>=2.50_1 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils" \
-t "office finance" \
--compression zstd \
"$STAGE"
+2 -2
View File
@@ -20,8 +20,8 @@ version=0.1.0
revision=1
archs="x86_64*"
hostmakedepends="rust nodejs pkg-config"
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel openssl-devel"
depends="libwebkit2gtk41"
makedepends="libwebkit2gtk41-devel libsoup3-devel gtk+3-devel librsvg-devel"
depends="libwebkit2gtk41>=2.50 fontconfig xdg-utils hicolor-icon-theme desktop-file-utils"
short_desc="Offline invoicing app by Bongbetic"
maintainer="Xavier Karma <xavier@bongbetic.com>"
license="custom:proprietary"
+46 -1
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "adler2"
@@ -2887,6 +2887,16 @@ dependencies = [
"sqlite-wasm-rs",
]
[[package]]
name = "rusqlite_migration"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03451d4dc523c0a96d9e412c574670b3f5f184c3988b77a435876be25477bbd3"
dependencies = [
"log",
"rusqlite",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3658,6 +3668,37 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2c8fe2d6b75caed0153f91eda30ea1dea3bfb6710d5adb80456d2c035a569d"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.21",
"tokio",
"tracing",
"windows-sys 0.61.2",
"zbus",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3"
dependencies = [
"bitflags 2.13.2",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]]
name = "tauri-runtime"
version = "2.12.1"
@@ -4196,12 +4237,16 @@ dependencies = [
"base64 0.22.1",
"chrono",
"rusqlite",
"rusqlite_migration",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-window-state",
"tempfile",
"thiserror 2.0.21",
"uuid",
]
+10 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"]
edition = "2021"
rust-version = "1.77"
rust-version = "1.95"
# The `_lib` suffix keeps the library name distinct from the binary name on Windows.
[lib]
@@ -21,11 +21,20 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
rusqlite_migration = "2.6"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
thiserror = "2"
base64 = "0.22"
# Desktop-only plugins (not available on mobile targets).
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
[dev-dependencies]
tempfile = "3"
[features]
# Production builds embed the frontend and serve it over the custom asset
# protocol. Without this, the app expects a dev server on localhost:1420.
+20 -1
View File
@@ -6,6 +6,25 @@
"permissions": [
"core:default",
"dialog:default",
"opener:default"
"opener:default",
"window-state:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{ "path": "$DOCUMENT/**" },
{ "path": "$DOWNLOAD/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$HOME/**" }
]
},
{
"identifier": "opener:allow-reveal-item-in-dir",
"allow": [
{ "path": "$DOCUMENT/**" },
{ "path": "$DOWNLOAD/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$HOME/**" }
]
}
]
}
+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(db)?;
if let Some(draft_id) = input.draft_id {
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
.map_err(db)?;
}
tx.commit().map_err(db)?;
fetch_invoice(conn, invoice_id).map_err(db)
}
#[tauri::command]
pub fn issue_invoice(
state: State<AppState>,
input: InvoiceInput,
render_prefs: serde_json::Value,
) -> Result<Invoice, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
issue_invoice_impl(&mut conn, &state.data_dir, input, &render_prefs)
}
/// Issued invoices are never deleted: cancelling keeps the row and its number.
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
let now = chrono::Utc::now().to_rfc3339();
let changed = conn
.execute(
"UPDATE invoices
SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2, updated_at = ?1
WHERE id = ?3 AND status = 'issued'",
params![now, reason.trim(), id],
)
.map_err(|e| e.to_string())?;
if changed == 0 {
let status: Option<String> = conn
.query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
.optional()
.map_err(|e| e.to_string())?;
return Err(match status {
None => "Invoice not found".to_string(),
Some(s) => format!("Only an issued invoice can be cancelled (this one is {s})"),
});
}
fetch_invoice(conn, id).map_err(|e| e.to_string())
}
tx.commit().map_err(|e| e.to_string())?;
fetch_invoice(&conn, invoice_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());
}
}
+128 -20
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()],
#[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;",
)
.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())
.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);
}
}
+104 -10
View File
@@ -1,13 +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";
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)?,
@@ -25,6 +26,10 @@ fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
currency: row.get(13)?,
onboarded: row.get::<_, i64>(14)? != 0,
theme: row.get(15)?,
gst_registration: row.get(16)?,
default_hsn_sac: row.get(17)?,
signatory_name: row.get(18)?,
signatory_designation: row.get(19)?,
})
}
@@ -48,7 +53,8 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
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
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
signatory_name = ?19, signatory_designation = ?20
WHERE id = 1"#,
params![
settings.vendor_name,
@@ -67,6 +73,10 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
settings.currency,
if settings.onboarded { 1 } else { 0 },
settings.theme,
settings.gst_registration,
settings.default_hsn_sac,
settings.signatory_name,
settings.signatory_designation,
],
)
.map_err(|e| e.to_string())?;
@@ -74,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)?,
@@ -159,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]));
}
}
+573 -7
View File
@@ -1,7 +1,29 @@
use rusqlite::{params, Connection};
use std::path::Path;
use rusqlite_migration::{Migrations, M};
use std::fs;
use std::path::{Path, PathBuf};
/// The full schema. Every statement is idempotent so it doubles as the migration step.
/// Number of pre-migration backups kept in the backup directory.
const MAX_BACKUPS: usize = 10;
/// Prefix shared by every pre-migration backup file name.
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
const LATEST_VERSION: i64 = 2;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("database error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("database migration failed: {0}")]
Migration(#[from] rusqlite_migration::Error),
#[error("file system error: {0}")]
Io(#[from] std::io::Error),
}
/// Version 1: the original schema. Every statement is idempotent, so databases
/// created before migrations existed (user_version 0, all tables present) adopt it safely.
const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -99,17 +121,129 @@ 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)?;
/// Version 2: GST registration, invoice snapshots/cancellation fields, HSN/SAC and drafts.
const M2: &str = r#"
ALTER TABLE app_settings ADD COLUMN gst_registration TEXT NOT NULL DEFAULT 'unregistered';
ALTER TABLE app_settings ADD COLUMN default_hsn_sac TEXT NOT NULL DEFAULT '';
ALTER TABLE app_settings ADD COLUMN signatory_name TEXT NOT NULL DEFAULT '';
ALTER TABLE app_settings ADD COLUMN signatory_designation TEXT NOT NULL DEFAULT '';
UPDATE app_settings SET gst_registration = 'regular' WHERE vendor_gstin <> '';
-- An unregistered supplier may not charge GST, so new invoices default to no tax.
UPDATE app_settings SET default_tax_type = 'none' WHERE gst_registration = 'unregistered';
ALTER TABLE invoices ADD COLUMN doc_type TEXT NOT NULL DEFAULT 'invoice';
ALTER TABLE invoices ADD COLUMN reverse_charge INTEGER NOT NULL DEFAULT 0;
ALTER TABLE invoices ADD COLUMN vendor_snapshot TEXT;
ALTER TABLE invoices ADD COLUMN render_prefs TEXT;
ALTER TABLE invoices ADD COLUMN snapshot_origin TEXT NOT NULL DEFAULT 'issued';
ALTER TABLE invoices ADD COLUMN cancelled_at TEXT;
ALTER TABLE invoices ADD COLUMN cancel_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE invoices ADD COLUMN archived_pdf_sha256 TEXT;
UPDATE invoices SET
vendor_snapshot = (
SELECT json_object(
'vendorName', vendor_name,
'vendorAddress', vendor_address,
'vendorEmail', vendor_email,
'vendorPhone', vendor_phone,
'vendorPan', vendor_pan,
'vendorGstin', vendor_gstin,
'vendorStateCode', vendor_state_code,
'logoPath', logo_path,
'signaturePath', signature_path,
'gstRegistration', gst_registration,
'signatoryName', signatory_name,
'signatoryDesignation', signatory_designation
) FROM app_settings WHERE id = 1
),
snapshot_origin = 'backfilled',
doc_type = CASE WHEN tax_type <> 'none' THEN 'tax_invoice' ELSE 'invoice' END;
ALTER TABLE invoice_items ADD COLUMN hsn_sac TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS invoice_drafts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![M::up(SCHEMA), M::up(M2)])
}
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
/// Existing databases are backed up into `backup_dir` before any migration runs.
pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
let mut conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.execute_batch(SCHEMA)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version < LATEST_VERSION && has_user_tables(&conn)? {
backup_before_migration(&conn, backup_dir, version)?;
}
migrations().to_latest(&mut conn)?;
seed(&conn)?;
Ok(conn)
}
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
conn.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
[],
|r| r.get(0),
)
}
/// Copy the database to `<backup_dir>/voiced-pre-v{version}-{YYYYmmdd-HHMMSS}.db`
/// and drop all but the newest `MAX_BACKUPS` backups.
fn backup_before_migration(
conn: &Connection,
backup_dir: &Path,
version: i64,
) -> Result<PathBuf, DbError> {
fs::create_dir_all(backup_dir)?;
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
let quoted = target.to_string_lossy().replace('\'', "''");
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
// A failed prune must not block startup; the backup itself already succeeded.
let _ = prune_backups(backup_dir, MAX_BACKUPS);
Ok(target)
}
/// Keep only the `keep` newest pre-migration backups, ordered by the timestamp
/// embedded in the file name (the schema version prefix is ignored).
fn prune_backups(backup_dir: &Path, keep: usize) -> std::io::Result<()> {
let mut backups: Vec<(String, PathBuf)> = Vec::new();
for entry in fs::read_dir(backup_dir)? {
let path = entry?.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if let Some(stem) = name
.strip_prefix(BACKUP_PREFIX)
.and_then(|n| n.strip_suffix(".db"))
{
// "{version}-{YYYYmmdd}-{HHMMSS}" -> "{YYYYmmdd}-{HHMMSS}"
let stamp = stem.split_once('-').map_or(stem, |(_, t)| t);
backups.push((stamp.to_string(), path));
}
}
backups.sort();
let excess = backups.len().saturating_sub(keep);
for (_, path) in backups.into_iter().take(excess) {
fs::remove_file(path)?;
}
Ok(())
}
/// Insert the first-run defaults the very first time the app is opened.
/// Vendor details are pre-filled from the owner's existing invoice.
/// Vendor details are pre-filled from the owner's existing invoice. The vendor
/// has no GSTIN yet, so the defaults describe an unregistered supplier (no tax).
fn seed(conn: &Connection) -> rusqlite::Result<()> {
let settings_count: i64 =
conn.query_row("SELECT COUNT(*) FROM app_settings", [], |r| r.get(0))?;
@@ -119,7 +253,7 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
(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')"#,
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, '29', 18, 'none', 30, 'INR', 0, 'g10')"#,
params![
"Test Vendor",
"1 Test Road, Bangalore - 560001",
@@ -169,7 +303,439 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
Ok(())
}
/// A migrated, seeded in-memory database for unit tests of the command logic.
#[cfg(test)]
pub fn open_in_memory() -> Result<Connection, DbError> {
let mut conn = Connection::open_in_memory()?;
conn.pragma_update(None, "foreign_keys", "ON")?;
migrations().to_latest(&mut conn)?;
seed(&conn)?;
Ok(conn)
}
/// `<data_dir>/assets/x.png` -> `assets/x.png`; anything else is returned unchanged.
/// Legacy databases stored absolute paths, which break as soon as the data dir moves.
fn relativize(path: &str, data_dir: &Path) -> String {
match Path::new(path).strip_prefix(data_dir.join("assets")) {
Ok(rest) if !rest.as_os_str().is_empty() => {
let mut out = String::from("assets");
for part in rest.components() {
out.push('/');
out.push_str(&part.as_os_str().to_string_lossy());
}
out
}
_ => path.to_string(),
}
}
/// Rewrite absolute asset paths under `<data_dir>/assets/` to relative ones in
/// settings, invoices and the invoice vendor snapshots. Safe to run on every start.
pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::Result<()> {
let tx = conn.unchecked_transaction()?;
for (table, column, pk) in [
("app_settings", "logo_path", "id"),
("app_settings", "signature_path", "id"),
("invoices", "signature_path", "id"),
] {
let rows: Vec<(i64, String)> = {
let mut stmt = tx.prepare(&format!(
"SELECT {pk}, {column} FROM {table} WHERE {column} IS NOT NULL"
))?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<rusqlite::Result<_>>()?
};
for (id, path) in rows {
let rel = relativize(&path, data_dir);
if rel != path {
tx.execute(
&format!("UPDATE {table} SET {column} = ?1 WHERE {pk} = ?2"),
params![rel, id],
)?;
}
}
}
let snapshots: Vec<(i64, String)> = {
let mut stmt =
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<rusqlite::Result<_>>()?
};
for (id, text) in snapshots {
let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&text) else {
continue;
};
let mut changed = false;
for key in ["logoPath", "signaturePath"] {
if let Some(path) = json.get(key).and_then(|v| v.as_str()) {
let rel = relativize(path, data_dir);
if rel != path {
json[key] = serde_json::Value::String(rel);
changed = true;
}
}
}
if changed {
tx.execute(
"UPDATE invoices SET vendor_snapshot = ?1 WHERE id = ?2",
params![json.to_string(), id],
)?;
}
}
tx.commit()
}
/// 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)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
/// The schema as it shipped before migrations existed (user_version 0).
const V1_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);
"#;
fn user_version(conn: &Connection) -> i64 {
conn.pragma_query_value(None, "user_version", |r| r.get(0)).unwrap()
}
fn backups_in(dir: &Path) -> Vec<PathBuf> {
match fs::read_dir(dir) {
Ok(rd) => rd.map(|e| e.unwrap().path()).collect(),
Err(_) => Vec::new(),
}
}
#[test]
fn migrations_are_valid_and_latest_matches() {
migrations().validate().unwrap();
let mut conn = Connection::open_in_memory().unwrap();
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION);
}
#[test]
fn fresh_database_is_seeded_without_backup() {
let dir = tempdir().unwrap();
let backups = dir.path().join("backups");
let conn = open(&dir.path().join("voiced.db"), &backups).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION);
let (tax_type, gst_reg, name): (String, String, String) = conn
.query_row(
"SELECT default_tax_type, gst_registration, vendor_name FROM app_settings WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(tax_type, "none");
assert_eq!(gst_reg, "unregistered");
assert_eq!(name, "Test Vendor");
assert!(backups_in(&backups).is_empty());
}
fn build_v1_database(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(V1_SCHEMA).unwrap();
conn.execute(
"INSERT INTO app_settings (id, vendor_name, vendor_gstin, default_tax_type, onboarded)
VALUES (1, 'Acme Studio', '29ABCDE1234F1Z5', 'cgst_sgst', 1)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO bank_accounts (label, bank_name, account_no, is_default)
VALUES ('Primary', 'Test Bank', '12345', 1)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
VALUES ('AC/2026-', 3, 2, 1, '2026-01-01T00:00:00Z')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO invoices (number, invoice_date, tax_type, tax_rate, total, created_at, updated_at)
VALUES ('AC/2026-001', '2026-01-02', 'cgst_sgst', 18, 118, 'now', 'now')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO invoice_items (invoice_id, description, rate, quantity, amount)
VALUES (1, 'Design work', 100, 1, 100)",
[],
)
.unwrap();
assert_eq!(user_version(&conn), 0);
}
#[test]
fn v1_database_upgrades_with_backup_and_backfill() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("voiced.db");
let backups = dir.path().join("backups");
build_v1_database(&db_path);
let conn = open(&db_path, &backups).unwrap();
assert_eq!(user_version(&conn), LATEST_VERSION);
// Backup exists and is a valid, un-migrated copy.
let files = backups_in(&backups);
assert_eq!(files.len(), 1);
let name = files[0].file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("voiced-pre-v0-") && name.ends_with(".db"), "{name}");
let backup = Connection::open(&files[0]).unwrap();
assert_eq!(user_version(&backup), 0);
let n: i64 = backup
.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
// Settings backfill.
let (gst_reg, tax_type, vendor): (String, String, String) = conn
.query_row(
"SELECT gst_registration, default_tax_type, vendor_name FROM app_settings WHERE id = 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
assert_eq!(gst_reg, "regular");
assert_eq!(tax_type, "cgst_sgst");
assert_eq!(vendor, "Acme Studio");
// Invoice backfill.
let (origin, snapshot, doc_type, number, total): (String, Option<String>, String, String, f64) = conn
.query_row(
"SELECT snapshot_origin, vendor_snapshot, doc_type, number, total FROM invoices",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
)
.unwrap();
assert_eq!(origin, "backfilled");
assert_eq!(doc_type, "tax_invoice");
assert_eq!(number, "AC/2026-001");
assert_eq!(total, 118.0);
let json: serde_json::Value = serde_json::from_str(&snapshot.expect("snapshot")).unwrap();
assert_eq!(json["vendorName"], "Acme Studio");
assert_eq!(json["vendorGstin"], "29ABCDE1234F1Z5");
assert_eq!(json["gstRegistration"], "regular");
// Old data intact, new columns defaulted.
let (desc, hsn): (String, String) = conn
.query_row("SELECT description, hsn_sac FROM invoice_items", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(desc, "Design work");
assert_eq!(hsn, "");
let (bank, next): (String, i64) = conn
.query_row(
"SELECT (SELECT bank_name FROM bank_accounts), (SELECT next_number FROM invoice_series)",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(bank, "Test Bank");
assert_eq!(next, 2);
}
#[test]
fn reopening_up_to_date_database_makes_no_backup() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("voiced.db");
let backups = dir.path().join("backups");
build_v1_database(&db_path);
drop(open(&db_path, &backups).unwrap());
assert_eq!(backups_in(&backups).len(), 1);
drop(open(&db_path, &backups).unwrap());
assert_eq!(backups_in(&backups).len(), 1);
}
#[test]
fn pruning_keeps_the_newest_ten() {
let dir = tempdir().unwrap();
for i in 0..14 {
// Mixed version prefixes: ordering must follow the timestamp only.
let version = if i % 2 == 0 { 2 } else { 10 };
let name = format!("{BACKUP_PREFIX}{version}-202601{:02}-120000.db", i + 1);
fs::write(dir.path().join(name), b"x").unwrap();
}
fs::write(dir.path().join("unrelated.db"), b"x").unwrap();
prune_backups(dir.path(), MAX_BACKUPS).unwrap();
let mut names: Vec<String> = backups_in(dir.path())
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.filter(|n| n.starts_with(BACKUP_PREFIX))
.collect();
names.sort();
assert_eq!(names.len(), MAX_BACKUPS);
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104")));
assert!(dir.path().join("unrelated.db").exists());
}
#[test]
fn asset_paths_become_relative_and_the_rewrite_is_idempotent() {
let dir = tempdir().unwrap();
let data = dir.path();
let conn = open(&data.join("voiced.db"), &data.join("backups")).unwrap();
let abs_logo = data.join("assets").join("logo-1.png").to_string_lossy().into_owned();
let abs_sig = data.join("assets").join("signature-2.jpg").to_string_lossy().into_owned();
let foreign = "/somewhere/else/logo.png";
conn.execute(
"UPDATE app_settings SET logo_path = ?1, signature_path = ?2",
params![abs_logo, foreign],
)
.unwrap();
let snapshot = serde_json::json!({
"vendorName": "Acme",
"logoPath": abs_logo,
"signaturePath": abs_sig,
})
.to_string();
conn.execute(
"INSERT INTO invoices (number, invoice_date, signature_path, vendor_snapshot, created_at, updated_at)
VALUES ('A-1', '2026-01-01', ?1, ?2, 'now', 'now')",
params![abs_sig, snapshot],
)
.unwrap();
conn.execute(
"INSERT INTO invoices (number, invoice_date, vendor_snapshot, created_at, updated_at)
VALUES ('A-2', '2026-01-01', 'not json', 'now', 'now')",
[],
)
.unwrap();
for _ in 0..2 {
relativize_asset_paths(&conn, data).unwrap();
let (logo, sig): (String, String) = conn
.query_row("SELECT logo_path, signature_path FROM app_settings", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(logo, "assets/logo-1.png");
assert_eq!(sig, foreign);
let (inv_sig, snap): (String, String) = conn
.query_row(
"SELECT signature_path, vendor_snapshot FROM invoices WHERE number = 'A-1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(inv_sig, "assets/signature-2.jpg");
let json: serde_json::Value = serde_json::from_str(&snap).unwrap();
assert_eq!(json["logoPath"], "assets/logo-1.png");
assert_eq!(json["signaturePath"], "assets/signature-2.jpg");
assert_eq!(json["vendorName"], "Acme");
let bad: String = conn
.query_row("SELECT vendor_snapshot FROM invoices WHERE number = 'A-2'", [], |r| r.get(0))
.unwrap();
assert_eq!(bad, "not json");
}
}
}
+506
View File
@@ -0,0 +1,506 @@
//! GST rules: state codes, tax-type derivation, GSTIN validation, integer-paise
//! totals and amount in words. Everything here is a pure function.
use crate::models::InvoiceItem;
use serde::{Deserialize, Serialize};
/// GST state codes, copied from India Compliance (`STATE_NUMBERS` in
/// `india_compliance/gst_india/constants/__init__.py`), including 96 and 97.
pub const STATES: &[(&str, &str)] = &[
("01", "Jammu and Kashmir"),
("02", "Himachal Pradesh"),
("03", "Punjab"),
("04", "Chandigarh"),
("05", "Uttarakhand"),
("06", "Haryana"),
("07", "Delhi"),
("08", "Rajasthan"),
("09", "Uttar Pradesh"),
("10", "Bihar"),
("11", "Sikkim"),
("12", "Sampleachal Pradesh"),
("13", "Nagaland"),
("14", "Manipur"),
("15", "Mizoram"),
("16", "Tripura"),
("17", "Meghalaya"),
("18", "Assam"),
("19", "West Bengal"),
("20", "Jharkhand"),
("21", "Odisha"),
("22", "Chhattisgarh"),
("23", "Madhya Pradesh"),
("24", "Gujarat"),
("26", "Dadra and Nagar Haveli and Daman and Diu"),
("27", "Maharashtra"),
("29", "Karnataka"),
("30", "Goa"),
("31", "Lakshadweep Islands"),
("32", "Kerala"),
("33", "Tamil Nadu"),
("34", "Puducherry"),
("35", "Andaman and Nicobar Islands"),
("36", "Telangana"),
("37", "Andhra Pradesh"),
("38", "Ladakh"),
("96", "Other Countries"),
("97", "Other Territory"),
];
pub fn state_exists(code: &str) -> bool {
STATES.iter().any(|(c, _)| *c == code)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaxType {
None,
CgstSgst,
Igst,
}
impl TaxType {
pub fn as_str(self) -> &'static str {
match self {
TaxType::None => "none",
TaxType::CgstSgst => "cgst_sgst",
TaxType::Igst => "igst",
}
}
}
/// An unregistered supplier cannot charge GST. Otherwise the tax head follows the
/// place of supply, which defaults to the supplier's own state when left empty.
pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str) -> TaxType {
if registration == "unregistered" {
return TaxType::None;
}
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state };
if pos == supplier_state {
TaxType::CgstSgst
} else {
TaxType::Igst
}
}
/// Union territories without a legislature: Chandigarh, Dadra & Nagar Haveli and
/// Daman & Diu, Lakshadweep, Andaman & Nicobar Islands, Ladakh.
#[cfg_attr(not(test), allow(dead_code))] // used through `second_head_label`, which the PDF layer will call
pub fn is_ut_without_legislature(code: &str) -> bool {
matches!(code, "04" | "26" | "31" | "35" | "38")
}
/// UTGST replaces SGST only when the supplier is in one of those territories.
#[cfg_attr(not(test), allow(dead_code))] // consumed by the PDF layer in a later step
pub fn second_head_label(supplier_state: &str) -> &'static str {
if is_ut_without_legislature(supplier_state) {
"UTGST"
} else {
"SGST"
}
}
pub fn doc_type(registration: &str) -> &'static str {
if registration == "unregistered" {
"invoice"
} else {
"tax_invoice"
}
}
const GSTIN_CHARS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
fn gstin_shape_ok(b: &[u8]) -> bool {
b.len() == 15
&& b[0..2].iter().all(u8::is_ascii_digit)
&& b[2..7].iter().all(u8::is_ascii_uppercase)
&& b[7..11].iter().all(u8::is_ascii_digit)
&& b[11].is_ascii_uppercase()
&& (b[12].is_ascii_uppercase() || (b'1'..=b'9').contains(&b[12]))
&& b[13] == b'Z'
&& (b[14].is_ascii_digit() || b[14].is_ascii_uppercase())
}
/// Standard GSTIN mod-36 check character over the first 14 characters.
fn gstin_check_char(first14: &[u8]) -> u8 {
let sum: usize = first14
.iter()
.enumerate()
.map(|(i, ch)| {
let value = GSTIN_CHARS.iter().position(|c| c == ch).unwrap_or(0);
let product = value * if i % 2 == 0 { 1 } else { 2 };
product / 36 + product % 36
})
.sum();
GSTIN_CHARS[(36 - sum % 36) % 36]
}
pub fn validate_gstin(g: &str) -> Result<(), String> {
let g = g.trim().to_ascii_uppercase();
if g.len() != 15 || !g.is_ascii() {
return Err("GSTIN must be 15 characters".into());
}
let b = g.as_bytes();
if !gstin_shape_ok(b) {
return Err("GSTIN format is invalid".into());
}
if !state_exists(&g[0..2]) {
return Err(format!("GSTIN has an unknown state code {}", &g[0..2]));
}
let expected = gstin_check_char(&b[0..14]);
if b[14] != expected {
return Err(format!(
"GSTIN check digit is wrong (expected {})",
expected as char
));
}
Ok(())
}
/// Characters 3 to 12 of a GSTIN are the holder's PAN.
pub fn gstin_matches_pan(gstin: &str, pan: &str) -> bool {
let gstin = gstin.trim().to_ascii_uppercase();
let pan = pan.trim().to_ascii_uppercase();
gstin.len() == 15 && gstin.is_ascii() && pan.len() == 10 && gstin[2..12] == pan
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Totals {
pub subtotal: i64,
pub discount: i64,
pub taxable: i64,
pub cgst: i64,
pub sgst: i64,
pub igst: i64,
pub total: i64,
}
pub fn rupees_to_paise(rupees: f64) -> i64 {
(rupees * 100.0).round() as i64
}
/// Round-half-up integer division for non-negative operands.
fn div_round(num: i128, den: i128) -> i64 {
((num + den / 2) / den) as i64
}
/// Amount of a single line in paise. Rate lines multiply the rounded rate by the
/// (possibly fractional) quantity and round half up once.
pub fn line_amount_paise(item: &InvoiceItem) -> i64 {
if item.mode == "rate" {
let rate = rupees_to_paise(item.rate);
(rate as f64 * item.quantity).round() as i64
} else {
rupees_to_paise(item.amount)
}
}
pub fn compute_totals(
items: &[InvoiceItem],
discount_paise: i64,
tax_type: TaxType,
rate_bp: i64,
) -> Totals {
let subtotal: i64 = items.iter().map(line_amount_paise).sum();
let discount = discount_paise.clamp(0, subtotal.max(0));
let taxable = subtotal - discount;
let (mut cgst, mut sgst, mut igst) = (0, 0, 0);
match tax_type {
TaxType::CgstSgst => {
cgst = div_round(taxable as i128 * rate_bp as i128, 20_000);
sgst = cgst;
}
TaxType::Igst => igst = div_round(taxable as i128 * rate_bp as i128, 10_000),
TaxType::None => {}
}
Totals {
subtotal,
discount,
taxable,
cgst,
sgst,
igst,
total: taxable + cgst + sgst + igst,
}
}
const ONES: [&str; 20] = [
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven",
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen",
];
const TENS: [&str; 10] = [
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety",
];
fn two_digits(n: u64) -> String {
if n < 20 {
return ONES[n as usize].to_string();
}
let ones = n % 10;
let mut out = TENS[(n / 10) as usize].to_string();
if ones > 0 {
out.push(' ');
out.push_str(ONES[ones as usize]);
}
out
}
fn three_digits(n: u64) -> String {
let hundreds = n / 100;
let rest = n % 100;
let mut out = String::new();
if hundreds > 0 {
out.push_str(ONES[hundreds as usize]);
out.push_str(" Hundred");
}
if rest > 0 {
if !out.is_empty() {
out.push(' ');
}
out.push_str(&two_digits(rest));
}
out
}
/// Whole number to words in the Indian system (thousand, lakh, crore).
fn whole_to_words(n: u64) -> String {
if n == 0 {
return "Zero".to_string();
}
let mut parts: Vec<String> = Vec::new();
let crore = n / 10_000_000;
let mut rest = n % 10_000_000;
if crore > 0 {
parts.push(format!("{} Crore", whole_to_words(crore)));
}
let lakh = rest / 100_000;
rest %= 100_000;
if lakh > 0 {
parts.push(format!("{} Lakh", two_digits(lakh)));
}
let thousand = rest / 1000;
rest %= 1000;
if thousand > 0 {
parts.push(format!("{} Thousand", two_digits(thousand)));
}
if rest > 0 {
parts.push(three_digits(rest));
}
parts.join(" ")
}
/// Same output as `amountInWords` in `src/lib/numberToWords.ts`,
/// e.g. 2_500_000 paise -> "Indian Rupees Twenty Five Thousand Only".
pub fn amount_in_words(paise: i64) -> String {
let value = paise.unsigned_abs();
let rupees = value / 100;
let paise = value % 100;
let mut out = format!("Indian Rupees {}", whole_to_words(rupees));
if paise > 0 {
out.push_str(&format!(" and {} Paise", two_digits(paise)));
}
out.push_str(" Only");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rate_item(rate: f64, quantity: f64) -> InvoiceItem {
InvoiceItem {
id: None,
description: String::new(),
mode: "rate".into(),
rate,
unit: "hour".into(),
quantity,
amount: 0.0,
sort_order: 0,
hsn_sac: String::new(),
}
}
fn fixed_item(amount: f64) -> InvoiceItem {
InvoiceItem {
mode: "fixed".into(),
amount,
..rate_item(0.0, 0.0)
}
}
#[test]
fn state_list_has_special_codes_and_unique_codes() {
assert_eq!(STATES.len(), 38);
assert!(STATES.contains(&("96", "Other Countries")));
assert!(STATES.contains(&("97", "Other Territory")));
assert!(STATES.contains(&("29", "Karnataka")));
let mut codes: Vec<_> = STATES.iter().map(|(c, _)| *c).collect();
codes.dedup();
assert_eq!(codes.len(), STATES.len());
}
#[test]
fn tax_type_derivation() {
assert_eq!(derive_tax_type("unregistered", "29", "27"), TaxType::None);
assert_eq!(derive_tax_type("unregistered", "29", ""), TaxType::None);
assert_eq!(derive_tax_type("regular", "29", "29"), TaxType::CgstSgst);
assert_eq!(derive_tax_type("regular", "29", ""), TaxType::CgstSgst);
assert_eq!(derive_tax_type("regular", "29", "27"), TaxType::Igst);
assert_eq!(derive_tax_type("regular", "29", "96"), TaxType::Igst);
}
#[test]
fn tax_type_serde_strings() {
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\"");
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst);
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None);
}
#[test]
fn union_territory_labels() {
for code in ["04", "26", "31", "35", "38"] {
assert!(is_ut_without_legislature(code));
assert_eq!(second_head_label(code), "UTGST");
}
for code in ["07", "29", "34", "01", "96"] {
assert!(!is_ut_without_legislature(code));
assert_eq!(second_head_label(code), "SGST");
}
}
#[test]
fn document_type() {
assert_eq!(doc_type("unregistered"), "invoice");
assert_eq!(doc_type("regular"), "tax_invoice");
}
#[test]
fn gstin_checksum() {
assert_eq!(validate_gstin("27AAPFU0939F1ZV"), Ok(()));
assert_eq!(validate_gstin("07AAGFF2194N1Z1"), Ok(()));
assert_eq!(validate_gstin("29AAGCB7383J1Z4"), Ok(()));
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
// The algorithm gives W for this body, so a trailing 5 is a typo.
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit"));
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit"));
}
#[test]
fn gstin_rejects_bad_shapes() {
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters"));
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters"));
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format"));
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format"));
// Valid shape and checksum but state 99 does not exist.
let body = b"99AAPFU0939F1Z";
let mut g = String::from_utf8(body.to_vec()).unwrap();
g.push(gstin_check_char(body) as char);
assert!(validate_gstin(&g).unwrap_err().contains("state code"));
}
#[test]
fn pan_cross_check() {
assert!(gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939F"));
assert!(gstin_matches_pan("27AAPFU0939F1ZV", " aapfu0939f "));
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939G"));
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", ""));
}
#[test]
fn totals_cgst_sgst_18_percent() {
let t = compute_totals(&[fixed_item(7310.0)], 0, TaxType::CgstSgst, 1800);
assert_eq!(t.subtotal, 731_000);
assert_eq!(t.cgst, 65_790);
assert_eq!(t.sgst, 65_790);
assert_eq!(t.igst, 0);
assert_eq!(t.total, 731_000 + 131_580);
}
#[test]
fn totals_igst_5_percent_rounds_half_up() {
// 33.33 x 5% = 1.6665 -> 1.67; split in two heads it would be 0.83 + 0.83.
let igst = compute_totals(&[fixed_item(33.33)], 0, TaxType::Igst, 500);
assert_eq!(igst.igst, 167);
assert_eq!(igst.total, 3333 + 167);
let split = compute_totals(&[fixed_item(33.33)], 0, TaxType::CgstSgst, 500);
assert_eq!((split.cgst, split.sgst), (83, 83));
assert_eq!(split.total, 3333 + 166);
}
#[test]
fn totals_fractional_hours() {
let t = compute_totals(&[rate_item(1200.0, 1.5)], 0, TaxType::None, 0);
assert_eq!(t.subtotal, 180_000);
assert_eq!(t.total, 180_000);
// 99.99 x 2.5 = 249.975 -> 249.98
assert_eq!(line_amount_paise(&rate_item(99.99, 2.5)), 24_998);
}
#[test]
fn totals_discount_is_clamped() {
let items = [fixed_item(100.0), fixed_item(50.5)];
let over = compute_totals(&items, 99_999, TaxType::Igst, 1800);
assert_eq!(over.discount, 15_050);
assert_eq!(over.taxable, 0);
assert_eq!(over.total, 0);
let negative = compute_totals(&items, -500, TaxType::None, 0);
assert_eq!(negative.discount, 0);
assert_eq!(negative.total, 15_050);
let partial = compute_totals(&items, 5050, TaxType::None, 0);
assert_eq!(partial.taxable, 10_000);
}
#[test]
fn totals_with_no_tax_ignore_the_rate() {
let t = compute_totals(&[fixed_item(1000.0)], 0, TaxType::None, 1800);
assert_eq!((t.cgst, t.sgst, t.igst), (0, 0, 0));
assert_eq!(t.total, 100_000);
}
#[test]
fn words_match_the_typescript_implementation() {
// Expected strings were produced by running src/lib/numberToWords.ts.
let cases: &[(i64, &str)] = &[
(0, "Indian Rupees Zero Only"),
(1, "Indian Rupees Zero and One Paise Only"),
(100, "Indian Rupees One Only"),
(1950, "Indian Rupees Nineteen and Fifty Paise Only"),
(10_000, "Indian Rupees One Hundred Only"),
(10_101, "Indian Rupees One Hundred One and One Paise Only"),
(
99_999,
"Indian Rupees Nine Hundred Ninety Nine and Ninety Nine Paise Only",
),
(100_000, "Indian Rupees One Thousand Only"),
(
1_234_567,
"Indian Rupees Twelve Thousand Three Hundred Forty Five and Sixty Seven Paise Only",
),
(10_000_000, "Indian Rupees One Lakh Only"),
(10_000_005, "Indian Rupees One Lakh and Five Paise Only"),
(
123_456_789,
"Indian Rupees Twelve Lakh Thirty Four Thousand Five Hundred Sixty Seven and Eighty Nine Paise Only",
),
(1_000_000_000, "Indian Rupees One Crore Only"),
(1210, "Indian Rupees Twelve and Ten Paise Only"),
(65_790, "Indian Rupees Six Hundred Fifty Seven and Ninety Paise Only"),
(
8_625_820,
"Indian Rupees Eighty Six Thousand Two Hundred Fifty Eight and Twenty Paise Only",
),
(
99_999_999_999,
"Indian Rupees Ninety Nine Crore Ninety Nine Lakh Ninety Nine Thousand Nine Hundred Ninety Nine and Ninety Nine Paise Only",
),
(250_000_000_000, "Indian Rupees Two Hundred Fifty Crore Only"),
];
for (paise, expected) in cases {
assert_eq!(amount_in_words(*paise), *expected, "paise {paise}");
}
}
}
+82 -17
View File
@@ -1,34 +1,91 @@
mod commands;
mod db;
mod gst;
mod models;
use rusqlite::Connection;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
pub struct AppState {
pub db: Mutex<Connection>,
pub data_dir: PathBuf,
}
/// Show a native error dialog. Release builds have no console on Windows, so
/// without this a startup failure would look like the app silently not opening.
fn show_startup_error(app: &tauri::App, error: &dyn std::fmt::Display, data_dir: Option<&Path>) {
let location = data_dir
.map(|d| format!("\n\nData directory:\n{}", d.display()))
.unwrap_or_default();
eprintln!("Voiced could not start: {error}");
app.dialog()
.message(format!("{error}{location}"))
.title("Voiced could not start")
.kind(MessageDialogKind::Error)
.blocking_show();
}
fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>> {
let data_dir = match app.path().app_data_dir() {
Ok(dir) => dir,
Err(e) => {
show_startup_error(app, &format!("Could not resolve the application data directory: {e}"), None);
return Err(e.into());
}
};
if let Err(e) = std::fs::create_dir_all(&data_dir) {
show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir));
return Err(e.into());
}
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
Ok(conn) => conn,
Err(e) => {
show_startup_error(app, &format!("Could not open the database: {e}"), Some(&data_dir));
return Err(e.into());
}
};
if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) {
show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir));
return Err(e.into());
}
Ok(AppState {
db: Mutex::new(conn),
data_dir,
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
// combinations. Respect an explicit user setting, otherwise turn it off.
#[cfg(target_os = "linux")]
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
let builder = tauri::Builder::default();
// The single-instance plugin must be registered before every other plugin.
#[cfg(desktop)]
let builder = builder
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_window_state::Builder::default().build());
let result = builder
.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,
});
let state = init_state(app)?;
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -44,16 +101,24 @@ pub fn run() {
commands::clients::save_client,
commands::clients::delete_client,
commands::invoice::peek_next_invoice_number,
commands::invoice::create_invoice,
commands::invoice::issue_invoice,
commands::invoice::cancel_invoice,
commands::invoice::list_invoices,
commands::invoice::get_invoice,
commands::invoice::delete_invoice,
commands::invoice::save_draft,
commands::invoice::list_drafts,
commands::invoice::get_draft,
commands::invoice::delete_draft,
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");
.run(tauri::generate_context!());
if let Err(e) = result {
eprintln!("error while running the Voiced application: {e}");
std::process::exit(1);
}
}
+30 -13
View File
@@ -19,6 +19,10 @@ pub struct Settings {
pub currency: String,
pub onboarded: bool,
pub theme: String,
pub gst_registration: String,
pub default_hsn_sac: String,
pub signatory_name: String,
pub signatory_designation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -92,6 +96,8 @@ pub struct InvoiceItem {
pub amount: f64,
#[serde(default)]
pub sort_order: i64,
#[serde(default)]
pub hsn_sac: String,
}
fn default_mode() -> String {
@@ -102,6 +108,8 @@ fn default_unit() -> String {
"unit".to_string()
}
/// What the webview may send when issuing an invoice. Money totals, tax amounts,
/// the amount in words and the vendor/bank snapshots are derived on the Rust side.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InvoiceInput {
@@ -121,34 +129,25 @@ pub struct InvoiceInput {
#[serde(default)]
pub place_of_supply_state_code: String,
#[serde(default)]
pub subtotal: f64,
#[serde(default)]
pub discount: f64,
/// The tax treatment the user chose; it must match the derived one.
#[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,
pub reverse_charge: bool,
#[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 draft_id: Option<i64>,
#[serde(default)]
pub items: Vec<InvoiceItem>,
}
@@ -183,9 +182,18 @@ pub struct Invoice {
pub bank_snapshot: String,
pub signature_path: Option<String>,
pub notes: String,
/// draft | issued | cancelled
pub status: String,
pub created_at: String,
pub updated_at: String,
pub doc_type: String,
pub reverse_charge: bool,
/// JSON of the supplier details as they were when the invoice was issued; empty if unknown.
pub vendor_snapshot: String,
pub snapshot_origin: String,
pub cancelled_at: Option<String>,
pub cancel_reason: String,
pub archived_pdf_sha256: Option<String>,
pub items: Vec<InvoiceItem>,
}
@@ -201,3 +209,12 @@ pub struct InvoiceSummary {
pub status: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftSummary {
pub id: i64,
pub updated_at: String,
pub client_name: Option<String>,
pub total: Option<f64>,
}
+3 -3
View File
@@ -15,8 +15,8 @@
"title": "Voiced",
"width": 1360,
"height": 900,
"minWidth": 1024,
"minHeight": 720,
"minWidth": 960,
"minHeight": 600,
"resizable": true,
"center": true
}
@@ -41,7 +41,7 @@
],
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
"type": "embedBootstrapper"
},
"nsis": {
"installMode": "currentUser"
+13 -3
View File
@@ -88,9 +88,19 @@ export default function App() {
return (
<Theme theme={theme}>
<AppShell view={view} onNavigate={setView} theme={settings.theme} onToggleTheme={toggleTheme}>
{view === "new" && <NewInvoice settings={settings} onSettingsChange={refreshSettings} />}
{view === "invoices" && <InvoiceHistory settings={settings} />}
{view === "series" && <SeriesSettings />}
{/* Main views stay mounted so a half-typed invoice survives switching tabs. */}
<NewInvoice
settings={settings}
onSettingsChange={refreshSettings}
active={view === "new"}
onActivate={() => setView("new")}
/>
<div hidden={view !== "invoices"}>
<InvoiceHistory settings={settings} active={view === "invoices"} />
</div>
<div hidden={view !== "series"}>
<SeriesSettings active={view === "series"} />
</div>
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
</AppShell>
</Theme>
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
export interface ShortcutHandlers {
onNew?: () => void;
onSaveDraft?: () => void;
onIssue?: () => void;
onAddLine?: () => void;
onHelp?: () => void;
}
const isTyping = (target: EventTarget | null) => {
const el = target as HTMLElement | null;
if (!el) return false;
return el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable;
};
/**
* Global keyboard shortcuts. Ctrl combinations work everywhere; the bare `?` is ignored
* while typing. F5 / Ctrl+R are blocked in production builds because a webview reload
* would discard in-memory state.
*/
export function useShortcuts(handlers: ShortcutHandlers) {
const ref = useRef(handlers);
ref.current = handlers;
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
const ctrl = event.ctrlKey || event.metaKey;
const key = event.key.toLowerCase();
if (import.meta.env.PROD && (event.key === "F5" || (ctrl && key === "r"))) {
event.preventDefault();
return;
}
const run = (fn?: () => void) => {
event.preventDefault();
fn?.();
};
if (ctrl && !event.shiftKey && !event.altKey) {
if (key === "n") return run(ref.current.onNew);
if (key === "s") return run(ref.current.onSaveDraft);
if (key === "l") return run(ref.current.onAddLine);
if (key === "enter") return run(ref.current.onIssue);
}
if (!ctrl && !event.altKey && event.key === "?" && !isTyping(event.target)) {
run(ref.current.onHelp);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
}
export const SHORTCUT_LIST: { keys: string; action: string }[] = [
{ keys: "Ctrl + N", action: "New invoice" },
{ keys: "Ctrl + S", action: "Save draft" },
{ keys: "Ctrl + Enter", action: "Issue and export PDF" },
{ keys: "Ctrl + L", action: "Add line item" },
{ keys: "?", action: "Show this list" },
];
+11 -2
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type {
BankAccount,
Client,
DraftSummary,
Invoice,
InvoiceInput,
InvoiceSeries,
@@ -27,10 +28,18 @@ export const api = {
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
createInvoice: (input: InvoiceInput) => invoke<Invoice>("create_invoice", { input }),
issueInvoice: (input: InvoiceInput, renderPrefs: Record<string, unknown>) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
cancelInvoice: (id: number, reason: string) =>
invoke<Invoice>("cancel_invoice", { id, reason }),
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
deleteInvoice: (id: number) => invoke<void>("delete_invoice", { id }),
saveDraft: (id: number | null, payload: unknown) =>
invoke<number>("save_draft", { id, payload }),
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
getDraft: (id: number) => invoke<unknown>("get_draft", { id }),
deleteDraft: (id: number) => invoke<void>("delete_draft", { id }),
importAsset: (sourcePath: string, kind: string) =>
invoke<string>("import_asset", { sourcePath, kind }),
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "./draft";
import { EMPTY_ITEM } from "./types";
const defaults: InvoiceFormState = {
invoiceDate: "2026-01-01",
dueDate: "2026-01-31",
clientId: null,
clientName: "",
clientAddress: "",
clientGstin: "",
poNumber: "",
placeOfSupply: "29",
saveClient: false,
items: [{ ...EMPTY_ITEM, hsnSac: "9983" }],
discount: 0,
taxRate: 18,
reverseCharge: false,
bankId: 2,
signaturePath: "/sig/default.png",
notes: "",
};
const filled: InvoiceFormState = {
invoiceDate: "2026-03-05",
dueDate: "2026-04-04",
clientId: 7,
clientName: "Acme Pvt Ltd",
clientAddress: "1 Main Rd\nKolkata",
clientGstin: "19AAAAA0000A1Z5",
poNumber: "PO-9",
placeOfSupply: "19",
saveClient: true,
items: [
{ ...EMPTY_ITEM, description: "Design", mode: "rate", rate: 500, unit: "hour", quantity: 4, hsnSac: "9983" },
{ ...EMPTY_ITEM, description: "Fee", amount: 1200, sortOrder: 1, hsnSac: "9983" },
],
discount: 100,
taxRate: 12,
reverseCharge: true,
bankId: null,
signaturePath: null,
notes: "Thanks",
};
describe("draft payload", () => {
it("round-trips through JSON", () => {
const json = JSON.parse(JSON.stringify(toDraftPayload(filled)));
expect(fromDraftPayload(json, defaults)).toEqual(filled);
});
it("does not store derived totals", () => {
const keys = Object.keys(toDraftPayload(filled));
for (const k of ["total", "subtotal", "cgst", "sgst", "igst", "amountInWords"]) {
expect(keys).not.toContain(k);
}
});
it("falls back to defaults for missing fields", () => {
const out = fromDraftPayload({ clientName: "Only name" }, defaults);
expect(out).toEqual({ ...defaults, clientName: "Only name" });
});
it("tolerates garbage input and partial items", () => {
expect(fromDraftPayload(null, defaults)).toEqual(defaults);
expect(fromDraftPayload("x", defaults)).toEqual(defaults);
const out = fromDraftPayload({ items: [{ description: "A" }], discount: "oops" }, defaults);
expect(out.discount).toBe(0);
expect(out.items).toHaveLength(1);
expect(out.items[0]).toMatchObject({ description: "A", mode: "fixed", quantity: 1, hsnSac: "" });
});
});
+92
View File
@@ -0,0 +1,92 @@
import { EMPTY_ITEM } from "./types";
import type { InvoiceItem, LineMode, LineUnit } from "./types";
/** Everything that defines an invoice on the New invoice form. Derived totals are not part of it. */
export interface InvoiceFormState {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupply: string;
saveClient: boolean;
items: InvoiceItem[];
discount: number;
taxRate: number;
reverseCharge: boolean;
bankId: number | null;
signaturePath: string | null;
notes: string;
}
const str = (v: unknown, fallback: string) => (typeof v === "string" ? v : fallback);
const num = (v: unknown, fallback: number) =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
const bool = (v: unknown, fallback: boolean) => (typeof v === "boolean" ? v : fallback);
const nullableNum = (v: unknown, fallback: number | null) =>
v === null ? null : typeof v === "number" && Number.isFinite(v) ? v : fallback;
function itemFromJson(raw: unknown, index: number): InvoiceItem {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
return {
description: str(r.description, ""),
mode: (r.mode === "rate" ? "rate" : "fixed") as LineMode,
rate: num(r.rate, EMPTY_ITEM.rate),
unit: str(r.unit, EMPTY_ITEM.unit) as LineUnit,
quantity: num(r.quantity, EMPTY_ITEM.quantity),
amount: num(r.amount, EMPTY_ITEM.amount),
sortOrder: index,
hsnSac: str(r.hsnSac, ""),
};
}
export function toDraftPayload(state: InvoiceFormState): Record<string, unknown> {
return {
version: 1,
invoiceDate: state.invoiceDate,
dueDate: state.dueDate,
clientId: state.clientId,
clientName: state.clientName,
clientAddress: state.clientAddress,
clientGstin: state.clientGstin,
poNumber: state.poNumber,
placeOfSupply: state.placeOfSupply,
saveClient: state.saveClient,
items: state.items.map((item, index) => ({ ...item, sortOrder: index })),
discount: state.discount,
taxRate: state.taxRate,
reverseCharge: state.reverseCharge,
bankId: state.bankId,
signaturePath: state.signaturePath,
notes: state.notes,
};
}
/** Tolerant: any missing or wrongly typed field falls back to `defaults`. */
export function fromDraftPayload(json: unknown, defaults: InvoiceFormState): InvoiceFormState {
const r = (json && typeof json === "object" ? json : {}) as Record<string, unknown>;
const items = Array.isArray(r.items) && r.items.length > 0 ? r.items.map(itemFromJson) : defaults.items;
return {
invoiceDate: str(r.invoiceDate, defaults.invoiceDate),
dueDate: str(r.dueDate, defaults.dueDate),
clientId: nullableNum(r.clientId, defaults.clientId),
clientName: str(r.clientName, defaults.clientName),
clientAddress: str(r.clientAddress, defaults.clientAddress),
clientGstin: str(r.clientGstin, defaults.clientGstin),
poNumber: str(r.poNumber, defaults.poNumber),
placeOfSupply: str(r.placeOfSupply, defaults.placeOfSupply),
saveClient: bool(r.saveClient, defaults.saveClient),
items,
discount: num(r.discount, defaults.discount),
taxRate: num(r.taxRate, defaults.taxRate),
reverseCharge: bool(r.reverseCharge, defaults.reverseCharge),
bankId: nullableNum(r.bankId, defaults.bankId),
signaturePath:
r.signaturePath === null || typeof r.signaturePath === "string"
? r.signaturePath
: defaults.signaturePath,
notes: str(r.notes, defaults.notes),
};
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { computeLineAmount, computeTotals } from "./invoice";
import { EMPTY_ITEM, type InvoiceItem } from "./types";
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
// and the stored totals stay identical.
const fixed = (amount: number): InvoiceItem => ({ ...EMPTY_ITEM, mode: "fixed", amount });
const rate = (r: number, quantity: number): InvoiceItem => ({
...EMPTY_ITEM,
mode: "rate",
rate: r,
quantity,
unit: "hour",
});
describe("computeTotals", () => {
it("splits 18% on 7310.00 into 657.90 CGST and SGST", () => {
const t = computeTotals([fixed(7310)], 0, "cgst_sgst", 18);
expect(t.subtotal).toBe(7310);
expect(t.cgst).toBe(657.9);
expect(t.sgst).toBe(657.9);
expect(t.igst).toBe(0);
expect(t.total).toBe(8625.8);
});
it("rounds odd paise half up for IGST and per head for CGST/SGST", () => {
const igst = computeTotals([fixed(33.33)], 0, "igst", 5);
expect(igst.igst).toBe(1.67);
expect(igst.total).toBe(35);
const split = computeTotals([fixed(33.33)], 0, "cgst_sgst", 5);
expect([split.cgst, split.sgst]).toEqual([0.83, 0.83]);
expect(split.total).toBe(34.99);
});
it("handles fractional quantities", () => {
const t = computeTotals([rate(1200, 1.5)], 0, "none", 0);
expect(t.subtotal).toBe(1800);
expect(t.total).toBe(1800);
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
});
it("clamps the discount to the subtotal", () => {
const items = [fixed(100), fixed(50.5)];
const over = computeTotals(items, 999.99, "igst", 18);
expect(over.discount).toBe(150.5);
expect(over.taxable).toBe(0);
expect(over.total).toBe(0);
expect(computeTotals(items, -5, "none", 0).discount).toBe(0);
expect(computeTotals(items, 50.5, "none", 0).taxable).toBe(100);
});
it("ignores the rate when no tax applies", () => {
const t = computeTotals([fixed(1000)], 0, "none", 18);
expect(t.taxTotal).toBe(0);
expect(t.total).toBe(1000);
});
});
+32 -20
View File
@@ -1,12 +1,23 @@
import { round2 } from "./format";
import type { InvoiceItem, TaxType } from "./types";
// The preview must show exactly what Rust stores (src-tauri/src/gst.rs), so money is
// computed in integer paise with round-half-up, the same way.
const toPaise = (rupees: number) => Math.round((Number.isFinite(rupees) ? rupees : 0) * 100);
function linePaise(item: InvoiceItem): number {
if (item.mode === "rate") {
return Math.round(toPaise(item.rate || 0) * (item.quantity || 0));
}
return toPaise(item.amount || 0);
}
/** Round-half-up division for non-negative integers. */
const divRound = (num: number, den: number) => Math.floor((num + den / 2) / den);
/** Amount for a single line: a fixed total, or rate × quantity. */
export function computeLineAmount(item: InvoiceItem): number {
if (item.mode === "rate") {
return round2((item.rate || 0) * (item.quantity || 0));
}
return round2(item.amount || 0);
return linePaise(item) / 100;
}
export interface Totals {
@@ -26,29 +37,30 @@ export function computeTotals(
taxType: TaxType,
taxRate: number,
): Totals {
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0));
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal));
const taxable = round2(subtotal - discountValue);
const subtotal = items.reduce((sum, item) => sum + linePaise(item), 0);
const discountPaise = Math.min(Math.max(toPaise(discount), 0), Math.max(subtotal, 0));
const taxable = subtotal - discountPaise;
const rateBp = Math.round((Number.isFinite(taxRate) ? taxRate : 0) * 100);
let cgst = 0;
let sgst = 0;
let igst = 0;
if (taxType === "cgst_sgst") {
cgst = round2((taxable * (taxRate / 2)) / 100);
sgst = round2((taxable * (taxRate / 2)) / 100);
cgst = divRound(taxable * rateBp, 20_000);
sgst = cgst;
} else if (taxType === "igst") {
igst = round2((taxable * taxRate) / 100);
igst = divRound(taxable * rateBp, 10_000);
}
const taxTotal = round2(cgst + sgst + igst);
const taxTotal = cgst + sgst + igst;
return {
subtotal,
discount: discountValue,
taxable,
cgst,
sgst,
igst,
taxTotal,
total: round2(taxable + taxTotal),
subtotal: subtotal / 100,
discount: discountPaise / 100,
taxable: taxable / 100,
cgst: cgst / 100,
sgst: sgst / 100,
igst: igst / 100,
taxTotal: taxTotal / 100,
total: (taxable + taxTotal) / 100,
};
}
+14 -2
View File
@@ -17,12 +17,24 @@ function blobToBase64(blob: Blob): Promise<string> {
});
}
function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings {
if (!snapshot) return live;
try {
return { ...live, ...(JSON.parse(snapshot) as Partial<Settings>) };
} catch {
return live;
}
}
export async function buildPdfProps(
invoice: Invoice,
settings: Settings,
bank: BankAccount | null,
): Promise<InvoicePdfProps> {
const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null;
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
// legacy rows without a snapshot.
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath)
: null;
@@ -37,7 +49,7 @@ export async function buildPdfProps(
}
return {
vendor: settings,
vendor,
bank: resolvedBank,
logoDataUri,
signatureDataUri,
+59 -12
View File
@@ -19,6 +19,10 @@ export interface Settings {
currency: string;
onboarded: boolean;
theme: string;
gstRegistration: GstRegistration;
defaultHsnSac: string;
signatoryName: string;
signatoryDesignation: string;
}
export interface BankAccount {
@@ -61,9 +65,41 @@ export interface InvoiceItem {
quantity: number;
amount: number;
sortOrder: number;
/** Optional so older callers that build items by hand keep compiling; stored as "" when absent. */
hsnSac?: string;
}
export type GstRegistration = "unregistered" | "regular";
/** What the webview sends to issue an invoice; totals, tax and words are derived in Rust. */
export interface InvoiceInput {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupplyStateCode: string;
discount: number;
taxType: TaxType;
taxRate: number;
reverseCharge: boolean;
bankAccountId: number | null;
signaturePath: string | null;
notes: string;
saveClient: boolean;
draftId: number | null;
items: InvoiceItem[];
}
export type InvoiceStatus = "draft" | "issued" | "cancelled";
/** An invoice as stored: all amounts are derived on the Rust side. */
export interface Invoice {
id: number;
number: string;
seriesId: number | null;
invoiceDate: string;
dueDate: string;
clientId: number | null;
@@ -85,18 +121,25 @@ export interface InvoiceInput {
bankSnapshot: string;
signaturePath: string | null;
notes: string;
saveClient: boolean;
status: InvoiceStatus;
createdAt: string;
updatedAt: string;
docType: "invoice" | "tax_invoice";
reverseCharge: boolean;
/** JSON of the supplier details at issue time; empty when unknown. */
vendorSnapshot: string;
snapshotOrigin: string;
cancelledAt: string | null;
cancelReason: string;
archivedPdfSha256: string | null;
items: InvoiceItem[];
}
export interface Invoice extends Omit<InvoiceInput, "saveClient"> {
export interface DraftSummary {
id: number;
number: string;
seriesId: number | null;
status: string;
createdAt: string;
updatedAt: string;
items: InvoiceItem[];
clientName: string | null;
total: number | null;
}
export interface InvoiceSummary {
@@ -106,7 +149,7 @@ export interface InvoiceSummary {
dueDate: string;
clientName: string;
total: number;
status: string;
status: InvoiceStatus;
createdAt: string;
}
@@ -118,10 +161,12 @@ export const EMPTY_ITEM: InvoiceItem = {
quantity: 1,
amount: 0,
sortOrder: 0,
hsnSac: "",
};
/** GST state codes, same list as STATES in src-tauri/src/gst.rs (from India Compliance). */
export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "01", name: "Jammu & Kashmir" },
{ code: "01", name: "Jammu and Kashmir" },
{ code: "02", name: "Himachal Pradesh" },
{ code: "03", name: "Punjab" },
{ code: "04", name: "Chandigarh" },
@@ -145,18 +190,20 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "22", name: "Chhattisgarh" },
{ code: "23", name: "Madhya Pradesh" },
{ code: "24", name: "Gujarat" },
{ code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" },
{ code: "26", name: "Dadra and Nagar Haveli and Daman and Diu" },
{ code: "27", name: "Maharashtra" },
{ code: "29", name: "Karnataka" },
{ code: "30", name: "Goa" },
{ code: "31", name: "Lakshadweep" },
{ code: "31", name: "Lakshadweep Islands" },
{ code: "32", name: "Kerala" },
{ code: "33", name: "Tamil Nadu" },
{ code: "34", name: "Puducherry" },
{ code: "35", name: "Andaman & Nicobar Islands" },
{ code: "35", name: "Andaman and Nicobar Islands" },
{ code: "36", name: "Telangana" },
{ code: "37", name: "Andhra Pradesh" },
{ code: "38", name: "Ladakh" },
{ code: "96", name: "Other Countries" },
{ code: "97", name: "Other Territory" },
];
export const UNIT_LABELS: Record<LineUnit, string> = {
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
describe("GSTIN checksum", () => {
it("accepts valid GSTINs", () => {
expect(gstinChecksumError("27AAPFU0939F1ZV")).toBeUndefined();
expect(gstinChecksumError("07AAGFF2194N1Z1")).toBeUndefined();
expect(gstinChecksumError("29ABCDE1234F1ZW")).toBeUndefined();
});
it("rejects a wrong check digit and bad shapes", () => {
expect(gstinChecksumError("29ABCDE1234F1Z5")).toMatch(/expected W/);
expect(gstinChecksumError("27AAPFU0939F1ZX")).toMatch(/check digit/);
expect(gstinChecksumError("27AAPFU0939F1Z")).toMatch(/15-character/);
});
it("cross-checks the PAN and the state", () => {
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939F")).toBeUndefined();
expect(gstinFullError("27AAPFU0939F1ZV", "AAPFU0939G")).toMatch(/PAN/);
expect(gstinFullError("")).toBeUndefined();
expect(vendorGstinError("unregistered", "", "", "29")).toBeUndefined();
expect(vendorGstinError("regular", "", "", "27")).toMatch(/needs a GSTIN/);
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "29")).toMatch(/state code/);
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
});
});
+44
View File
@@ -22,6 +22,50 @@ export function gstinError(value: string): string | undefined {
return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN";
}
const GSTIN_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/** Standard GSTIN mod-36 check digit. Returns an error message, or undefined when valid. */
export function gstinChecksumError(value: string): string | undefined {
const v = norm(value);
if (!GSTIN_RE.test(v)) return "Enter a valid 15-character GSTIN";
let sum = 0;
for (let i = 0; i < 14; i++) {
const product = GSTIN_CHARS.indexOf(v[i]) * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
const expected = GSTIN_CHARS[(36 - (sum % 36)) % 36];
return v[14] === expected ? undefined : `GSTIN check digit looks wrong (expected ${expected})`;
}
/**
* Full GSTIN check: shape, check digit and, when a PAN is given, that the GSTIN
* contains that PAN. Empty (or "NA") is valid; the field is optional.
*/
export function gstinFullError(value: string, pan = ""): string | undefined {
const v = norm(value);
if (!v || v === "NA") return undefined;
const base = gstinError(v) ?? gstinChecksumError(v);
if (base) return base;
if (pan.trim() && v.slice(2, 12) !== norm(pan)) return "GSTIN does not contain the PAN";
return undefined;
}
/** Supplier GSTIN rules for a registered business; an unregistered one needs none. */
export function vendorGstinError(
registration: string,
gstin: string,
pan: string,
stateCode: string,
): string | undefined {
if (registration === "unregistered") return undefined;
const g = norm(gstin);
if (!g) return "A registered business needs a GSTIN";
const base = gstinFullError(g, pan);
if (base) return base;
if (g.slice(0, 2) !== stateCode) return `GSTIN starts with ${g.slice(0, 2)}, but your state code is ${stateCode}`;
return undefined;
}
export function emailError(value: string): string | undefined {
if (!value.trim()) return undefined;
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
+4
View File
@@ -33,6 +33,10 @@ const vendor: Settings = {
currency: "INR",
onboarded: true,
theme: "g10",
gstRegistration: "unregistered",
defaultHsnSac: "",
signatoryName: "",
signatoryDesignation: "",
};
const bank: BankAccount = {
+5
View File
@@ -143,6 +143,11 @@ body {
gap: 0.5rem;
}
// Views stay mounted while hidden so their state survives navigation.
[hidden] {
display: none !important;
}
.voiced-page {
padding: 2rem;
max-width: 1600px;
+71 -3
View File
@@ -23,8 +23,14 @@ import {
import { Add, Save, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types";
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
import {
INDIAN_STATES,
type BankAccount,
type GstRegistration,
type Settings,
type TaxType,
} from "../lib/types";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
const blankBank = (): BankAccount => ({
@@ -70,7 +76,19 @@ export default function AppSettings({
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
const registered = form.gstRegistration !== "unregistered";
const gstinProblem = vendorGstinError(
form.gstRegistration,
form.vendorGstin,
form.vendorPan,
form.vendorStateCode,
);
const saveAll = async () => {
if (gstinProblem) {
setError(gstinProblem);
return;
}
setBusy(true);
setError(null);
setNotice(null);
@@ -140,6 +158,22 @@ export default function AppSettings({
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3>
<Grid narrow>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="GST registration"
name="s-gstReg"
orientation="horizontal"
valueSelected={form.gstRegistration}
onChange={(value) => {
set("gstRegistration", value as GstRegistration);
if (value === "unregistered") set("defaultTaxType", "none");
else if (form.defaultTaxType === "none") set("defaultTaxType", "cgst_sgst");
}}
>
<RadioButton id="s-gr-un" labelText="Unregistered" value="unregistered" />
<RadioButton id="s-gr-reg" labelText="Regular" value="regular" />
</RadioButtonGroup>
</Column>
<Column sm={4} md={8} lg={12}>
<TextInput
id="s-vendorName"
@@ -187,14 +221,18 @@ export default function AppSettings({
invalidText={panError(form.vendorPan)}
/>
</Column>
{registered ? (
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-vendorGstin"
labelText="GSTIN (blank if unregistered)"
labelText="GSTIN"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
invalid={Boolean(gstinProblem)}
invalidText={gstinProblem}
/>
</Column>
) : null}
<Column sm={4} md={8} lg={12}>
<Select
id="s-vendorState"
@@ -207,6 +245,22 @@ export default function AppSettings({
))}
</Select>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-signatoryName"
labelText="Authorised signatory name"
value={form.signatoryName}
onChange={(e) => set("signatoryName", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={6}>
<TextInput
id="s-signatoryDesignation"
labelText="Signatory designation"
value={form.signatoryDesignation}
onChange={(e) => set("signatoryDesignation", e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={8}>
<ImagePicker
kind="logo"
@@ -229,6 +283,8 @@ export default function AppSettings({
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3>
<Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
@@ -240,6 +296,16 @@ export default function AppSettings({
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
@@ -261,6 +327,7 @@ export default function AppSettings({
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
@@ -273,6 +340,7 @@ export default function AppSettings({
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>
+53 -16
View File
@@ -3,6 +3,7 @@ import {
Button,
InlineLoading,
InlineNotification,
Modal,
Table,
TableBody,
TableCell,
@@ -10,19 +11,22 @@ import {
TableHead,
TableHeader,
TableRow,
Tag,
TextInput,
} from "@carbon/react";
import { Download, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { Download, Close } from "@carbon/icons-react";
import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf";
import type { InvoiceSummary, Settings } from "../lib/types";
export default function InvoiceHistory({ settings }: { settings: Settings }) {
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState("");
const load = async () => {
try {
@@ -35,9 +39,10 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
}
};
// The view stays mounted, so refetch every time it is shown again.
useEffect(() => {
void load();
}, []);
if (active) void load();
}, [active]);
const onExport = async (id: number) => {
setBusyId(id);
@@ -52,16 +57,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
}
};
const onDelete = async (row: InvoiceSummary) => {
const ok = await confirm(`Delete invoice ${row.number}? This cannot be undone.`, {
title: "Delete invoice",
kind: "warning",
});
if (!ok) return;
const onCancel = async () => {
if (!cancelTarget) return;
try {
await api.deleteInvoice(row.id);
await api.cancelInvoice(cancelTarget.id, reason);
setCancelTarget(null);
setReason("");
await load();
} catch (e) {
setCancelTarget(null);
setError(String(e));
}
};
@@ -99,7 +103,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</TableCell>
<TableCell>{row.status}</TableCell>
<TableCell>
{row.status === "cancelled" ? (
<Tag type="red" size="sm">
Cancelled
</Tag>
) : (
row.status
)}
</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button
@@ -111,14 +123,19 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
>
{busyId === row.id ? "Exporting…" : "PDF"}
</Button>
{row.status === "issued" ? (
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={() => onDelete(row)}
renderIcon={Close}
onClick={() => {
setReason("");
setCancelTarget(row);
}}
>
Delete
Cancel
</Button>
) : null}
</div>
</TableCell>
</TableRow>
@@ -127,6 +144,26 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
</Table>
</TableContainer>
)}
<Modal
open={cancelTarget !== null}
danger
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
primaryButtonText="Cancel invoice"
secondaryButtonText="Keep invoice"
onRequestClose={() => setCancelTarget(null)}
onRequestSubmit={onCancel}
>
<p style={{ marginBottom: "1rem" }}>
The invoice stays in the list as cancelled and its number is not reused.
</p>
<TextInput
id="cancelReason"
labelText="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</Modal>
</div>
);
}
+641 -83
View File
File diff suppressed because it is too large Load Diff
+42 -5
View File
@@ -15,9 +15,9 @@ import {
TextInput,
} from "@carbon/react";
import { api } from "../lib/api";
import type { BankAccount, Settings, TaxType } from "../lib/types";
import type { BankAccount, GstRegistration, Settings, TaxType } from "../lib/types";
import { INDIAN_STATES } from "../lib/types";
import { emailError, ifscError, panError, phoneError } from "../lib/validators";
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
interface OnboardingProps {
@@ -73,9 +73,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
const bankSet = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setBank((prev) => ({ ...prev, [key]: value }));
const registered = form.gstRegistration !== "unregistered";
const gstinProblem = vendorGstinError(
form.gstRegistration,
form.vendorGstin,
form.vendorPan,
form.vendorStateCode,
);
const businessInvalid = useMemo(
() => Boolean(panError(form.vendorPan) || emailError(form.vendorEmail) || phoneError(form.vendorPhone)),
[form.vendorPan, form.vendorEmail, form.vendorPhone],
() =>
Boolean(
panError(form.vendorPan) ||
emailError(form.vendorEmail) ||
phoneError(form.vendorPhone) ||
gstinProblem,
),
[form.vendorPan, form.vendorEmail, form.vendorPhone, gstinProblem],
);
const finish = async () => {
@@ -110,6 +123,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
adjust anything that has changed.
</p>
</Column>
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="GST registration"
name="ob-gstReg"
orientation="horizontal"
valueSelected={form.gstRegistration}
onChange={(value) => {
set("gstRegistration", value as GstRegistration);
if (value === "unregistered") set("defaultTaxType", "none");
else if (form.defaultTaxType === "none") set("defaultTaxType", "cgst_sgst");
}}
>
<RadioButton id="ob-gr-un" labelText="Unregistered" value="unregistered" />
<RadioButton id="ob-gr-reg" labelText="Regular" value="regular" />
</RadioButtonGroup>
</Column>
<Column sm={4} md={8} lg={12}>
<TextInput
id="vendorName"
@@ -158,14 +187,18 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
invalidText={panError(form.vendorPan)}
/>
</Column>
{registered ? (
<Column sm={4} md={4} lg={6}>
<TextInput
id="vendorGstin"
labelText="GSTIN (leave blank if not registered)"
labelText="GSTIN"
value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
invalid={Boolean(gstinProblem)}
invalidText={gstinProblem}
/>
</Column>
) : null}
<Column sm={4} md={8} lg={12}>
<Select
id="vendorState"
@@ -287,6 +320,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => setPadding(Number(value) || 3)}
/>
</Column>
{registered ? (
<Column sm={4} md={4} lg={6}>
<NumberInput
id="taxRate"
@@ -297,6 +331,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
) : null}
<Column sm={4} md={4} lg={6}>
<NumberInput
id="terms"
@@ -307,6 +342,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
{registered ? (
<Column sm={4} md={8} lg={12}>
<RadioButtonGroup
legendText="Default tax treatment"
@@ -319,6 +355,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
<RadioButton id="tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
),
},
+10 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Button,
Column,
@@ -20,7 +20,7 @@ import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { InvoiceSeries } from "../lib/types";
export default function SeriesSettings() {
export default function SeriesSettings({ active: visible }: { active: boolean }) {
const [active, setActive] = useState<InvoiceSeries | null>(null);
const [all, setAll] = useState<InvoiceSeries[]>([]);
const [loading, setLoading] = useState(true);
@@ -31,14 +31,18 @@ export default function SeriesSettings() {
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const seeded = useRef(false);
const load = async () => {
try {
const [current, list] = await Promise.all([api.getActiveSeries(), api.listSeries()]);
setActive(current);
setAll(list);
if (current) {
// Only seed the form once, so a refetch on re-entry does not overwrite what was typed.
if (current && !seeded.current) {
setPrefix(current.prefix);
setPadding(current.padding);
seeded.current = true;
}
setError(null);
} catch (e) {
@@ -48,9 +52,10 @@ export default function SeriesSettings() {
}
};
// The view stays mounted, so refetch every time it is shown again.
useEffect(() => {
void load();
}, []);
if (visible) void load();
}, [visible]);
const startNew = async () => {
const ok = await confirm(