From 5597fb791dd5ddf76f35bd1cd9c5f5b7e56f8e01 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 04:15:58 +0530 Subject: [PATCH] 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. --- src-tauri/src/commands/assets.rs | 250 +++++++-- src-tauri/src/commands/invoice.rs | 845 ++++++++++++++++++++++++++--- src-tauri/src/commands/series.rs | 150 ++++- src-tauri/src/commands/settings.rs | 100 +++- src-tauri/src/db.rs | 145 +++++ src-tauri/src/gst.rs | 506 +++++++++++++++++ src-tauri/src/lib.rs | 13 +- src-tauri/src/models.rs | 39 +- src/lib/api.ts | 13 +- src/lib/invoice.test.ts | 58 ++ src/lib/invoice.ts | 52 +- src/lib/pdf.tsx | 16 +- src/lib/types.ts | 69 ++- src/lib/validators.test.ts | 27 + src/lib/validators.ts | 44 ++ src/views/AppSettings.tsx | 134 +++-- src/views/InvoiceHistory.tsx | 72 ++- src/views/NewInvoice.tsx | 92 ++-- src/views/Onboarding.tsx | 105 ++-- 19 files changed, 2403 insertions(+), 327 deletions(-) create mode 100644 src-tauri/src/gst.rs create mode 100644 src/lib/invoice.test.ts create mode 100644 src/lib/validators.test.ts diff --git a/src-tauri/src/commands/assets.rs b/src-tauri/src/commands/assets.rs index 4b7afe3..95290f1 100644 --- a/src-tauri/src/commands/assets.rs +++ b/src-tauri/src/commands/assets.rs @@ -1,8 +1,10 @@ use crate::AppState; use base64::{engine::general_purpose::STANDARD, Engine}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use tauri::State; +const FORMAT_ERROR: &str = "Use a PNG or JPEG image"; + fn safe_kind(kind: &str) -> String { kind.chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') @@ -10,47 +12,88 @@ fn safe_kind(kind: &str) -> String { .to_lowercase() } -fn sanitize_name(name: &str) -> String { - name.chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_') - .collect() -} - -fn extension_of(path: &str) -> String { - Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_else(|| "png".to_string()) -} - -fn mime_for(ext: &str) -> &'static str { - match ext { - "jpg" | "jpeg" => "image/jpeg", - "svg" => "image/svg+xml", - "webp" => "image/webp", - "gif" => "image/gif", - _ => "image/png", +/// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and +/// silently drops everything else, so nothing else is accepted. +/// Returns (extension, MIME type). +pub fn sniff_image(bytes: &[u8]) -> Result<(&'static str, &'static str), String> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + Ok(("png", "image/png")) + } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + Ok(("jpg", "image/jpeg")) + } else { + Err(FORMAT_ERROR.to_string()) } } -fn assets_dir(state: &State) -> Result { - let dir = state.data_dir.join("assets"); +fn assets_dir(data_dir: &Path) -> Result { + let dir = data_dir.join("assets"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; Ok(dir) } -fn write_asset(state: &State, kind: &str, ext: &str, bytes: &[u8]) -> Result { - let dir = assets_dir(state)?; - let file = format!( - "{}-{}.{}", - safe_kind(kind), - uuid::Uuid::new_v4().simple(), - ext - ); - let path = dir.join(file); - std::fs::write(&path, bytes).map_err(|e| e.to_string())?; - Ok(path.to_string_lossy().to_string()) +/// Resolve a stored asset path (relative to the data dir, or a legacy absolute one) +/// to a real file inside `/assets`. `..`, symlink escapes and anything +/// outside the assets directory are rejected. +pub fn resolve_asset(data_dir: &Path, path: &str) -> Result { + let raw = Path::new(path); + if path.trim().is_empty() { + return Err("Asset path is empty".into()); + } + if raw.components().any(|c| matches!(c, Component::ParentDir)) { + return Err("Asset path must not contain '..'".into()); + } + let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; + let root = data_dir + .join("assets") + .canonicalize() + .map_err(|e| format!("Asset store is unavailable: {e}"))?; + let canonical = full + .canonicalize() + .map_err(|e| format!("Could not open asset {path}: {e}"))?; + if canonical.starts_with(&root) && canonical != root { + Ok(canonical) + } else { + Err("Asset path is outside the asset store".into()) + } +} + +/// The form stored in the database: `assets/` relative to the data dir, with +/// forward slashes on every platform. +pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result { + 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::>() + .join("/")) +} + +fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result { + 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 { + let file = resolve_asset(data_dir, path)?; + let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; + let (_, mime) = sniff_image(&bytes)?; + Ok(format!("data:{};base64,{}", mime, STANDARD.encode(bytes))) +} + +fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> { + let raw = Path::new(path); + let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) }; + // Removing something that is already gone is fine, but an escape attempt is not. + if std::fs::symlink_metadata(&full).is_err() { + return Ok(()); + } + let target = resolve_asset(data_dir, path)?; + std::fs::remove_file(target).map_err(|e| e.to_string()) } /// Copy a user-picked file into the app's asset store and return the stored path. @@ -61,56 +104,153 @@ pub fn import_asset( kind: String, ) -> Result { let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?; - let ext = extension_of(&source_path); - write_asset(&state, &kind, &ext, &bytes) + write_asset(&state.data_dir, &kind, &bytes) } /// Used when the frontend already holds the bytes (base64) instead of a path. +/// The file name is ignored: the type comes from the bytes. #[tauri::command] pub fn save_asset_bytes( state: State, kind: String, - file_name: String, + #[allow(unused_variables)] file_name: String, data_base64: String, ) -> Result { let bytes = STANDARD .decode(data_base64.as_bytes()) .map_err(|e| e.to_string())?; - let ext = extension_of(&sanitize_name(&file_name)); - write_asset(&state, &kind, &ext, &bytes) + write_asset(&state.data_dir, &kind, &bytes) } /// Read a stored asset back as a data URI so it can be embedded in the PDF. #[tauri::command] -pub fn read_asset_data_uri(path: String) -> Result { - let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; - let ext = extension_of(&path); - Ok(format!( - "data:{};base64,{}", - mime_for(&ext), - STANDARD.encode(bytes) - )) +pub fn read_asset_data_uri(state: State, path: String) -> Result { + asset_data_uri(&state.data_dir, &path) } #[tauri::command] pub fn remove_asset(state: State, path: String) -> Result<(), String> { - let dir = assets_dir(&state)?; - let target = PathBuf::from(&path); - // Only allow deleting files inside the managed assets directory. - if target.starts_with(&dir) && target.exists() { - std::fs::remove_file(&target).map_err(|e| e.to_string())?; - } - Ok(()) + remove_asset_file(&state.data_dir, &path) } /// Write a base64 payload (e.g. a generated PDF) to a user-chosen path. #[tauri::command] pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> { + if path.trim().is_empty() { + return Err("No file path given".into()); + } let bytes = STANDARD .decode(data_base64.as_bytes()) .map_err(|e| e.to_string())?; if let Some(parent) = Path::new(&path).parent() { + // A missing parent is created; if that fails the write below reports the real error. std::fs::create_dir_all(parent).ok(); } std::fs::write(&path, bytes).map_err(|e| e.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0]; + const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F']; + + #[test] + fn sniffing_accepts_only_png_and_jpeg() { + assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png")); + assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg")); + assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image"); + assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err()); + assert!(sniff_image(b"").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()); + } +} diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index 272d336..4d584d0 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -1,13 +1,23 @@ +use super::assets::relative_asset_path; +use super::series::{validate_series_format, MAX_NUMBER_LEN}; +use super::settings::{map_bank, map_settings, SETTINGS_COLS}; use crate::db::format_number; -use crate::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary}; +use crate::gst::{self, TaxType}; +use crate::models::{ + BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings, +}; use crate::AppState; -use rusqlite::{params, Connection, OptionalExtension, Row}; +use chrono::NaiveDate; +use rusqlite::{named_params, params, Connection, OptionalExtension, Row}; +use std::path::Path; use tauri::State; const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name, client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words, - bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at"; + bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at, + doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at, + cancel_reason, archived_pdf_sha256"; fn map_invoice(row: &Row) -> rusqlite::Result { Ok(Invoice { @@ -38,13 +48,20 @@ fn map_invoice(row: &Row) -> rusqlite::Result { status: row.get(24)?, created_at: row.get(25)?, updated_at: row.get(26)?, + doc_type: row.get(27)?, + reverse_charge: row.get::<_, i64>(28)? != 0, + vendor_snapshot: row.get(29)?, + snapshot_origin: row.get(30)?, + cancelled_at: row.get(31)?, + cancel_reason: row.get(32)?, + archived_pdf_sha256: row.get(33)?, items: Vec::new(), }) } fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result> { let mut stmt = conn.prepare( - "SELECT id, description, mode, rate, unit, quantity, amount, sort_order + "SELECT id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC", )?; let rows = stmt.query_map(params![invoice_id], |row| { @@ -57,6 +74,7 @@ fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result) -> Result, input: InvoiceInput) -> Result { - let mut conn = state.db.lock().map_err(|e| e.to_string())?; - let tx = conn.transaction().map_err(|e| e.to_string())?; +fn paise_to_rupees(paise: i64) -> f64 { + paise as f64 / 100.0 +} - let series = tx +fn parse_date(label: &str, value: &str) -> Result<(), String> { + NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d") + .map(|_| ()) + .map_err(|_| format!("{label} must be a date like 2026-04-01")) +} + +/// Largest rupee amount accepted for a single line, far below anything that could +/// overflow integer paise arithmetic. +const MAX_LINE_RUPEES: f64 = 1e11; + +fn validate_items(items: &[InvoiceItem]) -> Result<(), String> { + if items.is_empty() { + return Err("Add at least one line item".into()); + } + for (i, item) in items.iter().enumerate() { + let n = i + 1; + let finite_ok = |v: f64| v.is_finite() && (0.0..=MAX_LINE_RUPEES).contains(&v); + match item.mode.as_str() { + "rate" => { + if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 { + return Err(format!("Line {n}: rate and quantity must be positive numbers")); + } + if item.quantity > MAX_LINE_RUPEES { + return Err(format!("Line {n}: quantity is too large")); + } + let amount = gst::line_amount_paise(item); + if amount <= 0 { + return Err(format!("Line {n}: amount must be greater than zero")); + } + if amount as f64 / 100.0 > MAX_LINE_RUPEES { + return Err(format!("Line {n}: amount is too large")); + } + } + "fixed" => { + if !finite_ok(item.amount) { + return Err(format!("Line {n}: amount must be zero or more")); + } + } + other => return Err(format!("Line {n}: unknown line type \"{other}\"")), + } + } + Ok(()) +} + +/// Checks that the supplier details in settings can legally produce this document. +fn validate_supplier(settings: &Settings) -> Result<(), String> { + if !gst::state_exists(&settings.vendor_state_code) { + return Err(format!( + "Supplier state code \"{}\" is not a valid GST state code. Fix it in Settings.", + settings.vendor_state_code + )); + } + match settings.gst_registration.as_str() { + "unregistered" => Ok(()), + "regular" => { + let gstin = settings.vendor_gstin.trim().to_ascii_uppercase(); + if gstin.is_empty() { + return Err("A registered supplier needs a GSTIN. Add it in Settings.".into()); + } + gst::validate_gstin(&gstin).map_err(|e| format!("Supplier GSTIN: {e}"))?; + if gstin[0..2] != settings.vendor_state_code { + return Err(format!( + "Supplier GSTIN starts with state code {} but the supplier state is {}", + &gstin[0..2], + settings.vendor_state_code + )); + } + let pan = settings.vendor_pan.trim(); + if !pan.is_empty() && !gst::gstin_matches_pan(&gstin, pan) { + return Err("Supplier GSTIN does not contain the supplier PAN".into()); + } + Ok(()) + } + other => Err(format!("Unsupported GST registration type \"{other}\"")), + } +} + +/// Supplier details frozen onto the invoice. Built from the stored settings, never +/// from anything the webview sent. An unregistered supplier has no GSTIN to print. +fn vendor_snapshot(settings: &Settings) -> String { + let gstin = if settings.gst_registration == "unregistered" { + "" + } else { + settings.vendor_gstin.trim() + }; + serde_json::json!({ + "vendorName": settings.vendor_name, + "vendorAddress": settings.vendor_address, + "vendorEmail": settings.vendor_email, + "vendorPhone": settings.vendor_phone, + "vendorPan": settings.vendor_pan, + "vendorGstin": gstin, + "vendorStateCode": settings.vendor_state_code, + "logoPath": settings.logo_path, + "signaturePath": settings.signature_path, + "gstRegistration": settings.gst_registration, + "signatoryName": settings.signatory_name, + "signatoryDesignation": settings.signatory_designation, + }) + .to_string() +} + +fn bank_snapshot(conn: &Connection, bank_id: Option) -> Result { + 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 { + let db = |e: rusqlite::Error| e.to_string(); + let tx = conn.transaction().map_err(db)?; + + let settings: Settings = tx + .query_row( + &format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS), + [], + map_settings, + ) + .map_err(db)?; + let (series_id, prefix, padding, next_number): (i64, String, i64, i64) = tx .query_row( "SELECT id, prefix, padding, next_number FROM invoice_series WHERE is_active = 1 ORDER BY id DESC LIMIT 1", [], - |r| { - Ok(( - r.get::<_, i64>(0)?, - r.get::<_, String>(1)?, - r.get::<_, i64>(2)?, - r.get::<_, i64>(3)?, - )) - }, + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), ) .optional() - .map_err(|e| e.to_string())? + .map_err(db)? .ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?; - let (series_id, prefix, padding, next_number) = series; + validate_series_format(&prefix, padding) + .map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?; let number = format_number(&prefix, padding, next_number); + if number.len() > MAX_NUMBER_LEN { + return Err(format!( + "Invoice number {number} is longer than {MAX_NUMBER_LEN} characters. Start a new series." + )); + } + + validate_supplier(&settings)?; + parse_date("Invoice date", &input.invoice_date)?; + if !input.due_date.trim().is_empty() { + parse_date("Due date", &input.due_date)?; + } + validate_items(&input.items)?; + + let client_gstin = match input.client_gstin.trim().to_ascii_uppercase().as_str() { + "" | "NA" => String::new(), + g => { + gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?; + g.to_string() + } + }; + let pos = input.place_of_supply_state_code.trim().to_string(); + if !pos.is_empty() && !gst::state_exists(&pos) { + return Err(format!("Place of supply \"{pos}\" is not a valid GST state code")); + } + + let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos); + if input.tax_type != derived.as_str() { + return Err(format!( + "Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}", + input.tax_type, + derived.as_str(), + settings.vendor_state_code, + if pos.is_empty() { &settings.vendor_state_code } else { &pos }, + )); + } + let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate }; + if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) { + return Err("Tax rate must be between 0 and 100".into()); + } + let rate_bp = (tax_rate * 100.0).round() as i64; + let stored_pos = if pos.is_empty() && derived != TaxType::None { + settings.vendor_state_code.clone() + } else { + pos + }; + + let totals = gst::compute_totals( + &input.items, + gst::rupees_to_paise(input.discount), + derived, + rate_bp, + ); + let words = gst::amount_in_words(totals.total); + let snapshot = vendor_snapshot(&settings); + let bank = bank_snapshot(&tx, input.bank_account_id)?; + let signature_path = match input.signature_path.as_deref().map(str::trim) { + Some(p) if !p.is_empty() => Some( + relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?, + ), + _ => None, + }; + let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?; + let now = chrono::Utc::now().to_rfc3339(); // Persist the client first when the user asked to save a new one. let client_id = match input.client_id { @@ -126,65 +337,78 @@ pub fn create_invoice(state: State, input: InvoiceInput) -> Result None, }; - let now = chrono::Utc::now().to_rfc3339(); tx.execute( r#"INSERT INTO invoices (number, series_id, invoice_date, due_date, client_id, client_name, client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words, - bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, - ?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#, - params![ - number, - series_id, - input.invoice_date, - input.due_date, - client_id, - input.client_name, - input.client_address, - input.client_gstin, - input.po_number, - input.place_of_supply_state_code, - input.subtotal, - input.discount, - input.tax_type, - input.tax_rate, - input.cgst_amount, - input.sgst_amount, - input.igst_amount, - input.total, - input.amount_in_words, - input.bank_account_id, - input.bank_snapshot, - input.signature_path, - input.notes, - now, - ], + bank_account_id, bank_snapshot, signature_path, notes, status, doc_type, + reverse_charge, vendor_snapshot, render_prefs, snapshot_origin, created_at, updated_at) + VALUES (:number, :series_id, :invoice_date, :due_date, :client_id, :client_name, + :client_address, :client_gstin, :po_number, :pos, :subtotal, :discount, + :tax_type, :tax_rate, :cgst, :sgst, :igst, :total, :words, :bank_id, + :bank_snapshot, :signature_path, :notes, 'issued', :doc_type, + :reverse_charge, :vendor_snapshot, :render_prefs, 'issued', :now, :now)"#, + named_params! { + ":number": number, + ":series_id": series_id, + ":invoice_date": input.invoice_date.trim(), + ":due_date": input.due_date.trim(), + ":client_id": client_id, + ":client_name": input.client_name, + ":client_address": input.client_address, + ":client_gstin": client_gstin, + ":po_number": input.po_number, + ":pos": stored_pos, + ":subtotal": paise_to_rupees(totals.subtotal), + ":discount": paise_to_rupees(totals.discount), + ":tax_type": derived.as_str(), + ":tax_rate": tax_rate, + ":cgst": paise_to_rupees(totals.cgst), + ":sgst": paise_to_rupees(totals.sgst), + ":igst": paise_to_rupees(totals.igst), + ":total": paise_to_rupees(totals.total), + ":words": words, + ":bank_id": input.bank_account_id, + ":bank_snapshot": bank, + ":signature_path": signature_path, + ":notes": input.notes, + ":doc_type": gst::doc_type(&settings.gst_registration), + ":reverse_charge": input.reverse_charge, + ":vendor_snapshot": snapshot, + ":render_prefs": prefs, + ":now": now, + }, ) - .map_err(|e| e.to_string())?; + .map_err(|e| { + if e.to_string().contains("UNIQUE") { + format!("Invoice number {number} already exists. Start a new series or fix the counter.") + } else { + e.to_string() + } + })?; let invoice_id = tx.last_insert_rowid(); { let mut stmt = tx .prepare( "INSERT INTO invoice_items - (invoice_id, description, mode, rate, unit, quantity, amount, sort_order) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + (invoice_id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", ) - .map_err(|e| e.to_string())?; + .map_err(db)?; for (i, item) in input.items.iter().enumerate() { stmt.execute(params![ invoice_id, @@ -193,10 +417,11 @@ pub fn create_invoice(state: State, input: InvoiceInput) -> Result, input: InvoiceInput) -> Result, + input: InvoiceInput, + render_prefs: serde_json::Value, +) -> Result { + 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 { + let now = chrono::Utc::now().to_rfc3339(); + let changed = conn + .execute( + "UPDATE invoices + SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2, updated_at = ?1 + WHERE id = ?3 AND status = 'issued'", + params![now, reason.trim(), id], + ) + .map_err(|e| e.to_string())?; + if changed == 0 { + let status: Option = conn + .query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0)) + .optional() + .map_err(|e| e.to_string())?; + return Err(match status { + None => "Invoice not found".to_string(), + Some(s) => format!("Only an issued invoice can be cancelled (this one is {s})"), + }); + } + fetch_invoice(conn, id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn cancel_invoice(state: State, id: i64, reason: String) -> Result { + let mut conn = state.db.lock().map_err(|e| e.to_string())?; + cancel_invoice_impl(&mut conn, id, &reason) } #[tauri::command] @@ -246,10 +515,458 @@ pub fn get_invoice(state: State, id: i64) -> Result { fetch_invoice(&conn, id).map_err(|e| e.to_string()) } +pub fn save_draft_impl( + conn: &Connection, + id: Option, + payload: &serde_json::Value, +) -> Result { + 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, 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::>>() + .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 { + let text: String = conn + .query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0)) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| "Draft not found".to_string())?; + serde_json::from_str(&text).map_err(|e| format!("Draft is corrupted: {e}")) +} + #[tauri::command] -pub fn delete_invoice(state: State, id: i64) -> Result<(), String> { +pub fn save_draft( + state: State, + id: Option, + payload: serde_json::Value, +) -> Result { 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) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + list_drafts_impl(&conn) +} + +#[tauri::command] +pub fn get_draft(state: State, id: i64) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + get_draft_impl(&conn, id) +} + +#[tauri::command] +pub fn delete_draft(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::tempdir; + + fn registered() -> Connection { + let conn = crate::db::open_in_memory().unwrap(); + conn.execute( + "UPDATE app_settings SET gst_registration = 'regular', vendor_gstin = '27AAPFU0939F1ZV', + vendor_state_code = '27', vendor_pan = 'AAPFU0939F', default_tax_type = 'cgst_sgst'", + [], + ) + .unwrap(); + conn + } + + fn input(extra: serde_json::Value) -> InvoiceInput { + let mut base = json!({ + "invoiceDate": "2026-04-01", + "dueDate": "2026-05-01", + "clientName": "Client Ltd", + "taxType": "cgst_sgst", + "taxRate": 18.0, + "items": [{"description": "Design", "mode": "fixed", "amount": 7310.0}], + }); + for (k, v) in extra.as_object().unwrap() { + base[k] = v.clone(); + } + serde_json::from_value(base).unwrap() + } + + fn issue(conn: &mut Connection, input: InvoiceInput) -> Result { + issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})) + } + + fn next_number(conn: &Connection) -> i64 { + conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .unwrap() + } + + fn invoice_count(conn: &Connection) -> i64 { + conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap() + } + + #[test] + fn issues_numbered_tax_invoices_with_derived_totals() { + let mut conn = registered(); + let year = chrono::Local::now().format("%Y"); + let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap(); + assert_eq!(first.number, format!("INV/{year}-001")); + assert_eq!(first.status, "issued"); + assert_eq!(first.doc_type, "tax_invoice"); + assert_eq!(first.snapshot_origin, "issued"); + assert_eq!(first.subtotal, 7310.0); + assert_eq!(first.cgst_amount, 657.9); + assert_eq!(first.sgst_amount, 657.9); + assert_eq!(first.igst_amount, 0.0); + assert_eq!(first.total, 8625.8); + assert_eq!( + first.amount_in_words, + "Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only" + ); + assert_eq!(first.place_of_supply_state_code, "27"); + assert_eq!(first.items.len(), 1); + assert_eq!(first.items[0].amount, 7310.0); + + let second = issue(&mut conn, input(json!({}))).unwrap(); + assert_eq!(second.number, format!("INV/{year}-002")); + // An empty place of supply is stored as the supplier's own state. + assert_eq!(second.place_of_supply_state_code, "27"); + assert_eq!(next_number(&conn), 3); + } + + #[test] + fn inter_state_supply_is_igst_with_hsn_and_reverse_charge() { + let mut conn = registered(); + let inv = issue( + &mut conn, + input(json!({ + "placeOfSupplyStateCode": "29", + "taxType": "igst", + "taxRate": 5.0, + "reverseCharge": true, + "discount": 10.0, + "items": [ + {"description": "Hours", "mode": "rate", "rate": 1200.0, "quantity": 1.5, "unit": "hour", "hsnSac": " 998314 "}, + {"description": "Fee", "mode": "fixed", "amount": 33.33}, + ], + })), + ) + .unwrap(); + assert_eq!(inv.subtotal, 1833.33); + assert_eq!(inv.discount, 10.0); + // (1833.33 - 10.00) = 1823.33 -> 5% = 91.1665 -> 91.17 + assert_eq!(inv.igst_amount, 91.17); + assert_eq!(inv.cgst_amount, 0.0); + assert_eq!(inv.total, 1914.5); + assert!(inv.reverse_charge); + assert_eq!(inv.items[0].hsn_sac, "998314"); + assert_eq!(inv.items[0].amount, 1800.0); + assert_eq!(inv.items[1].hsn_sac, ""); + } + + #[test] + fn tax_type_mismatch_is_rejected_and_the_counter_stays() { + let mut conn = registered(); + let err = issue( + &mut conn, + input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})), + ) + .unwrap_err(); + assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}"); + let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err(); + assert!(err.contains("does not match"), "{err}"); + assert_eq!(next_number(&conn), 1); + assert_eq!(invoice_count(&conn), 0); + } + + #[test] + fn unregistered_supplier_issues_a_plain_invoice_without_tax() { + let mut conn = crate::db::open_in_memory().unwrap(); + conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap(); + let inv = issue( + &mut conn, + input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})), + ) + .unwrap(); + assert_eq!(inv.doc_type, "invoice"); + assert_eq!(inv.tax_type, "none"); + assert_eq!(inv.tax_rate, 0.0); + assert_eq!(inv.total, 7310.0); + assert_eq!(inv.cgst_amount + inv.sgst_amount + inv.igst_amount, 0.0); + // An unregistered supplier's snapshot must not carry a GSTIN. + let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap(); + assert_eq!(snap["vendorGstin"], ""); + assert_eq!(snap["gstRegistration"], "unregistered"); + + let err = issue(&mut conn, input(json!({"taxType": "igst"}))).unwrap_err(); + assert!(err.contains("does not match \"none\""), "{err}"); + } + + #[test] + fn vendor_snapshot_comes_from_settings_not_input() { + let mut conn = registered(); + conn.execute( + "UPDATE app_settings SET vendor_name = 'Real Name', logo_path = 'assets/logo-1.png', + signatory_name = 'A Signer', signatory_designation = 'Partner'", + [], + ) + .unwrap(); + // Fields the webview might still send are ignored. + let inv = issue( + &mut conn, + input(json!({ + "vendorName": "Forged", "vendorSnapshot": "{\"vendorName\":\"Forged\"}", + "total": 1.0, "subtotal": 1.0, "cgstAmount": 99.0, "amountInWords": "Free", + "bankSnapshot": "{\"bankName\":\"Forged\"}", + })), + ) + .unwrap(); + let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap(); + assert_eq!(snap["vendorName"], "Real Name"); + assert_eq!(snap["vendorGstin"], "27AAPFU0939F1ZV"); + assert_eq!(snap["vendorStateCode"], "27"); + assert_eq!(snap["logoPath"], "assets/logo-1.png"); + assert!(snap["signaturePath"].is_null()); + assert_eq!(snap["gstRegistration"], "regular"); + assert_eq!(snap["signatoryName"], "A Signer"); + assert_eq!(snap["signatoryDesignation"], "Partner"); + assert_eq!(inv.total, 8625.8); + assert_eq!(inv.bank_snapshot, ""); + assert!(inv.amount_in_words.contains("Eight Thousand")); + + // Later settings edits do not touch the stored snapshot. + conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap(); + let again = fetch_invoice(&conn, inv.id).unwrap(); + assert_eq!(again.vendor_snapshot, inv.vendor_snapshot); + } + + #[test] + fn bank_snapshot_is_built_from_the_bank_row() { + let mut conn = registered(); + let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap(); + let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap(); + let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap(); + assert_eq!(bank["bankName"], "State Bank of India"); + let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err(); + assert!(err.contains("bank account"), "{err}"); + } + + #[test] + fn failure_after_validation_leaves_the_series_untouched() { + let mut conn = registered(); + // Occupy the next number so the INSERT hits the UNIQUE constraint mid-transaction. + let year = chrono::Local::now().format("%Y"); + conn.execute( + "INSERT INTO invoices (number, invoice_date, created_at, updated_at) + VALUES (?1, '2026-01-01', 'now', 'now')", + params![format!("INV/{year}-001")], + ) + .unwrap(); + conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", []) + .unwrap(); + let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err(); + assert!(err.contains("already exists"), "{err}"); + assert_eq!(next_number(&conn), 1); + assert_eq!(invoice_count(&conn), 1); + // The client insert and the draft delete were rolled back too. + let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap(); + let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap(); + assert_eq!((clients, drafts), (0, 1)); + } + + #[test] + fn validation_failures_do_not_consume_a_number() { + let mut conn = registered(); + let bad = [ + (json!({"items": []}), "at least one line"), + (json!({"invoiceDate": "01/04/2026"}), "Invoice date"), + (json!({"dueDate": "soon"}), "Due date"), + ( + json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}), + "greater than zero", + ), + (json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"), + (json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"), + (json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"), + (json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"), + (json!({"taxRate": 150.0}), "between 0 and 100"), + (json!({"signaturePath": "../voiced.db"}), "Signature image"), + ]; + for (extra, needle) in bad { + let err = issue(&mut conn, input(extra)).unwrap_err(); + assert!(err.contains(needle), "expected {needle:?} in {err:?}"); + } + assert_eq!(next_number(&conn), 1); + assert_eq!(invoice_count(&conn), 0); + + // A valid client GSTIN, lowercase, is accepted and normalised. + let ok = issue(&mut conn, input(json!({"clientGstin": "29aagcb7383j1z4"}))).unwrap(); + assert_eq!(ok.client_gstin, "29AAGCB7383J1Z4"); + } + + #[test] + fn supplier_settings_are_validated() { + let mut conn = registered(); + conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap(); + assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit")); + conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap(); + assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN")); + conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap(); + assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29")); + conn.execute( + "UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'", + [], + ) + .unwrap(); + assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN")); + conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", []) + .unwrap(); + assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code")); + assert_eq!(next_number(&conn), 1); + } + + #[test] + fn legacy_series_that_break_the_rules_are_refused() { + let mut conn = registered(); + conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap(); + let err = issue(&mut conn, input(json!({}))).unwrap_err(); + assert!(err.contains("active invoice series"), "{err}"); + conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap(); + assert!(issue(&mut conn, input(json!({}))).is_err()); + // A counter that outgrows its padding can push the number past 16 characters. + conn.execute( + "UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLM', padding = 3, next_number = 10000", + [], + ) + .unwrap(); + assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16")); + assert_eq!(invoice_count(&conn), 0); + } + + #[test] + fn issuing_deletes_the_draft() { + let mut conn = registered(); + let draft = save_draft_impl(&conn, None, &json!({"clientName": "X"})).unwrap(); + let other = save_draft_impl(&conn, None, &json!({"clientName": "Y"})).unwrap(); + issue(&mut conn, input(json!({"draftId": draft}))).unwrap(); + assert!(get_draft_impl(&conn, draft).is_err()); + assert!(get_draft_impl(&conn, other).is_ok()); + } + + #[test] + fn signature_path_is_stored_relative() { + let dir = tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("assets")).unwrap(); + std::fs::write(dir.path().join("assets/signature-1.png"), b"x").unwrap(); + let mut conn = registered(); + let abs = dir.path().join("assets/signature-1.png"); + let inv = issue_invoice_impl( + &mut conn, + dir.path(), + input(json!({"signaturePath": abs.to_str().unwrap()})), + &json!({"theme": "plain"}), + ) + .unwrap(); + assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png")); + let prefs: String = conn + .query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0)) + .unwrap(); + assert_eq!(prefs, "{\"theme\":\"plain\"}"); + } + + #[test] + fn cancelling_keeps_the_number_and_only_works_once() { + let mut conn = registered(); + let inv = issue(&mut conn, input(json!({}))).unwrap(); + let cancelled = cancel_invoice_impl(&mut conn, inv.id, " duplicate ").unwrap(); + assert_eq!(cancelled.status, "cancelled"); + assert_eq!(cancelled.number, inv.number); + assert_eq!(cancelled.cancel_reason, "duplicate"); + assert!(cancelled.cancelled_at.is_some()); + assert_eq!(cancelled.total, inv.total); + + let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err(); + assert!(err.contains("cancelled"), "{err}"); + assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found")); + conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap(); + assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err()); + + // The number is never reused. + let next = issue(&mut conn, input(json!({}))).unwrap(); + assert_ne!(next.number, inv.number); + assert_eq!(next_number(&conn), 3); + } + + #[test] + fn drafts_round_trip() { + let conn = crate::db::open_in_memory().unwrap(); + let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap(); + let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap(); + assert_ne!(a, b); + let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap(); + assert_eq!(c, a); + assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err()); + + assert_eq!(get_draft_impl(&conn, a).unwrap()["clientName"], "Acme 2"); + let list = list_drafts_impl(&conn).unwrap(); + assert_eq!(list.len(), 2); + let acme = list.iter().find(|d| d.id == a).unwrap(); + assert_eq!(acme.client_name.as_deref(), Some("Acme 2")); + assert_eq!(acme.total, Some(10.0)); + let blank = list.iter().find(|d| d.id == b).unwrap(); + assert_eq!((blank.client_name.clone(), blank.total), (None, None)); + + conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap(); + assert!(get_draft_impl(&conn, a).is_err()); + } +} diff --git a/src-tauri/src/commands/series.rs b/src-tauri/src/commands/series.rs index b75fabb..fbb3e8f 100644 --- a/src-tauri/src/commands/series.rs +++ b/src-tauri/src/commands/series.rs @@ -1,7 +1,7 @@ use crate::db::format_number; use crate::models::InvoiceSeries; use crate::AppState; -use rusqlite::{params, Row}; +use rusqlite::{params, Connection, Row}; use tauri::State; fn map_series(row: &Row) -> rusqlite::Result { @@ -52,32 +52,140 @@ pub fn list_series(state: State) -> Result, String> Ok(rows) } +/// Longest invoice number GST allows (India Compliance enforces the same limit). +pub const MAX_NUMBER_LEN: usize = 16; + +/// A series prefix starts with a letter or digit and then uses only letters, digits, +/// '-' and '/'; the prefix plus the number padding must fit in 16 characters. +pub fn validate_series_format(prefix: &str, padding: i64) -> Result<(), String> { + let mut chars = prefix.chars(); + match chars.next() { + None => return Err("Series prefix cannot be empty".into()), + Some(c) if !c.is_ascii_alphanumeric() => { + return Err("Series prefix must start with a letter or digit".into()) + } + Some(_) => {} + } + if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') { + return Err( + "Series prefix may only contain letters, digits, '-' and '/' (no spaces)".into(), + ); + } + let length = prefix.len() as i64 + padding.max(1); + if length > MAX_NUMBER_LEN as i64 { + return Err(format!( + "Series prefix plus number padding is {length} characters; invoice numbers can be at most {MAX_NUMBER_LEN}" + )); + } + Ok(()) +} + /// Deactivates the current series and starts a fresh one, resetting the counter to 1. +/// The deactivate and insert happen in one transaction so a failure never leaves the +/// app without an active series. +pub fn start_new_series_impl( + conn: &mut Connection, + prefix: &str, + padding: i64, +) -> Result { + let prefix = prefix.trim(); + let padding = padding.clamp(1, 8); + validate_series_format(prefix, padding)?; + let tx = conn.transaction().map_err(|e| e.to_string())?; + tx.execute("UPDATE invoice_series SET is_active = 0", []) + .map_err(|e| e.to_string())?; + tx.execute( + "INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at) + VALUES (?1, ?2, 1, 1, ?3)", + params![prefix, padding, chrono::Utc::now().to_rfc3339()], + ) + .map_err(|e| e.to_string())?; + let id = tx.last_insert_rowid(); + let series = tx + .query_row( + &format!("{} WHERE id = ?1", SERIES_SELECT), + params![id], + map_series, + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(series) +} + #[tauri::command] pub fn start_new_series( state: State, prefix: String, padding: i64, ) -> Result { - let prefix = prefix.trim().to_string(); - if prefix.is_empty() { - return Err("Series prefix cannot be empty".into()); + let mut conn = state.db.lock().map_err(|e| e.to_string())?; + start_new_series_impl(&mut conn, &prefix, padding) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn conn() -> Connection { + crate::db::open_in_memory().unwrap() } - let padding = padding.clamp(1, 8); - let conn = state.db.lock().map_err(|e| e.to_string())?; - conn.execute("UPDATE invoice_series SET is_active = 0", []) - .map_err(|e| e.to_string())?; - conn.execute( - "INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at) - VALUES (?1, ?2, 1, 1, ?3)", - params![prefix, padding, chrono::Utc::now().to_rfc3339()], - ) - .map_err(|e| e.to_string())?; - let id = conn.last_insert_rowid(); - conn.query_row( - &format!("{} WHERE id = ?1", SERIES_SELECT), - params![id], - map_series, - ) - .map_err(|e| e.to_string()) + + #[test] + fn prefix_charset_and_length_rules() { + assert!(validate_series_format("INV/2026-", 3).is_ok()); + assert!(validate_series_format("INV", 1).is_ok()); + assert!(validate_series_format("", 3).is_err()); + assert!(validate_series_format("-AP", 3).is_err()); + assert!(validate_series_format("/AP", 3).is_err()); + assert!(validate_series_format("AP 2026", 3).is_err()); + assert!(validate_series_format("AP_26", 3).is_err()); + assert!(validate_series_format("AP.26", 3).is_err()); + assert!(validate_series_format("ÄP", 3).is_err()); + // 8 + 8 = 16 fits, 9 + 8 = 17 does not. + assert!(validate_series_format("ABCDEFGH", 8).is_ok()); + let err = validate_series_format("ABCDEFGHI", 8).unwrap_err(); + assert!(err.contains("17") && err.contains("16"), "{err}"); + } + + #[test] + fn new_series_deactivates_the_old_one() { + let mut c = conn(); + let s = start_new_series_impl(&mut c, " FY27/ ", 4).unwrap(); + assert_eq!(s.prefix, "FY27/"); + assert_eq!(s.next_invoice_number, "FY27/0001"); + let active: i64 = c + .query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(active, 1); + } + + #[test] + fn invalid_series_leaves_the_active_one_alone() { + let mut c = conn(); + let before: i64 = c + .query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .unwrap(); + assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err()); + assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err()); + let after: i64 = c + .query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(before, after); + } + + #[test] + fn failed_insert_rolls_back_the_deactivation() { + let mut c = conn(); + // A trigger forces the INSERT to fail after the UPDATE has run. + c.execute_batch( + "CREATE TRIGGER no_new_series BEFORE INSERT ON invoice_series + BEGIN SELECT RAISE(ABORT, 'blocked'); END;", + ) + .unwrap(); + assert!(start_new_series_impl(&mut c, "FY27", 3).is_err()); + let active: i64 = c + .query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(active, 1); + } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index e39e3e3..de1e17f 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,14 +1,14 @@ use crate::models::{BankAccount, Settings}; use crate::AppState; -use rusqlite::{params, Row}; +use rusqlite::{params, Connection, Row}; use tauri::State; -const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan, +pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan, vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate, default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name, signatory_designation"; -fn map_settings(row: &Row) -> rusqlite::Result { +pub(crate) fn map_settings(row: &Row) -> rusqlite::Result { Ok(Settings { vendor_name: row.get(0)?, vendor_address: row.get(1)?, @@ -84,7 +84,7 @@ pub fn save_settings(state: State, settings: Settings) -> Result rusqlite::Result { +pub(crate) fn map_bank(row: &Row) -> rusqlite::Result { Ok(BankAccount { id: Some(row.get(0)?), label: row.get(1)?, @@ -169,10 +169,94 @@ pub fn save_bank(state: State, bank: BankAccount) -> Result 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 = tx + .query_row("SELECT MIN(id) FROM bank_accounts", [], |r| r.get(0)) + .map_err(|e| e.to_string())?; + tx.execute("UPDATE bank_accounts SET is_default = 0", []) + .map_err(|e| e.to_string())?; + if let Some(next) = next { + tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next]) + .map_err(|e| e.to_string())?; + } + tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next]) + .map_err(|e| e.to_string())?; + } + tx.commit().map_err(|e| e.to_string()) +} + #[tauri::command] pub fn delete_bank(state: State, id: i64) -> Result<(), String> { - let conn = state.db.lock().map_err(|e| e.to_string())?; - conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id]) - .map_err(|e| e.to_string())?; - Ok(()) + let mut conn = state.db.lock().map_err(|e| e.to_string())?; + delete_bank_impl(&mut conn, id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn add_bank(conn: &Connection, name: &str, is_default: bool) -> i64 { + conn.execute( + "INSERT INTO bank_accounts (bank_name, is_default) VALUES (?1, ?2)", + params![name, is_default], + ) + .unwrap(); + conn.last_insert_rowid() + } + + fn default_state(conn: &Connection) -> (Option, Vec) { + 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])); + } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 5a42245..c9c0da4 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -303,6 +303,91 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> { Ok(()) } +/// A migrated, seeded in-memory database for unit tests of the command logic. +#[cfg(test)] +pub fn open_in_memory() -> Result { + let mut conn = Connection::open_in_memory()?; + conn.pragma_update(None, "foreign_keys", "ON")?; + migrations().to_latest(&mut conn)?; + seed(&conn)?; + Ok(conn) +} + +/// `/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 `/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::>()? + }; + 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::>()? + }; + for (id, text) in snapshots { + let Ok(mut json) = serde_json::from_str::(&text) else { + continue; + }; + let mut changed = false; + for key in ["logoPath", "signaturePath"] { + if let Some(path) = json.get(key).and_then(|v| v.as_str()) { + let rel = relativize(path, data_dir); + if rel != path { + json[key] = serde_json::Value::String(rel); + changed = true; + } + } + } + if changed { + tx.execute( + "UPDATE invoices SET vendor_snapshot = ?1 WHERE id = ?2", + params![json.to_string(), id], + )?; + } + } + + tx.commit() +} + /// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001". pub fn format_number(prefix: &str, padding: i64, number: i64) -> String { format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize) @@ -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!(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"); + } + } } diff --git a/src-tauri/src/gst.rs b/src-tauri/src/gst.rs new file mode 100644 index 0000000..ac578fb --- /dev/null +++ b/src-tauri/src/gst.rs @@ -0,0 +1,506 @@ +//! GST rules: state codes, tax-type derivation, GSTIN validation, integer-paise +//! totals and amount in words. Everything here is a pure function. + +use crate::models::InvoiceItem; +use serde::{Deserialize, Serialize}; + +/// GST state codes, copied from India Compliance (`STATE_NUMBERS` in +/// `india_compliance/gst_india/constants/__init__.py`), including 96 and 97. +pub const STATES: &[(&str, &str)] = &[ + ("01", "Jammu and Kashmir"), + ("02", "Himachal Pradesh"), + ("03", "Punjab"), + ("04", "Chandigarh"), + ("05", "Uttarakhand"), + ("06", "Haryana"), + ("07", "Delhi"), + ("08", "Rajasthan"), + ("09", "Uttar Pradesh"), + ("10", "Bihar"), + ("11", "Sikkim"), + ("12", "Sampleachal Pradesh"), + ("13", "Nagaland"), + ("14", "Manipur"), + ("15", "Mizoram"), + ("16", "Tripura"), + ("17", "Meghalaya"), + ("18", "Assam"), + ("19", "West Bengal"), + ("20", "Jharkhand"), + ("21", "Odisha"), + ("22", "Chhattisgarh"), + ("23", "Madhya Pradesh"), + ("24", "Gujarat"), + ("26", "Dadra and Nagar Haveli and Daman and Diu"), + ("27", "Maharashtra"), + ("29", "Karnataka"), + ("30", "Goa"), + ("31", "Lakshadweep Islands"), + ("32", "Kerala"), + ("33", "Tamil Nadu"), + ("34", "Puducherry"), + ("35", "Andaman and Nicobar Islands"), + ("36", "Telangana"), + ("37", "Andhra Pradesh"), + ("38", "Ladakh"), + ("96", "Other Countries"), + ("97", "Other Territory"), +]; + +pub fn state_exists(code: &str) -> bool { + STATES.iter().any(|(c, _)| *c == code) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaxType { + None, + CgstSgst, + Igst, +} + +impl TaxType { + pub fn as_str(self) -> &'static str { + match self { + TaxType::None => "none", + TaxType::CgstSgst => "cgst_sgst", + TaxType::Igst => "igst", + } + } +} + +/// An unregistered supplier cannot charge GST. Otherwise the tax head follows the +/// place of supply, which defaults to the supplier's own state when left empty. +pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str) -> TaxType { + if registration == "unregistered" { + return TaxType::None; + } + let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state }; + if pos == supplier_state { + TaxType::CgstSgst + } else { + TaxType::Igst + } +} + +/// Union territories without a legislature: Chandigarh, Dadra & Nagar Haveli and +/// Daman & Diu, Lakshadweep, Andaman & Nicobar Islands, Ladakh. +#[cfg_attr(not(test), allow(dead_code))] // used through `second_head_label`, which the PDF layer will call +pub fn is_ut_without_legislature(code: &str) -> bool { + matches!(code, "04" | "26" | "31" | "35" | "38") +} + +/// UTGST replaces SGST only when the supplier is in one of those territories. +#[cfg_attr(not(test), allow(dead_code))] // consumed by the PDF layer in a later step +pub fn second_head_label(supplier_state: &str) -> &'static str { + if is_ut_without_legislature(supplier_state) { + "UTGST" + } else { + "SGST" + } +} + +pub fn doc_type(registration: &str) -> &'static str { + if registration == "unregistered" { + "invoice" + } else { + "tax_invoice" + } +} + +const GSTIN_CHARS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +fn gstin_shape_ok(b: &[u8]) -> bool { + b.len() == 15 + && b[0..2].iter().all(u8::is_ascii_digit) + && b[2..7].iter().all(u8::is_ascii_uppercase) + && b[7..11].iter().all(u8::is_ascii_digit) + && b[11].is_ascii_uppercase() + && (b[12].is_ascii_uppercase() || (b'1'..=b'9').contains(&b[12])) + && b[13] == b'Z' + && (b[14].is_ascii_digit() || b[14].is_ascii_uppercase()) +} + +/// Standard GSTIN mod-36 check character over the first 14 characters. +fn gstin_check_char(first14: &[u8]) -> u8 { + let sum: usize = first14 + .iter() + .enumerate() + .map(|(i, ch)| { + let value = GSTIN_CHARS.iter().position(|c| c == ch).unwrap_or(0); + let product = value * if i % 2 == 0 { 1 } else { 2 }; + product / 36 + product % 36 + }) + .sum(); + GSTIN_CHARS[(36 - sum % 36) % 36] +} + +pub fn validate_gstin(g: &str) -> Result<(), String> { + let g = g.trim().to_ascii_uppercase(); + if g.len() != 15 || !g.is_ascii() { + return Err("GSTIN must be 15 characters".into()); + } + let b = g.as_bytes(); + if !gstin_shape_ok(b) { + return Err("GSTIN format is invalid".into()); + } + if !state_exists(&g[0..2]) { + return Err(format!("GSTIN has an unknown state code {}", &g[0..2])); + } + let expected = gstin_check_char(&b[0..14]); + if b[14] != expected { + return Err(format!( + "GSTIN check digit is wrong (expected {})", + expected as char + )); + } + Ok(()) +} + +/// Characters 3 to 12 of a GSTIN are the holder's PAN. +pub fn gstin_matches_pan(gstin: &str, pan: &str) -> bool { + let gstin = gstin.trim().to_ascii_uppercase(); + let pan = pan.trim().to_ascii_uppercase(); + gstin.len() == 15 && gstin.is_ascii() && pan.len() == 10 && gstin[2..12] == pan +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Totals { + pub subtotal: i64, + pub discount: i64, + pub taxable: i64, + pub cgst: i64, + pub sgst: i64, + pub igst: i64, + pub total: i64, +} + +pub fn rupees_to_paise(rupees: f64) -> i64 { + (rupees * 100.0).round() as i64 +} + +/// Round-half-up integer division for non-negative operands. +fn div_round(num: i128, den: i128) -> i64 { + ((num + den / 2) / den) as i64 +} + +/// Amount of a single line in paise. Rate lines multiply the rounded rate by the +/// (possibly fractional) quantity and round half up once. +pub fn line_amount_paise(item: &InvoiceItem) -> i64 { + if item.mode == "rate" { + let rate = rupees_to_paise(item.rate); + (rate as f64 * item.quantity).round() as i64 + } else { + rupees_to_paise(item.amount) + } +} + +pub fn compute_totals( + items: &[InvoiceItem], + discount_paise: i64, + tax_type: TaxType, + rate_bp: i64, +) -> Totals { + let subtotal: i64 = items.iter().map(line_amount_paise).sum(); + let discount = discount_paise.clamp(0, subtotal.max(0)); + let taxable = subtotal - discount; + let (mut cgst, mut sgst, mut igst) = (0, 0, 0); + match tax_type { + TaxType::CgstSgst => { + cgst = div_round(taxable as i128 * rate_bp as i128, 20_000); + sgst = cgst; + } + TaxType::Igst => igst = div_round(taxable as i128 * rate_bp as i128, 10_000), + TaxType::None => {} + } + Totals { + subtotal, + discount, + taxable, + cgst, + sgst, + igst, + total: taxable + cgst + sgst + igst, + } +} + +const ONES: [&str; 20] = [ + "", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", + "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen", +]; + +const TENS: [&str; 10] = [ + "", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety", +]; + +fn two_digits(n: u64) -> String { + if n < 20 { + return ONES[n as usize].to_string(); + } + let ones = n % 10; + let mut out = TENS[(n / 10) as usize].to_string(); + if ones > 0 { + out.push(' '); + out.push_str(ONES[ones as usize]); + } + out +} + +fn three_digits(n: u64) -> String { + let hundreds = n / 100; + let rest = n % 100; + let mut out = String::new(); + if hundreds > 0 { + out.push_str(ONES[hundreds as usize]); + out.push_str(" Hundred"); + } + if rest > 0 { + if !out.is_empty() { + out.push(' '); + } + out.push_str(&two_digits(rest)); + } + out +} + +/// Whole number to words in the Indian system (thousand, lakh, crore). +fn whole_to_words(n: u64) -> String { + if n == 0 { + return "Zero".to_string(); + } + let mut parts: Vec = 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::("\"igst\"").unwrap(), TaxType::Igst); + assert_eq!(serde_json::from_str::("\"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}"); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9330d32..ea171d4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod commands; mod db; +mod gst; mod models; use rusqlite::Connection; @@ -46,6 +47,10 @@ fn init_state(app: &tauri::App) -> Result> return Err(e.into()); } }; + if let Err(e) = db::relativize_asset_paths(&conn, &data_dir) { + show_startup_error(app, &format!("Could not update stored image paths: {e}"), Some(&data_dir)); + return Err(e.into()); + } Ok(AppState { db: Mutex::new(conn), data_dir, @@ -96,10 +101,14 @@ pub fn run() { commands::clients::save_client, commands::clients::delete_client, commands::invoice::peek_next_invoice_number, - commands::invoice::create_invoice, + commands::invoice::issue_invoice, + commands::invoice::cancel_invoice, commands::invoice::list_invoices, commands::invoice::get_invoice, - commands::invoice::delete_invoice, + commands::invoice::save_draft, + commands::invoice::list_drafts, + commands::invoice::get_draft, + commands::invoice::delete_draft, commands::assets::import_asset, commands::assets::save_asset_bytes, commands::assets::read_asset_data_uri, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 4b7f40b..8da6344 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -96,6 +96,8 @@ pub struct InvoiceItem { pub amount: f64, #[serde(default)] pub sort_order: i64, + #[serde(default)] + pub hsn_sac: String, } fn default_mode() -> String { @@ -106,6 +108,8 @@ fn default_unit() -> String { "unit".to_string() } +/// What the webview may send when issuing an invoice. Money totals, tax amounts, +/// the amount in words and the vendor/bank snapshots are derived on the Rust side. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InvoiceInput { @@ -125,34 +129,25 @@ pub struct InvoiceInput { #[serde(default)] pub place_of_supply_state_code: String, #[serde(default)] - pub subtotal: f64, - #[serde(default)] pub discount: f64, + /// The tax treatment the user chose; it must match the derived one. #[serde(default = "default_tax_type")] pub tax_type: String, #[serde(default)] pub tax_rate: f64, #[serde(default)] - pub cgst_amount: f64, - #[serde(default)] - pub sgst_amount: f64, - #[serde(default)] - pub igst_amount: f64, - #[serde(default)] - pub total: f64, - #[serde(default)] - pub amount_in_words: String, + pub reverse_charge: bool, #[serde(default)] pub bank_account_id: Option, #[serde(default)] - pub bank_snapshot: String, - #[serde(default)] pub signature_path: Option, #[serde(default)] pub notes: String, #[serde(default)] pub save_client: bool, #[serde(default)] + pub draft_id: Option, + #[serde(default)] pub items: Vec, } @@ -187,9 +182,18 @@ pub struct Invoice { pub bank_snapshot: String, pub signature_path: Option, pub notes: String, + /// draft | issued | cancelled pub status: String, pub created_at: String, pub updated_at: String, + pub doc_type: String, + pub reverse_charge: bool, + /// JSON of the supplier details as they were when the invoice was issued; empty if unknown. + pub vendor_snapshot: String, + pub snapshot_origin: String, + pub cancelled_at: Option, + pub cancel_reason: String, + pub archived_pdf_sha256: Option, pub items: Vec, } @@ -205,3 +209,12 @@ pub struct InvoiceSummary { pub status: String, pub created_at: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftSummary { + pub id: i64, + pub updated_at: String, + pub client_name: Option, + pub total: Option, +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 78aee28..c1dbe52 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { BankAccount, Client, + DraftSummary, Invoice, InvoiceInput, InvoiceSeries, @@ -27,10 +28,18 @@ export const api = { deleteClient: (id: number) => invoke("delete_client", { id }), peekNextInvoiceNumber: () => invoke("peek_next_invoice_number"), - createInvoice: (input: InvoiceInput) => invoke("create_invoice", { input }), + issueInvoice: (input: InvoiceInput, renderPrefs: Record) => + invoke("issue_invoice", { input, renderPrefs }), + cancelInvoice: (id: number, reason: string) => + invoke("cancel_invoice", { id, reason }), listInvoices: () => invoke("list_invoices"), getInvoice: (id: number) => invoke("get_invoice", { id }), - deleteInvoice: (id: number) => invoke("delete_invoice", { id }), + + saveDraft: (id: number | null, payload: unknown) => + invoke("save_draft", { id, payload }), + listDrafts: () => invoke("list_drafts"), + getDraft: (id: number) => invoke("get_draft", { id }), + deleteDraft: (id: number) => invoke("delete_draft", { id }), importAsset: (sourcePath: string, kind: string) => invoke("import_asset", { sourcePath, kind }), diff --git a/src/lib/invoice.test.ts b/src/lib/invoice.test.ts new file mode 100644 index 0000000..414f5f9 --- /dev/null +++ b/src/lib/invoice.test.ts @@ -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); + }); +}); diff --git a/src/lib/invoice.ts b/src/lib/invoice.ts index 2464aeb..4f4c006 100644 --- a/src/lib/invoice.ts +++ b/src/lib/invoice.ts @@ -1,12 +1,23 @@ -import { round2 } from "./format"; import type { InvoiceItem, TaxType } from "./types"; +// The preview must show exactly what Rust stores (src-tauri/src/gst.rs), so money is +// computed in integer paise with round-half-up, the same way. + +const toPaise = (rupees: number) => Math.round((Number.isFinite(rupees) ? rupees : 0) * 100); + +function linePaise(item: InvoiceItem): number { + if (item.mode === "rate") { + return Math.round(toPaise(item.rate || 0) * (item.quantity || 0)); + } + return toPaise(item.amount || 0); +} + +/** Round-half-up division for non-negative integers. */ +const divRound = (num: number, den: number) => Math.floor((num + den / 2) / den); + /** Amount for a single line: a fixed total, or rate × quantity. */ export function computeLineAmount(item: InvoiceItem): number { - if (item.mode === "rate") { - return round2((item.rate || 0) * (item.quantity || 0)); - } - return round2(item.amount || 0); + return linePaise(item) / 100; } export interface Totals { @@ -26,29 +37,30 @@ export function computeTotals( taxType: TaxType, taxRate: number, ): Totals { - const subtotal = round2(items.reduce((sum, item) => sum + computeLineAmount(item), 0)); - const discountValue = round2(Math.min(Math.max(discount || 0, 0), subtotal)); - const taxable = round2(subtotal - discountValue); + const subtotal = items.reduce((sum, item) => sum + linePaise(item), 0); + const discountPaise = Math.min(Math.max(toPaise(discount), 0), Math.max(subtotal, 0)); + const taxable = subtotal - discountPaise; + const rateBp = Math.round((Number.isFinite(taxRate) ? taxRate : 0) * 100); let cgst = 0; let sgst = 0; let igst = 0; if (taxType === "cgst_sgst") { - cgst = round2((taxable * (taxRate / 2)) / 100); - sgst = round2((taxable * (taxRate / 2)) / 100); + cgst = divRound(taxable * rateBp, 20_000); + sgst = cgst; } else if (taxType === "igst") { - igst = round2((taxable * taxRate) / 100); + igst = divRound(taxable * rateBp, 10_000); } - const taxTotal = round2(cgst + sgst + igst); + const taxTotal = cgst + sgst + igst; return { - subtotal, - discount: discountValue, - taxable, - cgst, - sgst, - igst, - taxTotal, - total: round2(taxable + taxTotal), + subtotal: subtotal / 100, + discount: discountPaise / 100, + taxable: taxable / 100, + cgst: cgst / 100, + sgst: sgst / 100, + igst: igst / 100, + taxTotal: taxTotal / 100, + total: (taxable + taxTotal) / 100, }; } diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx index 1509979..22b0b08 100644 --- a/src/lib/pdf.tsx +++ b/src/lib/pdf.tsx @@ -17,12 +17,24 @@ function blobToBase64(blob: Blob): Promise { }); } +function vendorFromSnapshot(snapshot: string | undefined, live: Settings): Settings { + if (!snapshot) return live; + try { + return { ...live, ...(JSON.parse(snapshot) as Partial) }; + } catch { + return live; + } +} + export async function buildPdfProps( invoice: Invoice, settings: Settings, bank: BankAccount | null, ): Promise { - const logoDataUri = settings.logoPath ? await api.readAssetDataUri(settings.logoPath) : null; + // Re-exports use the vendor details frozen at issue; live settings are only a fallback for + // legacy rows without a snapshot. + const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings); + const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null; const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null; @@ -37,7 +49,7 @@ export async function buildPdfProps( } return { - vendor: settings, + vendor, bank: resolvedBank, logoDataUri, signatureDataUri, diff --git a/src/lib/types.ts b/src/lib/types.ts index 80eb220..28318d2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,7 +19,7 @@ export interface Settings { currency: string; onboarded: boolean; theme: string; - gstRegistration: string; + gstRegistration: GstRegistration; defaultHsnSac: string; signatoryName: string; signatoryDesignation: string; @@ -65,9 +65,41 @@ export interface InvoiceItem { quantity: number; amount: number; sortOrder: number; + /** Optional so older callers that build items by hand keep compiling; stored as "" when absent. */ + hsnSac?: string; } +export type GstRegistration = "unregistered" | "regular"; + +/** What the webview sends to issue an invoice; totals, tax and words are derived in Rust. */ export interface InvoiceInput { + invoiceDate: string; + dueDate: string; + clientId: number | null; + clientName: string; + clientAddress: string; + clientGstin: string; + poNumber: string; + placeOfSupplyStateCode: string; + discount: number; + taxType: TaxType; + taxRate: number; + reverseCharge: boolean; + bankAccountId: number | null; + signaturePath: string | null; + notes: string; + saveClient: boolean; + draftId: number | null; + items: InvoiceItem[]; +} + +export type InvoiceStatus = "draft" | "issued" | "cancelled"; + +/** An invoice as stored: all amounts are derived on the Rust side. */ +export interface Invoice { + id: number; + number: string; + seriesId: number | null; invoiceDate: string; dueDate: string; clientId: number | null; @@ -89,18 +121,25 @@ export interface InvoiceInput { bankSnapshot: string; signaturePath: string | null; notes: string; - saveClient: boolean; + status: InvoiceStatus; + createdAt: string; + updatedAt: string; + docType: "invoice" | "tax_invoice"; + reverseCharge: boolean; + /** JSON of the supplier details at issue time; empty when unknown. */ + vendorSnapshot: string; + snapshotOrigin: string; + cancelledAt: string | null; + cancelReason: string; + archivedPdfSha256: string | null; items: InvoiceItem[]; } -export interface Invoice extends Omit { +export interface DraftSummary { id: number; - number: string; - seriesId: number | null; - status: string; - createdAt: string; updatedAt: string; - items: InvoiceItem[]; + clientName: string | null; + total: number | null; } export interface InvoiceSummary { @@ -110,7 +149,7 @@ export interface InvoiceSummary { dueDate: string; clientName: string; total: number; - status: string; + status: InvoiceStatus; createdAt: string; } @@ -122,10 +161,12 @@ export const EMPTY_ITEM: InvoiceItem = { quantity: 1, amount: 0, sortOrder: 0, + hsnSac: "", }; +/** GST state codes, same list as STATES in src-tauri/src/gst.rs (from India Compliance). */ export const INDIAN_STATES: { code: string; name: string }[] = [ - { code: "01", name: "Jammu & Kashmir" }, + { code: "01", name: "Jammu and Kashmir" }, { code: "02", name: "Himachal Pradesh" }, { code: "03", name: "Punjab" }, { code: "04", name: "Chandigarh" }, @@ -149,18 +190,20 @@ export const INDIAN_STATES: { code: string; name: string }[] = [ { code: "22", name: "Chhattisgarh" }, { code: "23", name: "Madhya Pradesh" }, { code: "24", name: "Gujarat" }, - { code: "26", name: "Dadra & Nagar Haveli and Daman & Diu" }, + { code: "26", name: "Dadra and Nagar Haveli and Daman and Diu" }, { code: "27", name: "Maharashtra" }, { code: "29", name: "Karnataka" }, { code: "30", name: "Goa" }, - { code: "31", name: "Lakshadweep" }, + { code: "31", name: "Lakshadweep Islands" }, { code: "32", name: "Kerala" }, { code: "33", name: "Tamil Nadu" }, { code: "34", name: "Puducherry" }, - { code: "35", name: "Andaman & Nicobar Islands" }, + { code: "35", name: "Andaman and Nicobar Islands" }, { code: "36", name: "Telangana" }, { code: "37", name: "Andhra Pradesh" }, { code: "38", name: "Ladakh" }, + { code: "96", name: "Other Countries" }, + { code: "97", name: "Other Territory" }, ]; export const UNIT_LABELS: Record = { diff --git a/src/lib/validators.test.ts b/src/lib/validators.test.ts new file mode 100644 index 0000000..a2c1df4 --- /dev/null +++ b/src/lib/validators.test.ts @@ -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(); + }); +}); diff --git a/src/lib/validators.ts b/src/lib/validators.ts index ab5a824..2d884d9 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -22,6 +22,50 @@ export function gstinError(value: string): string | undefined { return GSTIN_RE.test(v) ? undefined : "Enter a valid 15-character GSTIN"; } +const GSTIN_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** Standard GSTIN mod-36 check digit. Returns an error message, or undefined when valid. */ +export function gstinChecksumError(value: string): string | undefined { + const v = norm(value); + if (!GSTIN_RE.test(v)) return "Enter a valid 15-character GSTIN"; + let sum = 0; + for (let i = 0; i < 14; i++) { + const product = GSTIN_CHARS.indexOf(v[i]) * (i % 2 === 0 ? 1 : 2); + sum += Math.floor(product / 36) + (product % 36); + } + const expected = GSTIN_CHARS[(36 - (sum % 36)) % 36]; + return v[14] === expected ? undefined : `GSTIN check digit looks wrong (expected ${expected})`; +} + +/** + * Full GSTIN check: shape, check digit and, when a PAN is given, that the GSTIN + * contains that PAN. Empty (or "NA") is valid; the field is optional. + */ +export function gstinFullError(value: string, pan = ""): string | undefined { + const v = norm(value); + if (!v || v === "NA") return undefined; + const base = gstinError(v) ?? gstinChecksumError(v); + if (base) return base; + if (pan.trim() && v.slice(2, 12) !== norm(pan)) return "GSTIN does not contain the PAN"; + return undefined; +} + +/** Supplier GSTIN rules for a registered business; an unregistered one needs none. */ +export function vendorGstinError( + registration: string, + gstin: string, + pan: string, + stateCode: string, +): string | undefined { + if (registration === "unregistered") return undefined; + const g = norm(gstin); + if (!g) return "A registered business needs a GSTIN"; + const base = gstinFullError(g, pan); + if (base) return base; + if (g.slice(0, 2) !== stateCode) return `GSTIN starts with ${g.slice(0, 2)}, but your state code is ${stateCode}`; + return undefined; +} + export function emailError(value: string): string | undefined { if (!value.trim()) return undefined; return EMAIL_RE.test(value.trim()) ? undefined : "Enter a valid email address"; diff --git a/src/views/AppSettings.tsx b/src/views/AppSettings.tsx index b1372f3..8a8b1d5 100644 --- a/src/views/AppSettings.tsx +++ b/src/views/AppSettings.tsx @@ -23,8 +23,14 @@ import { import { Add, Save, TrashCan } from "@carbon/icons-react"; import { confirm } from "@tauri-apps/plugin-dialog"; import { api } from "../lib/api"; -import { INDIAN_STATES, type BankAccount, type Settings, type TaxType } from "../lib/types"; -import { emailError, ifscError, panError, phoneError } from "../lib/validators"; +import { + INDIAN_STATES, + type BankAccount, + type GstRegistration, + type Settings, + type TaxType, +} from "../lib/types"; +import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators"; import { ImagePicker } from "../components/ImagePicker"; const blankBank = (): BankAccount => ({ @@ -70,7 +76,19 @@ export default function AppSettings({ const setBank = (key: K, value: BankAccount[K]) => setEditing((prev) => (prev ? { ...prev, [key]: value } : prev)); + const registered = form.gstRegistration !== "unregistered"; + const gstinProblem = vendorGstinError( + form.gstRegistration, + form.vendorGstin, + form.vendorPan, + form.vendorStateCode, + ); + const saveAll = async () => { + if (gstinProblem) { + setError(gstinProblem); + return; + } setBusy(true); setError(null); setNotice(null); @@ -140,6 +158,22 @@ export default function AppSettings({

Vendor details

+ + { + set("gstRegistration", value as GstRegistration); + if (value === "unregistered") set("defaultTaxType", "none"); + else if (form.defaultTaxType === "none") set("defaultTaxType", "cgst_sgst"); + }} + > + + + + - - set("vendorGstin", e.target.value.toUpperCase())} - /> - + {registered ? ( + + set("vendorGstin", e.target.value.toUpperCase())} + invalid={Boolean(gstinProblem)} + invalidText={gstinProblem} + /> + + ) : null} - - set("defaultTaxType", value as TaxType)} - > - - - - - + {registered ? ( + + set("defaultTaxType", value as TaxType)} + > + + + + + + ) : null}
diff --git a/src/views/InvoiceHistory.tsx b/src/views/InvoiceHistory.tsx index 70c5e81..87f14a1 100644 --- a/src/views/InvoiceHistory.tsx +++ b/src/views/InvoiceHistory.tsx @@ -3,6 +3,7 @@ import { Button, InlineLoading, InlineNotification, + Modal, Table, TableBody, TableCell, @@ -10,9 +11,10 @@ import { TableHead, TableHeader, TableRow, + Tag, + TextInput, } from "@carbon/react"; -import { Download, TrashCan } from "@carbon/icons-react"; -import { confirm } from "@tauri-apps/plugin-dialog"; +import { Download, Close } from "@carbon/icons-react"; import { api } from "../lib/api"; import { formatAmount, formatDate } from "../lib/format"; import { exportInvoicePdf } from "../lib/pdf"; @@ -23,6 +25,8 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); + const [cancelTarget, setCancelTarget] = useState(null); + const [reason, setReason] = useState(""); const load = async () => { try { @@ -52,16 +56,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) { } }; - const onDelete = async (row: InvoiceSummary) => { - const ok = await confirm(`Delete invoice ${row.number}? This cannot be undone.`, { - title: "Delete invoice", - kind: "warning", - }); - if (!ok) return; + const onCancel = async () => { + if (!cancelTarget) return; try { - await api.deleteInvoice(row.id); + await api.cancelInvoice(cancelTarget.id, reason); + setCancelTarget(null); + setReason(""); await load(); } catch (e) { + setCancelTarget(null); setError(String(e)); } }; @@ -99,7 +102,15 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) { {formatDate(row.invoiceDate)} {row.clientName || "—"} ₹{formatAmount(row.total)} - {row.status} + + {row.status === "cancelled" ? ( + + Cancelled + + ) : ( + row.status + )} +
- + {row.status === "issued" ? ( + + ) : null}
@@ -127,6 +143,26 @@ export default function InvoiceHistory({ settings }: { settings: Settings }) { )} + + setCancelTarget(null)} + onRequestSubmit={onCancel} + > +

+ The invoice stays in the list as cancelled and its number is not reused. +

+ setReason(e.target.value)} + /> +
); } diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 8cad2c5..cc17f8a 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -6,8 +6,6 @@ import { Grid, InlineNotification, NumberInput, - RadioButton, - RadioButtonGroup, Select, SelectItem, TextArea, @@ -29,6 +27,7 @@ import type { } from "../lib/types"; import { computeLineAmount, computeTotals } from "../lib/invoice"; import { amountInWords } from "../lib/numberToWords"; +import { gstinFullError } from "../lib/validators"; import { addDays, formatAmount, todayIso } from "../lib/format"; import { buildPdfProps, exportPdfFromProps } from "../lib/pdf"; import { InvoicePreview } from "../components/InvoicePreview"; @@ -41,6 +40,7 @@ interface NewInvoiceProps { } export default function NewInvoice({ settings }: NewInvoiceProps) { + const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac }); const today = todayIso(); const [invoiceDate, setInvoiceDate] = useState(today); 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 [saveClient, setSaveClient] = useState(false); - const [items, setItems] = useState([{ ...EMPTY_ITEM }]); + const [items, setItems] = useState([newItem()]); const [discount, setDiscount] = useState(0); - const [taxesApplicable, setTaxesApplicable] = useState(settings.defaultTaxType !== "none"); - const [taxType, setTaxType] = useState( - settings.defaultTaxType === "none" ? "cgst_sgst" : settings.defaultTaxType, - ); + const registered = settings.gstRegistration !== "unregistered"; + const [reverseCharge, setReverseCharge] = useState(false); const [taxRate, setTaxRate] = useState(settings.defaultTaxRate); const [banks, setBanks] = useState([]); @@ -122,7 +120,14 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { }; }, [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( () => computeTotals(items, discount, effectiveTaxType, taxRate), [items, discount, effectiveTaxType, taxRate], @@ -159,7 +164,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { const updateItem = (index: number, patch: Partial) => 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) => setItems((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== index))); @@ -179,7 +184,7 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { setClientGstin(""); setPoNumber(""); setSaveClient(false); - setItems([{ ...EMPTY_ITEM }]); + setItems([newItem()]); setDiscount(0); setNotes(""); setInvoiceDate(todayIso()); @@ -193,6 +198,10 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { }; const onSave = async () => { + if (clientGstinError) { + setError(`Client GSTIN: ${clientGstinError}`); + return; + } setSaving(true); setError(null); setSaved(null); @@ -206,27 +215,18 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { clientGstin, poNumber, placeOfSupplyStateCode: placeOfSupply, - subtotal: totals.subtotal, - discount: totals.discount, + discount, taxType: effectiveTaxType, taxRate, - cgstAmount: totals.cgst, - sgstAmount: totals.sgst, - igstAmount: totals.igst, - total: totals.total, - amountInWords: words, + reverseCharge: registered && reverseCharge, bankAccountId: bankId, - bankSnapshot: bank ? JSON.stringify(bank) : "", signaturePath, notes, saveClient, - items: items.map((item, index) => ({ - ...item, - sortOrder: index, - amount: computeLineAmount(item), - })), + draftId: null, + items: items.map((item, index) => ({ ...item, sortOrder: index })), }; - const invoice = await api.createInvoice(input); + const invoice = await api.issueInvoice(input, {}); const props = await buildPdfProps(invoice, settings, bank); const path = await exportPdfFromProps(props, invoice.number); if (path) setSaved(path); @@ -336,6 +336,8 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { labelText="Client GSTIN (optional)" value={clientGstin} onChange={(e) => setClientGstin(e.target.value.toUpperCase())} + invalid={Boolean(clientGstinError)} + invalidText={clientGstinError} /> @@ -391,6 +393,16 @@ export default function NewInvoice({ settings }: NewInvoiceProps) { onChange={(e) => updateItem(index, { description: e.target.value })} /> + {registered ? ( +
+ updateItem(index, { hsnSac: e.target.value })} + /> +
+ ) : null}
setPadding(Number(value) || 3)} /> - - set("defaultTaxRate", Number(value) || 0)} - /> - + {registered ? ( + + set("defaultTaxRate", Number(value) || 0)} + /> + + ) : null} set("paymentTermsDays", Number(value) || 0)} /> - - set("defaultTaxType", value as TaxType)} - > - - - - - + {registered ? ( + + set("defaultTaxType", value as TaxType)} + > + + + + + + ) : null} ), },