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

issue_invoice allocates the number, freezes vendor and bank details and
stores server-computed totals in one transaction. Issued invoices are
cancelled, not deleted. Tax heads derive from supplier state and place of
supply (a disagreeing choice is rejected); unregistered suppliers issue a
plain Invoice. Adds GSTIN checksum validation, canonical India Compliance
state list, server-side drafts, series validation, relative and
magic-byte-checked asset paths, and GST settings. PDF re-exports use the
frozen vendor snapshot.
This commit is contained in:
2026-10-04 04:15:58 +05:30
parent ecf57c9221
commit a00c816635
19 changed files with 2403 additions and 327 deletions
+195 -55
View File
@@ -1,8 +1,10 @@
use crate::AppState; use crate::AppState;
use base64::{engine::general_purpose::STANDARD, Engine}; use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use tauri::State; use tauri::State;
const FORMAT_ERROR: &str = "Use a PNG or JPEG image";
fn safe_kind(kind: &str) -> String { fn safe_kind(kind: &str) -> String {
kind.chars() kind.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
@@ -10,47 +12,88 @@ fn safe_kind(kind: &str) -> String {
.to_lowercase() .to_lowercase()
} }
fn sanitize_name(name: &str) -> String { /// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and
name.chars() /// silently drops everything else, so nothing else is accepted.
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_') /// Returns (extension, MIME type).
.collect() 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"))
fn extension_of(path: &str) -> String { } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Path::new(path) Ok(("jpg", "image/jpeg"))
.extension() } else {
.and_then(|e| e.to_str()) Err(FORMAT_ERROR.to_string())
.map(|e| e.to_lowercase())
.unwrap_or_else(|| "png".to_string())
}
fn mime_for(ext: &str) -> &'static str {
match ext {
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"gif" => "image/gif",
_ => "image/png",
} }
} }
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> { fn assets_dir(data_dir: &Path) -> Result<PathBuf, String> {
let dir = state.data_dir.join("assets"); let dir = data_dir.join("assets");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir) Ok(dir)
} }
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> { /// Resolve a stored asset path (relative to the data dir, or a legacy absolute one)
let dir = assets_dir(state)?; /// to a real file inside `<data_dir>/assets`. `..`, symlink escapes and anything
let file = format!( /// outside the assets directory are rejected.
"{}-{}.{}", pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
safe_kind(kind), let raw = Path::new(path);
uuid::Uuid::new_v4().simple(), if path.trim().is_empty() {
ext return Err("Asset path is empty".into());
); }
let path = dir.join(file); if raw.components().any(|c| matches!(c, Component::ParentDir)) {
std::fs::write(&path, bytes).map_err(|e| e.to_string())?; return Err("Asset path must not contain '..'".into());
Ok(path.to_string_lossy().to_string()) }
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. /// 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, kind: String,
) -> Result<String, String> { ) -> Result<String, String> {
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?; let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
let ext = extension_of(&source_path); write_asset(&state.data_dir, &kind, &bytes)
write_asset(&state, &kind, &ext, &bytes)
} }
/// Used when the frontend already holds the bytes (base64) instead of a path. /// 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] #[tauri::command]
pub fn save_asset_bytes( pub fn save_asset_bytes(
state: State<AppState>, state: State<AppState>,
kind: String, kind: String,
file_name: String, #[allow(unused_variables)] file_name: String,
data_base64: String, data_base64: String,
) -> Result<String, String> { ) -> Result<String, String> {
let bytes = STANDARD let bytes = STANDARD
.decode(data_base64.as_bytes()) .decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let ext = extension_of(&sanitize_name(&file_name)); write_asset(&state.data_dir, &kind, &bytes)
write_asset(&state, &kind, &ext, &bytes)
} }
/// Read a stored asset back as a data URI so it can be embedded in the PDF. /// Read a stored asset back as a data URI so it can be embedded in the PDF.
#[tauri::command] #[tauri::command]
pub fn read_asset_data_uri(path: String) -> Result<String, String> { pub fn read_asset_data_uri(state: State<AppState>, path: String) -> Result<String, String> {
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; asset_data_uri(&state.data_dir, &path)
let ext = extension_of(&path);
Ok(format!(
"data:{};base64,{}",
mime_for(&ext),
STANDARD.encode(bytes)
))
} }
#[tauri::command] #[tauri::command]
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> { pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
let dir = assets_dir(&state)?; remove_asset_file(&state.data_dir, &path)
let target = PathBuf::from(&path);
// Only allow deleting files inside the managed assets directory.
if target.starts_with(&dir) && target.exists() {
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
}
Ok(())
} }
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path. /// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
#[tauri::command] #[tauri::command]
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> { 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 let bytes = STANDARD
.decode(data_base64.as_bytes()) .decode(data_base64.as_bytes())
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if let Some(parent) = Path::new(&path).parent() { 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::create_dir_all(parent).ok();
} }
std::fs::write(&path, bytes).map_err(|e| e.to_string()) 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::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 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; use tauri::State;
const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name, 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, 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, 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> { fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
Ok(Invoice { Ok(Invoice {
@@ -38,13 +48,20 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
status: row.get(24)?, status: row.get(24)?,
created_at: row.get(25)?, created_at: row.get(25)?,
updated_at: row.get(26)?, 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(), items: Vec::new(),
}) })
} }
fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> { fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> {
let mut stmt = conn.prepare( 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", FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC",
)?; )?;
let rows = stmt.query_map(params![invoice_id], |row| { 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)?, quantity: row.get(5)?,
amount: row.get(6)?, amount: row.get(6)?,
sort_order: row.get(7)?, sort_order: row.get(7)?,
hsn_sac: row.get(8)?,
}) })
})?; })?;
rows.collect() rows.collect()
@@ -90,31 +108,224 @@ pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String
} }
} }
#[tauri::command] fn paise_to_rupees(paise: i64) -> f64 {
pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Invoice, String> { paise as f64 / 100.0
let mut conn = state.db.lock().map_err(|e| e.to_string())?; }
let tx = conn.transaction().map_err(|e| e.to_string())?;
let series = tx 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( .query_row(
"SELECT id, prefix, padding, next_number FROM invoice_series "SELECT id, prefix, padding, next_number FROM invoice_series
WHERE is_active = 1 ORDER BY id DESC LIMIT 1", WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
[], [],
|r| { |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
},
) )
.optional() .optional()
.map_err(|e| e.to_string())? .map_err(db)?
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?; .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); 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. // Persist the client first when the user asked to save a new one.
let client_id = match input.client_id { let client_id = match input.client_id {
@@ -126,65 +337,78 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
params![ params![
input.client_name, input.client_name,
input.client_address, input.client_address,
input.client_gstin, client_gstin,
input.place_of_supply_state_code, stored_pos,
input.po_number, input.po_number,
chrono::Utc::now().to_rfc3339() now
], ],
) )
.map_err(|e| e.to_string())?; .map_err(db)?;
Some(tx.last_insert_rowid()) Some(tx.last_insert_rowid())
} }
None => None, None => None,
}; };
let now = chrono::Utc::now().to_rfc3339();
tx.execute( tx.execute(
r#"INSERT INTO invoices r#"INSERT INTO invoices
(number, series_id, invoice_date, due_date, client_id, client_name, client_address, (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, 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, 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, doc_type,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, reverse_charge, vendor_snapshot, render_prefs, snapshot_origin, created_at, updated_at)
?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#, VALUES (:number, :series_id, :invoice_date, :due_date, :client_id, :client_name,
params![ :client_address, :client_gstin, :po_number, :pos, :subtotal, :discount,
number, :tax_type, :tax_rate, :cgst, :sgst, :igst, :total, :words, :bank_id,
series_id, :bank_snapshot, :signature_path, :notes, 'issued', :doc_type,
input.invoice_date, :reverse_charge, :vendor_snapshot, :render_prefs, 'issued', :now, :now)"#,
input.due_date, named_params! {
client_id, ":number": number,
input.client_name, ":series_id": series_id,
input.client_address, ":invoice_date": input.invoice_date.trim(),
input.client_gstin, ":due_date": input.due_date.trim(),
input.po_number, ":client_id": client_id,
input.place_of_supply_state_code, ":client_name": input.client_name,
input.subtotal, ":client_address": input.client_address,
input.discount, ":client_gstin": client_gstin,
input.tax_type, ":po_number": input.po_number,
input.tax_rate, ":pos": stored_pos,
input.cgst_amount, ":subtotal": paise_to_rupees(totals.subtotal),
input.sgst_amount, ":discount": paise_to_rupees(totals.discount),
input.igst_amount, ":tax_type": derived.as_str(),
input.total, ":tax_rate": tax_rate,
input.amount_in_words, ":cgst": paise_to_rupees(totals.cgst),
input.bank_account_id, ":sgst": paise_to_rupees(totals.sgst),
input.bank_snapshot, ":igst": paise_to_rupees(totals.igst),
input.signature_path, ":total": paise_to_rupees(totals.total),
input.notes, ":words": words,
now, ":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 invoice_id = tx.last_insert_rowid();
{ {
let mut stmt = tx let mut stmt = tx
.prepare( .prepare(
"INSERT INTO invoice_items "INSERT INTO invoice_items
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order) (invoice_id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", 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() { for (i, item) in input.items.iter().enumerate() {
stmt.execute(params![ stmt.execute(params![
invoice_id, invoice_id,
@@ -193,10 +417,11 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
item.rate, item.rate,
item.unit, item.unit,
item.quantity, item.quantity,
item.amount, paise_to_rupees(gst::line_amount_paise(item)),
i as i64, 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", "UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
params![series_id], 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())?; .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())?; #[tauri::command]
pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result<Invoice, String> {
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string()) let mut conn = state.db.lock().map_err(|e| e.to_string())?;
cancel_invoice_impl(&mut conn, id, &reason)
} }
#[tauri::command] #[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()) 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] #[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())?; 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())?; .map_err(|e| e.to_string())?;
Ok(()) 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!("AP/{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!("AP/{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!("AP/{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::db::format_number;
use crate::models::InvoiceSeries; use crate::models::InvoiceSeries;
use crate::AppState; use crate::AppState;
use rusqlite::{params, Row}; use rusqlite::{params, Connection, Row};
use tauri::State; use tauri::State;
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> { 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) 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. /// 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] #[tauri::command]
pub fn start_new_series( pub fn start_new_series(
state: State<AppState>, state: State<AppState>,
prefix: String, prefix: String,
padding: i64, padding: i64,
) -> Result<InvoiceSeries, String> { ) -> Result<InvoiceSeries, String> {
let prefix = prefix.trim().to_string(); let mut conn = state.db.lock().map_err(|e| e.to_string())?;
if prefix.is_empty() { start_new_series_impl(&mut conn, &prefix, padding)
return Err("Series prefix cannot be empty".into()); }
#[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())?; #[test]
conn.execute("UPDATE invoice_series SET is_active = 0", []) fn prefix_charset_and_length_rules() {
.map_err(|e| e.to_string())?; assert!(validate_series_format("AP/2026-", 3).is_ok());
conn.execute( assert!(validate_series_format("INV", 1).is_ok());
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at) assert!(validate_series_format("", 3).is_err());
VALUES (?1, ?2, 1, 1, ?3)", assert!(validate_series_format("-AP", 3).is_err());
params![prefix, padding, chrono::Utc::now().to_rfc3339()], 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())?; .unwrap();
let id = conn.last_insert_rowid(); assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
conn.query_row( let active: i64 = c
&format!("{} WHERE id = ?1", SERIES_SELECT), .query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
params![id], .unwrap();
map_series, assert_eq!(active, 1);
) }
.map_err(|e| e.to_string())
} }
+92 -8
View File
@@ -1,14 +1,14 @@
use crate::models::{BankAccount, Settings}; use crate::models::{BankAccount, Settings};
use crate::AppState; use crate::AppState;
use rusqlite::{params, Row}; use rusqlite::{params, Connection, Row};
use tauri::State; 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, vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name, default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
signatory_designation"; signatory_designation";
fn map_settings(row: &Row) -> rusqlite::Result<Settings> { pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
Ok(Settings { Ok(Settings {
vendor_name: row.get(0)?, vendor_name: row.get(0)?,
vendor_address: row.get(1)?, vendor_address: row.get(1)?,
@@ -84,7 +84,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
get_settings(state) get_settings(state)
} }
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> { pub(crate) fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
Ok(BankAccount { Ok(BankAccount {
id: Some(row.get(0)?), id: Some(row.get(0)?),
label: row.get(1)?, label: row.get(1)?,
@@ -169,10 +169,94 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
.map_err(|e| e.to_string()) .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] #[tauri::command]
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> { pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let mut conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id]) delete_bank_impl(&mut conn, id)
.map_err(|e| e.to_string())?; }
Ok(())
#[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]));
}
} }
+145
View File
@@ -303,6 +303,91 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
Ok(()) 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. "AP/2026-" + 1 -> "AP/2026-001". /// Format an invoice number from a series definition, e.g. "AP/2026-" + 1 -> "AP/2026-001".
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String { pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize) format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
@@ -593,4 +678,64 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104"))); assert!(names.iter().all(|n| !n.contains("20260101") && !n.contains("20260104")));
assert!(dir.path().join("unrelated.db").exists()); 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", "Arunachal 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}");
}
}
}
+11 -2
View File
@@ -1,5 +1,6 @@
mod commands; mod commands;
mod db; mod db;
mod gst;
mod models; mod models;
use rusqlite::Connection; use rusqlite::Connection;
@@ -46,6 +47,10 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
return Err(e.into()); 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 { Ok(AppState {
db: Mutex::new(conn), db: Mutex::new(conn),
data_dir, data_dir,
@@ -96,10 +101,14 @@ pub fn run() {
commands::clients::save_client, commands::clients::save_client,
commands::clients::delete_client, commands::clients::delete_client,
commands::invoice::peek_next_invoice_number, commands::invoice::peek_next_invoice_number,
commands::invoice::create_invoice, commands::invoice::issue_invoice,
commands::invoice::cancel_invoice,
commands::invoice::list_invoices, commands::invoice::list_invoices,
commands::invoice::get_invoice, 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::import_asset,
commands::assets::save_asset_bytes, commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri, commands::assets::read_asset_data_uri,
+26 -13
View File
@@ -96,6 +96,8 @@ pub struct InvoiceItem {
pub amount: f64, pub amount: f64,
#[serde(default)] #[serde(default)]
pub sort_order: i64, pub sort_order: i64,
#[serde(default)]
pub hsn_sac: String,
} }
fn default_mode() -> String { fn default_mode() -> String {
@@ -106,6 +108,8 @@ fn default_unit() -> String {
"unit".to_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)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct InvoiceInput { pub struct InvoiceInput {
@@ -125,34 +129,25 @@ pub struct InvoiceInput {
#[serde(default)] #[serde(default)]
pub place_of_supply_state_code: String, pub place_of_supply_state_code: String,
#[serde(default)] #[serde(default)]
pub subtotal: f64,
#[serde(default)]
pub discount: f64, pub discount: f64,
/// The tax treatment the user chose; it must match the derived one.
#[serde(default = "default_tax_type")] #[serde(default = "default_tax_type")]
pub tax_type: String, pub tax_type: String,
#[serde(default)] #[serde(default)]
pub tax_rate: f64, pub tax_rate: f64,
#[serde(default)] #[serde(default)]
pub cgst_amount: f64, pub reverse_charge: bool,
#[serde(default)]
pub sgst_amount: f64,
#[serde(default)]
pub igst_amount: f64,
#[serde(default)]
pub total: f64,
#[serde(default)]
pub amount_in_words: String,
#[serde(default)] #[serde(default)]
pub bank_account_id: Option<i64>, pub bank_account_id: Option<i64>,
#[serde(default)] #[serde(default)]
pub bank_snapshot: String,
#[serde(default)]
pub signature_path: Option<String>, pub signature_path: Option<String>,
#[serde(default)] #[serde(default)]
pub notes: String, pub notes: String,
#[serde(default)] #[serde(default)]
pub save_client: bool, pub save_client: bool,
#[serde(default)] #[serde(default)]
pub draft_id: Option<i64>,
#[serde(default)]
pub items: Vec<InvoiceItem>, pub items: Vec<InvoiceItem>,
} }
@@ -187,9 +182,18 @@ pub struct Invoice {
pub bank_snapshot: String, pub bank_snapshot: String,
pub signature_path: Option<String>, pub signature_path: Option<String>,
pub notes: String, pub notes: String,
/// draft | issued | cancelled
pub status: String, pub status: String,
pub created_at: String, pub created_at: String,
pub updated_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>, pub items: Vec<InvoiceItem>,
} }
@@ -205,3 +209,12 @@ pub struct InvoiceSummary {
pub status: String, pub status: String,
pub created_at: 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>,
}
+11 -2
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type { import type {
BankAccount, BankAccount,
Client, Client,
DraftSummary,
Invoice, Invoice,
InvoiceInput, InvoiceInput,
InvoiceSeries, InvoiceSeries,
@@ -27,10 +28,18 @@ export const api = {
deleteClient: (id: number) => invoke<void>("delete_client", { id }), deleteClient: (id: number) => invoke<void>("delete_client", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"), 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"), listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }), 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) => importAsset: (sourcePath: string, kind: string) =>
invoke<string>("import_asset", { sourcePath, kind }), invoke<string>("import_asset", { sourcePath, kind }),
+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"; 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. */ /** Amount for a single line: a fixed total, or rate × quantity. */
export function computeLineAmount(item: InvoiceItem): number { export function computeLineAmount(item: InvoiceItem): number {
if (item.mode === "rate") { return linePaise(item) / 100;
return round2((item.rate || 0) * (item.quantity || 0));
}
return round2(item.amount || 0);
} }
export interface Totals { export interface Totals {
@@ -26,29 +37,30 @@ export function computeTotals(
taxType: TaxType, taxType: TaxType,
taxRate: number, taxRate: number,
): Totals { ): Totals {
const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0)); const subtotal = items.reduce((sum, item) => sum + linePaise(item), 0);
const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal)); const discountPaise = Math.min(Math.max(toPaise(discount), 0), Math.max(subtotal, 0));
const taxable = round2(subtotal - discountValue); const taxable = subtotal - discountPaise;
const rateBp = Math.round((Number.isFinite(taxRate) ? taxRate : 0) * 100);
let cgst = 0; let cgst = 0;
let sgst = 0; let sgst = 0;
let igst = 0; let igst = 0;
if (taxType === "cgst_sgst") { if (taxType === "cgst_sgst") {
cgst = round2((taxable * (taxRate / 2)) / 100); cgst = divRound(taxable * rateBp, 20_000);
sgst = round2((taxable * (taxRate / 2)) / 100); sgst = cgst;
} else if (taxType === "igst") { } 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 { return {
subtotal, subtotal: subtotal / 100,
discount: discountValue, discount: discountPaise / 100,
taxable, taxable: taxable / 100,
cgst, cgst: cgst / 100,
sgst, sgst: sgst / 100,
igst, igst: igst / 100,
taxTotal, taxTotal: taxTotal / 100,
total: round2(taxable + taxTotal), 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( export async function buildPdfProps(
invoice: Invoice, invoice: Invoice,
settings: Settings, settings: Settings,
bank: BankAccount | null, bank: BankAccount | null,
): Promise<InvoicePdfProps> { ): 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 const signatureDataUri = invoice.signaturePath
? await api.readAssetDataUri(invoice.signaturePath) ? await api.readAssetDataUri(invoice.signaturePath)
: null; : null;
@@ -37,7 +49,7 @@ export async function buildPdfProps(
} }
return { return {
vendor: settings, vendor,
bank: resolvedBank, bank: resolvedBank,
logoDataUri, logoDataUri,
signatureDataUri, signatureDataUri,
+56 -13
View File
@@ -19,7 +19,7 @@ export interface Settings {
currency: string; currency: string;
onboarded: boolean; onboarded: boolean;
theme: string; theme: string;
gstRegistration: string; gstRegistration: GstRegistration;
defaultHsnSac: string; defaultHsnSac: string;
signatoryName: string; signatoryName: string;
signatoryDesignation: string; signatoryDesignation: string;
@@ -65,9 +65,41 @@ export interface InvoiceItem {
quantity: number; quantity: number;
amount: number; amount: number;
sortOrder: 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 { 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; invoiceDate: string;
dueDate: string; dueDate: string;
clientId: number | null; clientId: number | null;
@@ -89,18 +121,25 @@ export interface InvoiceInput {
bankSnapshot: string; bankSnapshot: string;
signaturePath: string | null; signaturePath: string | null;
notes: string; 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[]; items: InvoiceItem[];
} }
export interface Invoice extends Omit<InvoiceInput, "saveClient"> { export interface DraftSummary {
id: number; id: number;
number: string;
seriesId: number | null;
status: string;
createdAt: string;
updatedAt: string; updatedAt: string;
items: InvoiceItem[]; clientName: string | null;
total: number | null;
} }
export interface InvoiceSummary { export interface InvoiceSummary {
@@ -110,7 +149,7 @@ export interface InvoiceSummary {
dueDate: string; dueDate: string;
clientName: string; clientName: string;
total: number; total: number;
status: string; status: InvoiceStatus;
createdAt: string; createdAt: string;
} }
@@ -122,10 +161,12 @@ export const EMPTY_ITEM: InvoiceItem = {
quantity: 1, quantity: 1,
amount: 0, amount: 0,
sortOrder: 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 }[] = [ 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: "02", name: "Himachal Pradesh" },
{ code: "03", name: "Punjab" }, { code: "03", name: "Punjab" },
{ code: "04", name: "Chandigarh" }, { code: "04", name: "Chandigarh" },
@@ -149,18 +190,20 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
{ code: "22", name: "Chhattisgarh" }, { code: "22", name: "Chhattisgarh" },
{ code: "23", name: "Madhya Pradesh" }, { code: "23", name: "Madhya Pradesh" },
{ code: "24", name: "Gujarat" }, { 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: "27", name: "Maharashtra" },
{ code: "29", name: "Karnataka" }, { code: "29", name: "Karnataka" },
{ code: "30", name: "Goa" }, { code: "30", name: "Goa" },
{ code: "31", name: "Lakshadweep" }, { code: "31", name: "Lakshadweep Islands" },
{ code: "32", name: "Kerala" }, { code: "32", name: "Kerala" },
{ code: "33", name: "Tamil Nadu" }, { code: "33", name: "Tamil Nadu" },
{ code: "34", name: "Puducherry" }, { code: "34", name: "Puducherry" },
{ code: "35", name: "Andaman & Nicobar Islands" }, { code: "35", name: "Andaman and Nicobar Islands" },
{ code: "36", name: "Telangana" }, { code: "36", name: "Telangana" },
{ code: "37", name: "Andhra Pradesh" }, { code: "37", name: "Andhra Pradesh" },
{ code: "38", name: "Ladakh" }, { code: "38", name: "Ladakh" },
{ code: "96", name: "Other Countries" },
{ code: "97", name: "Other Territory" },
]; ];
export const UNIT_LABELS: Record<LineUnit, string> = { 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"; 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 { export function emailError(value: string): string | undefined {
if (!value.trim()) return undefined; if (!value.trim()) return undefined;
return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address"; return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address";
+71 -3
View File
@@ -23,8 +23,14 @@ import {
import { Add, Save, TrashCan } from "@carbon/icons-react"; import { Add, Save, TrashCan } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog"; import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types"; import {
import { emailError, ifscError, panError, phoneError } from "../lib/validators"; 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"; import { ImagePicker } from "../components/ImagePicker";
const blankBank = (): BankAccount => ({ const blankBank = (): BankAccount => ({
@@ -70,7 +76,19 @@ export default function AppSettings({
const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) => const setBank = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev)); 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 () => { const saveAll = async () => {
if (gstinProblem) {
setError(gstinProblem);
return;
}
setBusy(true); setBusy(true);
setError(null); setError(null);
setNotice(null); setNotice(null);
@@ -140,6 +158,22 @@ export default function AppSettings({
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}> <section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3> <h3>Vendor details</h3>
<Grid narrow> <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}> <Column sm={4} md={8} lg={12}>
<TextInput <TextInput
id="s-vendorName" id="s-vendorName"
@@ -187,14 +221,18 @@ export default function AppSettings({
invalidText={panError(form.vendorPan)} invalidText={panError(form.vendorPan)}
/> />
</Column> </Column>
{registered ? (
<Column sm={4} md={4} lg={6}> <Column sm={4} md={4} lg={6}>
<TextInput <TextInput
id="s-vendorGstin" id="s-vendorGstin"
labelText="GSTIN (blank if unregistered)" labelText="GSTIN"
value={form.vendorGstin} value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())} onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
invalid={Boolean(gstinProblem)}
invalidText={gstinProblem}
/> />
</Column> </Column>
) : null}
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
<Select <Select
id="s-vendorState" id="s-vendorState"
@@ -207,6 +245,22 @@ export default function AppSettings({
))} ))}
</Select> </Select>
</Column> </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}> <Column sm={4} md={8} lg={8}>
<ImagePicker <ImagePicker
kind="logo" kind="logo"
@@ -229,6 +283,8 @@ export default function AppSettings({
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}> <section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3> <h3>Defaults</h3>
<Grid narrow> <Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}> <Column sm={4} md={4} lg={5}>
<NumberInput <NumberInput
id="s-taxRate" id="s-taxRate"
@@ -240,6 +296,16 @@ export default function AppSettings({
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)} onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/> />
</Column> </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}> <Column sm={4} md={4} lg={5}>
<NumberInput <NumberInput
id="s-terms" id="s-terms"
@@ -261,6 +327,7 @@ export default function AppSettings({
<SelectItem value="g100" text="Carbon dark (g100)" /> <SelectItem value="g100" text="Carbon dark (g100)" />
</Select> </Select>
</Column> </Column>
{registered ? (
<Column sm={4} md={8} lg={16}> <Column sm={4} md={8} lg={16}>
<RadioButtonGroup <RadioButtonGroup
legendText="Default tax treatment" legendText="Default tax treatment"
@@ -273,6 +340,7 @@ export default function AppSettings({
<RadioButton id="s-tt-none" labelText="No tax" value="none" /> <RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup> </RadioButtonGroup>
</Column> </Column>
) : null}
</Grid> </Grid>
</section> </section>
+49 -13
View File
@@ -3,6 +3,7 @@ import {
Button, Button,
InlineLoading, InlineLoading,
InlineNotification, InlineNotification,
Modal,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -10,9 +11,10 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
Tag,
TextInput,
} from "@carbon/react"; } from "@carbon/react";
import { Download, TrashCan } from "@carbon/icons-react"; import { Download, Close } from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format"; import { formatAmount, formatDate } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf"; import { exportInvoicePdf } from "../lib/pdf";
@@ -23,6 +25,8 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null); const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState("");
const load = async () => { const load = async () => {
try { try {
@@ -52,16 +56,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
} }
}; };
const onDelete = async (row: InvoiceSummary) => { const onCancel = async () => {
const ok = await confirm(`Delete invoice ${row.number}? This cannot be undone.`, { if (!cancelTarget) return;
title: "Delete invoice",
kind: "warning",
});
if (!ok) return;
try { try {
await api.deleteInvoice(row.id); await api.cancelInvoice(cancelTarget.id, reason);
setCancelTarget(null);
setReason("");
await load(); await load();
} catch (e) { } catch (e) {
setCancelTarget(null);
setError(String(e)); setError(String(e));
} }
}; };
@@ -99,7 +102,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
<TableCell>{formatDate(row.invoiceDate)}</TableCell> <TableCell>{formatDate(row.invoiceDate)}</TableCell>
<TableCell>{row.clientName || "—"}</TableCell> <TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</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> <TableCell>
<div className="voiced-header-action"> <div className="voiced-header-action">
<Button <Button
@@ -111,14 +122,19 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
> >
{busyId === row.id ? "Exporting…" : "PDF"} {busyId === row.id ? "Exporting…" : "PDF"}
</Button> </Button>
{row.status === "issued" ? (
<Button <Button
kind="ghost" kind="ghost"
size="sm" size="sm"
renderIcon={TrashCan} renderIcon={Close}
onClick={() => onDelete(row)} onClick={() => {
setReason("");
setCancelTarget(row);
}}
> >
Delete Cancel
</Button> </Button>
) : null}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -127,6 +143,26 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) {
</Table> </Table>
</TableContainer> </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> </div>
); );
} }
+50 -42
View File
@@ -6,8 +6,6 @@ import {
Grid, Grid,
InlineNotification, InlineNotification,
NumberInput, NumberInput,
RadioButton,
RadioButtonGroup,
Select, Select,
SelectItem, SelectItem,
TextArea, TextArea,
@@ -29,6 +27,7 @@ import type {
} from "../lib/types"; } from "../lib/types";
import { computeLineAmount, computeTotals } from "../lib/invoice"; import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords"; import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format"; import { addDays, formatAmount, todayIso } from "../lib/format";
import { buildPdfProps, exportPdfFromProps } from "../lib/pdf"; import { buildPdfProps, exportPdfFromProps } from "../lib/pdf";
import { InvoicePreview } from "../components/InvoicePreview"; import { InvoicePreview } from "../components/InvoicePreview";
@@ -41,6 +40,7 @@ interface NewInvoiceProps {
} }
export default function NewInvoice({ settings }: NewInvoiceProps) { export default function NewInvoice({ settings }: NewInvoiceProps) {
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
const today = todayIso(); const today = todayIso();
const [invoiceDate, setInvoiceDate] = useState(today); const [invoiceDate, setInvoiceDate] = useState(today);
const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays)); const [dueDate, setDueDate] = useState(addDays(today, settings.paymentTermsDays));
@@ -55,13 +55,11 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
const [placeOfSupply, setPlaceOfSupply] = useState(settings.vendorStateCode); const [placeOfSupply, setPlaceOfSupply] = useState(settings.vendorStateCode);
const [saveClient, setSaveClient] = useState(false); const [saveClient, setSaveClient] = useState(false);
const [items, setItems] = useState<InvoiceItem[]>([{ ...EMPTY_ITEM }]); const [items, setItems] = useState<InvoiceItem[]>([newItem()]);
const [discount, setDiscount] = useState(0); const [discount, setDiscount] = useState(0);
const [taxesApplicable, setTaxesApplicable] = useState(settings.defaultTaxType !== "none"); const registered = settings.gstRegistration !== "unregistered";
const [taxType, setTaxType] = useState<TaxType>( const [reverseCharge, setReverseCharge] = useState(false);
settings.defaultTaxType === "none" ? "cgst_sgst" : settings.defaultTaxType,
);
const [taxRate, setTaxRate] = useState(settings.defaultTaxRate); const [taxRate, setTaxRate] = useState(settings.defaultTaxRate);
const [banks, setBanks] = useState<BankAccount[]>([]); const [banks, setBanks] = useState<BankAccount[]>([]);
@@ -122,7 +120,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
}; };
}, [signaturePath]); }, [signaturePath]);
const effectiveTaxType: TaxType = taxesApplicable ? taxType : "none"; // Rust derives the tax type from the supplier state and the place of supply; this
// mirrors that rule so the preview and the value sent for the cross-check agree.
const effectiveTaxType: TaxType = !registered
? "none"
: !placeOfSupply || placeOfSupply === settings.vendorStateCode
? "cgst_sgst"
: "igst";
const clientGstinError = gstinFullError(clientGstin);
const totals = useMemo( const totals = useMemo(
() => computeTotals(items, discount, effectiveTaxType, taxRate), () => computeTotals(items, discount, effectiveTaxType, taxRate),
[items, discount, effectiveTaxType, taxRate], [items, discount, effectiveTaxType, taxRate],
@@ -159,7 +164,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
const updateItem = (index: number, patch: Partial<InvoiceItem>) => const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item))); setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]); const addItem = () => setItems((prev) => [...prev, newItem()]);
const removeItem = (index: number) => const removeItem = (index: number) =>
setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index))); setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index)));
@@ -179,7 +184,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
setClientGstin(""); setClientGstin("");
setPoNumber(""); setPoNumber("");
setSaveClient(false); setSaveClient(false);
setItems([{ ...EMPTY_ITEM }]); setItems([newItem()]);
setDiscount(0); setDiscount(0);
setNotes(""); setNotes("");
setInvoiceDate(todayIso()); setInvoiceDate(todayIso());
@@ -193,6 +198,10 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
}; };
const onSave = async () => { const onSave = async () => {
if (clientGstinError) {
setError(`Client GSTIN: ${clientGstinError}`);
return;
}
setSaving(true); setSaving(true);
setError(null); setError(null);
setSaved(null); setSaved(null);
@@ -206,27 +215,18 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
clientGstin, clientGstin,
poNumber, poNumber,
placeOfSupplyStateCode: placeOfSupply, placeOfSupplyStateCode: placeOfSupply,
subtotal: totals.subtotal, discount,
discount: totals.discount,
taxType: effectiveTaxType, taxType: effectiveTaxType,
taxRate, taxRate,
cgstAmount: totals.cgst, reverseCharge: registered && reverseCharge,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: words,
bankAccountId: bankId, bankAccountId: bankId,
bankSnapshot: bank ? JSON.stringify(bank) : "",
signaturePath, signaturePath,
notes, notes,
saveClient, saveClient,
items: items.map((item, index) => ({ draftId: null,
...item, items: items.map((item, index) => ({ ...item, sortOrder: index })),
sortOrder: index,
amount: computeLineAmount(item),
})),
}; };
const invoice = await api.createInvoice(input); const invoice = await api.issueInvoice(input, {});
const props = await buildPdfProps(invoice, settings, bank); const props = await buildPdfProps(invoice, settings, bank);
const path = await exportPdfFromProps(props, invoice.number); const path = await exportPdfFromProps(props, invoice.number);
if (path) setSaved(path); if (path) setSaved(path);
@@ -336,6 +336,8 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
labelText="Client GSTIN (optional)" labelText="Client GSTIN (optional)"
value={clientGstin} value={clientGstin}
onChange={(e) => setClientGstin(e.target.value.toUpperCase())} onChange={(e) => setClientGstin(e.target.value.toUpperCase())}
invalid={Boolean(clientGstinError)}
invalidText={clientGstinError}
/> />
</Column> </Column>
<Column sm={4} md={4} lg={5}> <Column sm={4} md={4} lg={5}>
@@ -391,6 +393,16 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
onChange={(e) => updateItem(index, { description: e.target.value })} onChange={(e) => updateItem(index, { description: e.target.value })}
/> />
</div> </div>
{registered ? (
<div style={{ flex: "0 1 120px" }}>
<TextInput
id={`hsn-${index}`}
labelText="HSN/SAC"
value={item.hsnSac ?? ""}
onChange={(e) => updateItem(index, { hsnSac: e.target.value })}
/>
</div>
) : null}
<div style={{ flex: "0 1 150px" }}> <div style={{ flex: "0 1 150px" }}>
<Select <Select
id={`mode-${index}`} id={`mode-${index}`}
@@ -479,26 +491,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
onChange={(_e, { value }) => setDiscount(Number(value) || 0)} onChange={(_e, { value }) => setDiscount(Number(value) || 0)}
/> />
</Column> </Column>
<Column sm={4} md={8} lg={16}> {registered ? (
<Toggle
id="taxes"
labelText="Taxes applicable"
toggled={taxesApplicable}
onToggle={setTaxesApplicable}
/>
</Column>
{taxesApplicable ? (
<> <>
<Column sm={4} md={8} lg={16}> <Column sm={4} md={8} lg={16}>
<RadioButtonGroup <p className="voiced-page__subtitle" style={{ marginBottom: 0 }}>
legendText="Tax treatment" {effectiveTaxType === "igst"
name="taxType" ? "IGST (inter-state supply)"
valueSelected={taxType} : "CGST + SGST (supply within the supplier's state)"}
onChange={(value) => setTaxType(value as TaxType)} </p>
>
<RadioButton id="t-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="t-igst" labelText="IGST (inter-state)" value="igst" />
</RadioButtonGroup>
</Column> </Column>
<Column sm={4} md={4} lg={8}> <Column sm={4} md={4} lg={8}>
<NumberInput <NumberInput
@@ -511,6 +511,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) {
onChange={(_e, { value }) => setTaxRate(Number(value) || 0)} onChange={(_e, { value }) => setTaxRate(Number(value) || 0)}
/> />
</Column> </Column>
<Column sm={4} md={8} lg={16}>
<Toggle
id="reverseCharge"
labelText="Tax payable on reverse charge"
toggled={reverseCharge}
onToggle={setReverseCharge}
/>
</Column>
</> </>
) : null} ) : null}
</Grid> </Grid>
+42 -5
View File
@@ -15,9 +15,9 @@ import {
TextInput, TextInput,
} from "@carbon/react"; } from "@carbon/react";
import { api } from "../lib/api"; 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 { 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"; import { ImagePicker } from "../components/ImagePicker";
interface OnboardingProps { interface OnboardingProps {
@@ -73,9 +73,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
const bankSet = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) => const bankSet = <K extends keyof BankAccount>(key: K, value: BankAccount[K]) =>
setBank((prev) => ({ ...prev, [key]: value })); setBank((prev) => ({ ...prev, [key]: value }));
const registered = form.gstRegistration !== "unregistered";
const gstinProblem = vendorGstinError(
form.gstRegistration,
form.vendorGstin,
form.vendorPan,
form.vendorStateCode,
);
const businessInvalid = useMemo( 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 () => { const finish = async () => {
@@ -110,6 +123,22 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
adjust anything that has changed. adjust anything that has changed.
</p> </p>
</Column> </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}> <Column sm={4} md={8} lg={12}>
<TextInput <TextInput
id="vendorName" id="vendorName"
@@ -158,14 +187,18 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
invalidText={panError(form.vendorPan)} invalidText={panError(form.vendorPan)}
/> />
</Column> </Column>
{registered ? (
<Column sm={4} md={4} lg={6}> <Column sm={4} md={4} lg={6}>
<TextInput <TextInput
id="vendorGstin" id="vendorGstin"
labelText="GSTIN (leave blank if not registered)" labelText="GSTIN"
value={form.vendorGstin} value={form.vendorGstin}
onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())} onChange={(e) => set("vendorGstin", e.target.value.toUpperCase())}
invalid={Boolean(gstinProblem)}
invalidText={gstinProblem}
/> />
</Column> </Column>
) : null}
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
<Select <Select
id="vendorState" id="vendorState"
@@ -287,6 +320,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => setPadding(Number(value) || 3)} onChange={(_e, { value }) => setPadding(Number(value) || 3)}
/> />
</Column> </Column>
{registered ? (
<Column sm={4} md={4} lg={6}> <Column sm={4} md={4} lg={6}>
<NumberInput <NumberInput
id="taxRate" id="taxRate"
@@ -297,6 +331,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)} onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/> />
</Column> </Column>
) : null}
<Column sm={4} md={4} lg={6}> <Column sm={4} md={4} lg={6}>
<NumberInput <NumberInput
id="terms" id="terms"
@@ -307,6 +342,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)} onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/> />
</Column> </Column>
{registered ? (
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
<RadioButtonGroup <RadioButtonGroup
legendText="Default tax treatment" legendText="Default tax treatment"
@@ -319,6 +355,7 @@ export default function Onboarding({ initial, onDone }: OnboardingProps) {
<RadioButton id="tt-none" labelText="No tax" value="none" /> <RadioButton id="tt-none" labelText="No tax" value="none" />
</RadioButtonGroup> </RadioButtonGroup>
</Column> </Column>
) : null}
</Grid> </Grid>
), ),
}, },