Add issue/cancel lifecycle, GST derivation and integer-paise totals
issue_invoice allocates the number, freezes vendor and bank details and stores server-computed totals in one transaction. Issued invoices are cancelled, not deleted. Tax heads derive from supplier state and place of supply (a disagreeing choice is rejected); unregistered suppliers issue a plain Invoice. Adds GSTIN checksum validation, canonical India Compliance state list, server-side drafts, series validation, relative and magic-byte-checked asset paths, and GST settings. PDF re-exports use the frozen vendor snapshot.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
use crate::AppState;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use tauri::State;
|
||||
|
||||
const FORMAT_ERROR: &str = "Use a PNG or JPEG image";
|
||||
|
||||
fn safe_kind(kind: &str) -> String {
|
||||
kind.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
||||
@@ -10,47 +12,88 @@ fn safe_kind(kind: &str) -> String {
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extension_of(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_else(|| "png".to_string())
|
||||
}
|
||||
|
||||
fn mime_for(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"svg" => "image/svg+xml",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
_ => "image/png",
|
||||
/// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and
|
||||
/// silently drops everything else, so nothing else is accepted.
|
||||
/// Returns (extension, MIME type).
|
||||
pub fn sniff_image(bytes: &[u8]) -> Result<(&'static str, &'static str), String> {
|
||||
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
Ok(("png", "image/png"))
|
||||
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
Ok(("jpg", "image/jpeg"))
|
||||
} else {
|
||||
Err(FORMAT_ERROR.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn assets_dir(state: &State<AppState>) -> Result<PathBuf, String> {
|
||||
let dir = state.data_dir.join("assets");
|
||||
fn assets_dir(data_dir: &Path) -> Result<PathBuf, String> {
|
||||
let dir = data_dir.join("assets");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
let dir = assets_dir(state)?;
|
||||
let file = format!(
|
||||
"{}-{}.{}",
|
||||
safe_kind(kind),
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
ext
|
||||
);
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
/// Resolve a stored asset path (relative to the data dir, or a legacy absolute one)
|
||||
/// to a real file inside `<data_dir>/assets`. `..`, symlink escapes and anything
|
||||
/// outside the assets directory are rejected.
|
||||
pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let raw = Path::new(path);
|
||||
if path.trim().is_empty() {
|
||||
return Err("Asset path is empty".into());
|
||||
}
|
||||
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||
return Err("Asset path must not contain '..'".into());
|
||||
}
|
||||
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
||||
let root = data_dir
|
||||
.join("assets")
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Asset store is unavailable: {e}"))?;
|
||||
let canonical = full
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Could not open asset {path}: {e}"))?;
|
||||
if canonical.starts_with(&root) && canonical != root {
|
||||
Ok(canonical)
|
||||
} else {
|
||||
Err("Asset path is outside the asset store".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The form stored in the database: `assets/<file>` relative to the data dir, with
|
||||
/// forward slashes on every platform.
|
||||
pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String> {
|
||||
let canonical = resolve_asset(data_dir, path)?;
|
||||
let base = data_dir.canonicalize().map_err(|e| e.to_string())?;
|
||||
let rel = canonical.strip_prefix(&base).map_err(|e| e.to_string())?;
|
||||
Ok(rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/"))
|
||||
}
|
||||
|
||||
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
let (ext, _) = sniff_image(bytes)?;
|
||||
let dir = assets_dir(data_dir)?;
|
||||
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext);
|
||||
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
|
||||
Ok(format!("assets/{file}"))
|
||||
}
|
||||
|
||||
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
|
||||
let file = resolve_asset(data_dir, path)?;
|
||||
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
||||
let (_, mime) = sniff_image(&bytes)?;
|
||||
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(bytes)))
|
||||
}
|
||||
|
||||
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
|
||||
let raw = Path::new(path);
|
||||
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
||||
// Removing something that is already gone is fine, but an escape attempt is not.
|
||||
if std::fs::symlink_metadata(&full).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
let target = resolve_asset(data_dir, path)?;
|
||||
std::fs::remove_file(target).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Copy a user-picked file into the app's asset store and return the stored path.
|
||||
@@ -61,56 +104,153 @@ pub fn import_asset(
|
||||
kind: String,
|
||||
) -> Result<String, String> {
|
||||
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
|
||||
let ext = extension_of(&source_path);
|
||||
write_asset(&state, &kind, &ext, &bytes)
|
||||
write_asset(&state.data_dir, &kind, &bytes)
|
||||
}
|
||||
|
||||
/// Used when the frontend already holds the bytes (base64) instead of a path.
|
||||
/// The file name is ignored: the type comes from the bytes.
|
||||
#[tauri::command]
|
||||
pub fn save_asset_bytes(
|
||||
state: State<AppState>,
|
||||
kind: String,
|
||||
file_name: String,
|
||||
#[allow(unused_variables)] file_name: String,
|
||||
data_base64: String,
|
||||
) -> Result<String, String> {
|
||||
let bytes = STANDARD
|
||||
.decode(data_base64.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ext = extension_of(&sanitize_name(&file_name));
|
||||
write_asset(&state, &kind, &ext, &bytes)
|
||||
write_asset(&state.data_dir, &kind, &bytes)
|
||||
}
|
||||
|
||||
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
|
||||
#[tauri::command]
|
||||
pub fn read_asset_data_uri(path: String) -> Result<String, String> {
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
let ext = extension_of(&path);
|
||||
Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
mime_for(&ext),
|
||||
STANDARD.encode(bytes)
|
||||
))
|
||||
pub fn read_asset_data_uri(state: State<AppState>, path: String) -> Result<String, String> {
|
||||
asset_data_uri(&state.data_dir, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
|
||||
let dir = assets_dir(&state)?;
|
||||
let target = PathBuf::from(&path);
|
||||
// Only allow deleting files inside the managed assets directory.
|
||||
if target.starts_with(&dir) && target.exists() {
|
||||
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
remove_asset_file(&state.data_dir, &path)
|
||||
}
|
||||
|
||||
/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path.
|
||||
#[tauri::command]
|
||||
pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> {
|
||||
if path.trim().is_empty() {
|
||||
return Err("No file path given".into());
|
||||
}
|
||||
let bytes = STANDARD
|
||||
.decode(data_base64.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(parent) = Path::new(&path).parent() {
|
||||
// A missing parent is created; if that fails the write below reports the real error.
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
|
||||
const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
|
||||
|
||||
#[test]
|
||||
fn sniffing_accepts_only_png_and_jpeg() {
|
||||
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
||||
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
|
||||
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image");
|
||||
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
|
||||
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
|
||||
assert!(sniff_image(b"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_ignores_the_name_and_stores_a_relative_path() {
|
||||
let dir = tempdir().unwrap();
|
||||
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
|
||||
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}");
|
||||
assert!(dir.path().join(&stored).is_file());
|
||||
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
||||
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
||||
assert!(uri.starts_with("data:image/jpeg;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
|
||||
let dir = tempdir().unwrap();
|
||||
let assets = dir.path().join("assets");
|
||||
std::fs::create_dir_all(&assets).unwrap();
|
||||
std::fs::write(assets.join("liar.jpg"), PNG).unwrap();
|
||||
let uri = asset_data_uri(dir.path(), "assets/liar.jpg").unwrap();
|
||||
assert!(uri.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_accepts_relative_and_inside_absolute_paths() {
|
||||
let dir = tempdir().unwrap();
|
||||
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||
let by_relative = resolve_asset(dir.path(), &stored).unwrap();
|
||||
let absolute = dir.path().join(&stored);
|
||||
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
||||
assert_eq!(by_relative, by_absolute);
|
||||
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_rejects_escapes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||
std::fs::write(dir.path().join("voiced.db"), b"secret").unwrap();
|
||||
let outside = tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("x.png"), PNG).unwrap();
|
||||
|
||||
assert!(resolve_asset(dir.path(), "../voiced.db").is_err());
|
||||
assert!(resolve_asset(dir.path(), "assets/../voiced.db").is_err());
|
||||
assert!(resolve_asset(dir.path(), "voiced.db").is_err());
|
||||
assert!(resolve_asset(dir.path(), "assets").is_err());
|
||||
assert!(resolve_asset(dir.path(), "").is_err());
|
||||
let abs_outside = outside.path().join("x.png");
|
||||
assert!(resolve_asset(dir.path(), abs_outside.to_str().unwrap()).is_err());
|
||||
let abs_db = dir.path().join("voiced.db");
|
||||
assert!(resolve_asset(dir.path(), abs_db.to_str().unwrap()).is_err());
|
||||
assert!(resolve_asset(dir.path(), "assets/missing.png").is_err());
|
||||
assert!(resolve_asset(dir.path(), &stored).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolver_rejects_symlink_escapes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let _ = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||
let outside = tempdir().unwrap();
|
||||
let target = outside.path().join("x.png");
|
||||
std::fs::write(&target, PNG).unwrap();
|
||||
std::os::unix::fs::symlink(&target, dir.path().join("assets/link.png")).unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), dir.path().join("assets/dir")).unwrap();
|
||||
|
||||
assert!(resolve_asset(dir.path(), "assets/link.png").is_err());
|
||||
assert!(resolve_asset(dir.path(), "assets/dir/x.png").is_err());
|
||||
assert!(remove_asset_file(dir.path(), "assets/link.png").is_err());
|
||||
assert!(target.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_inside_and_tolerates_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
||||
remove_asset_file(dir.path(), &stored).unwrap();
|
||||
assert!(!dir.path().join(&stored).exists());
|
||||
remove_asset_file(dir.path(), &stored).unwrap();
|
||||
std::fs::write(dir.path().join("voiced.db"), b"x").unwrap();
|
||||
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
||||
assert!(dir.path().join("voiced.db").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_binary_file_rejects_empty_path() {
|
||||
assert!(save_binary_file(" ".into(), "AAAA".into()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
use super::assets::relative_asset_path;
|
||||
use super::series::{validate_series_format, MAX_NUMBER_LEN};
|
||||
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
||||
use crate::db::format_number;
|
||||
use crate::models::{Invoice, InvoiceInput, InvoiceItem, InvoiceSummary};
|
||||
use crate::gst::{self, TaxType};
|
||||
use crate::models::{
|
||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
|
||||
};
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use chrono::NaiveDate;
|
||||
use rusqlite::{named_params, params, Connection, OptionalExtension, Row};
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
|
||||
const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, client_id, client_name,
|
||||
client_address, client_gstin, po_number, place_of_supply_state_code, subtotal, discount,
|
||||
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at";
|
||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
||||
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
||||
cancel_reason, archived_pdf_sha256";
|
||||
|
||||
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||
Ok(Invoice {
|
||||
@@ -38,13 +48,20 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||
status: row.get(24)?,
|
||||
created_at: row.get(25)?,
|
||||
updated_at: row.get(26)?,
|
||||
doc_type: row.get(27)?,
|
||||
reverse_charge: row.get::<_, i64>(28)? != 0,
|
||||
vendor_snapshot: row.get(29)?,
|
||||
snapshot_origin: row.get(30)?,
|
||||
cancelled_at: row.get(31)?,
|
||||
cancel_reason: row.get(32)?,
|
||||
archived_pdf_sha256: row.get(33)?,
|
||||
items: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<InvoiceItem>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, description, mode, rate, unit, quantity, amount, sort_order
|
||||
"SELECT id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac
|
||||
FROM invoice_items WHERE invoice_id = ?1 ORDER BY sort_order ASC, id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![invoice_id], |row| {
|
||||
@@ -57,6 +74,7 @@ fn fetch_items(conn: &Connection, invoice_id: i64) -> rusqlite::Result<Vec<Invoi
|
||||
quantity: row.get(5)?,
|
||||
amount: row.get(6)?,
|
||||
sort_order: row.get(7)?,
|
||||
hsn_sac: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
@@ -90,31 +108,224 @@ pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Invoice, String> {
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
fn paise_to_rupees(paise: i64) -> f64 {
|
||||
paise as f64 / 100.0
|
||||
}
|
||||
|
||||
let series = tx
|
||||
fn parse_date(label: &str, value: &str) -> Result<(), String> {
|
||||
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")
|
||||
.map(|_| ())
|
||||
.map_err(|_| format!("{label} must be a date like 2026-04-01"))
|
||||
}
|
||||
|
||||
/// Largest rupee amount accepted for a single line, far below anything that could
|
||||
/// overflow integer paise arithmetic.
|
||||
const MAX_LINE_RUPEES: f64 = 1e11;
|
||||
|
||||
fn validate_items(items: &[InvoiceItem]) -> Result<(), String> {
|
||||
if items.is_empty() {
|
||||
return Err("Add at least one line item".into());
|
||||
}
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let finite_ok = |v: f64| v.is_finite() && (0.0..=MAX_LINE_RUPEES).contains(&v);
|
||||
match item.mode.as_str() {
|
||||
"rate" => {
|
||||
if !finite_ok(item.rate) || !item.quantity.is_finite() || item.quantity < 0.0 {
|
||||
return Err(format!("Line {n}: rate and quantity must be positive numbers"));
|
||||
}
|
||||
if item.quantity > MAX_LINE_RUPEES {
|
||||
return Err(format!("Line {n}: quantity is too large"));
|
||||
}
|
||||
let amount = gst::line_amount_paise(item);
|
||||
if amount <= 0 {
|
||||
return Err(format!("Line {n}: amount must be greater than zero"));
|
||||
}
|
||||
if amount as f64 / 100.0 > MAX_LINE_RUPEES {
|
||||
return Err(format!("Line {n}: amount is too large"));
|
||||
}
|
||||
}
|
||||
"fixed" => {
|
||||
if !finite_ok(item.amount) {
|
||||
return Err(format!("Line {n}: amount must be zero or more"));
|
||||
}
|
||||
}
|
||||
other => return Err(format!("Line {n}: unknown line type \"{other}\"")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks that the supplier details in settings can legally produce this document.
|
||||
fn validate_supplier(settings: &Settings) -> Result<(), String> {
|
||||
if !gst::state_exists(&settings.vendor_state_code) {
|
||||
return Err(format!(
|
||||
"Supplier state code \"{}\" is not a valid GST state code. Fix it in Settings.",
|
||||
settings.vendor_state_code
|
||||
));
|
||||
}
|
||||
match settings.gst_registration.as_str() {
|
||||
"unregistered" => Ok(()),
|
||||
"regular" => {
|
||||
let gstin = settings.vendor_gstin.trim().to_ascii_uppercase();
|
||||
if gstin.is_empty() {
|
||||
return Err("A registered supplier needs a GSTIN. Add it in Settings.".into());
|
||||
}
|
||||
gst::validate_gstin(&gstin).map_err(|e| format!("Supplier GSTIN: {e}"))?;
|
||||
if gstin[0..2] != settings.vendor_state_code {
|
||||
return Err(format!(
|
||||
"Supplier GSTIN starts with state code {} but the supplier state is {}",
|
||||
&gstin[0..2],
|
||||
settings.vendor_state_code
|
||||
));
|
||||
}
|
||||
let pan = settings.vendor_pan.trim();
|
||||
if !pan.is_empty() && !gst::gstin_matches_pan(&gstin, pan) {
|
||||
return Err("Supplier GSTIN does not contain the supplier PAN".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
other => Err(format!("Unsupported GST registration type \"{other}\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Supplier details frozen onto the invoice. Built from the stored settings, never
|
||||
/// from anything the webview sent. An unregistered supplier has no GSTIN to print.
|
||||
fn vendor_snapshot(settings: &Settings) -> String {
|
||||
let gstin = if settings.gst_registration == "unregistered" {
|
||||
""
|
||||
} else {
|
||||
settings.vendor_gstin.trim()
|
||||
};
|
||||
serde_json::json!({
|
||||
"vendorName": settings.vendor_name,
|
||||
"vendorAddress": settings.vendor_address,
|
||||
"vendorEmail": settings.vendor_email,
|
||||
"vendorPhone": settings.vendor_phone,
|
||||
"vendorPan": settings.vendor_pan,
|
||||
"vendorGstin": gstin,
|
||||
"vendorStateCode": settings.vendor_state_code,
|
||||
"logoPath": settings.logo_path,
|
||||
"signaturePath": settings.signature_path,
|
||||
"gstRegistration": settings.gst_registration,
|
||||
"signatoryName": settings.signatory_name,
|
||||
"signatoryDesignation": settings.signatory_designation,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn bank_snapshot(conn: &Connection, bank_id: Option<i64>) -> Result<String, String> {
|
||||
let Some(id) = bank_id else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let bank: BankAccount = conn
|
||||
.query_row(
|
||||
"SELECT id, label, bank_name, account_name, account_no, branch, ifsc, is_default
|
||||
FROM bank_accounts WHERE id = ?1",
|
||||
params![id],
|
||||
map_bank,
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "The selected bank account no longer exists".to_string())?;
|
||||
serde_json::to_string(&bank).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Validate, price and store an invoice in one transaction. The series counter only
|
||||
/// moves if everything succeeded.
|
||||
pub fn issue_invoice_impl(
|
||||
conn: &mut Connection,
|
||||
data_dir: &Path,
|
||||
input: InvoiceInput,
|
||||
render_prefs: &serde_json::Value,
|
||||
) -> Result<Invoice, String> {
|
||||
let db = |e: rusqlite::Error| e.to_string();
|
||||
let tx = conn.transaction().map_err(db)?;
|
||||
|
||||
let settings: Settings = tx
|
||||
.query_row(
|
||||
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||
[],
|
||||
map_settings,
|
||||
)
|
||||
.map_err(db)?;
|
||||
let (series_id, prefix, padding, next_number): (i64, String, i64, i64) = tx
|
||||
.query_row(
|
||||
"SELECT id, prefix, padding, next_number FROM invoice_series
|
||||
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
||||
[],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
r.get::<_, i64>(3)?,
|
||||
))
|
||||
},
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(db)?
|
||||
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
||||
|
||||
let (series_id, prefix, padding, next_number) = series;
|
||||
validate_series_format(&prefix, padding)
|
||||
.map_err(|e| format!("The active invoice series cannot be used: {e}. Start a new series."))?;
|
||||
let number = format_number(&prefix, padding, next_number);
|
||||
if number.len() > MAX_NUMBER_LEN {
|
||||
return Err(format!(
|
||||
"Invoice number {number} is longer than {MAX_NUMBER_LEN} characters. Start a new series."
|
||||
));
|
||||
}
|
||||
|
||||
validate_supplier(&settings)?;
|
||||
parse_date("Invoice date", &input.invoice_date)?;
|
||||
if !input.due_date.trim().is_empty() {
|
||||
parse_date("Due date", &input.due_date)?;
|
||||
}
|
||||
validate_items(&input.items)?;
|
||||
|
||||
let client_gstin = match input.client_gstin.trim().to_ascii_uppercase().as_str() {
|
||||
"" | "NA" => String::new(),
|
||||
g => {
|
||||
gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?;
|
||||
g.to_string()
|
||||
}
|
||||
};
|
||||
let pos = input.place_of_supply_state_code.trim().to_string();
|
||||
if !pos.is_empty() && !gst::state_exists(&pos) {
|
||||
return Err(format!("Place of supply \"{pos}\" is not a valid GST state code"));
|
||||
}
|
||||
|
||||
let derived = gst::derive_tax_type(&settings.gst_registration, &settings.vendor_state_code, &pos);
|
||||
if input.tax_type != derived.as_str() {
|
||||
return Err(format!(
|
||||
"Tax type \"{}\" does not match \"{}\", which follows from supplier state {} and place of supply {}",
|
||||
input.tax_type,
|
||||
derived.as_str(),
|
||||
settings.vendor_state_code,
|
||||
if pos.is_empty() { &settings.vendor_state_code } else { &pos },
|
||||
));
|
||||
}
|
||||
let tax_rate = if derived == TaxType::None { 0.0 } else { input.tax_rate };
|
||||
if !tax_rate.is_finite() || !(0.0..=100.0).contains(&tax_rate) {
|
||||
return Err("Tax rate must be between 0 and 100".into());
|
||||
}
|
||||
let rate_bp = (tax_rate * 100.0).round() as i64;
|
||||
let stored_pos = if pos.is_empty() && derived != TaxType::None {
|
||||
settings.vendor_state_code.clone()
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
|
||||
let totals = gst::compute_totals(
|
||||
&input.items,
|
||||
gst::rupees_to_paise(input.discount),
|
||||
derived,
|
||||
rate_bp,
|
||||
);
|
||||
let words = gst::amount_in_words(totals.total);
|
||||
let snapshot = vendor_snapshot(&settings);
|
||||
let bank = bank_snapshot(&tx, input.bank_account_id)?;
|
||||
let signature_path = match input.signature_path.as_deref().map(str::trim) {
|
||||
Some(p) if !p.is_empty() => Some(
|
||||
relative_asset_path(data_dir, p).map_err(|e| format!("Signature image: {e}"))?,
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let prefs = serde_json::to_string(render_prefs).map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Persist the client first when the user asked to save a new one.
|
||||
let client_id = match input.client_id {
|
||||
@@ -126,65 +337,78 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
|
||||
params![
|
||||
input.client_name,
|
||||
input.client_address,
|
||||
input.client_gstin,
|
||||
input.place_of_supply_state_code,
|
||||
client_gstin,
|
||||
stored_pos,
|
||||
input.po_number,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(db)?;
|
||||
Some(tx.last_insert_rowid())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
tx.execute(
|
||||
r#"INSERT INTO invoices
|
||||
(number, series_id, invoice_date, due_date, client_id, client_name, client_address,
|
||||
client_gstin, po_number, place_of_supply_state_code, subtotal, discount, tax_type,
|
||||
tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17,
|
||||
?18, ?19, ?20, ?21, ?22, ?23, 'issued', ?24, ?24)"#,
|
||||
params![
|
||||
number,
|
||||
series_id,
|
||||
input.invoice_date,
|
||||
input.due_date,
|
||||
client_id,
|
||||
input.client_name,
|
||||
input.client_address,
|
||||
input.client_gstin,
|
||||
input.po_number,
|
||||
input.place_of_supply_state_code,
|
||||
input.subtotal,
|
||||
input.discount,
|
||||
input.tax_type,
|
||||
input.tax_rate,
|
||||
input.cgst_amount,
|
||||
input.sgst_amount,
|
||||
input.igst_amount,
|
||||
input.total,
|
||||
input.amount_in_words,
|
||||
input.bank_account_id,
|
||||
input.bank_snapshot,
|
||||
input.signature_path,
|
||||
input.notes,
|
||||
now,
|
||||
],
|
||||
bank_account_id, bank_snapshot, signature_path, notes, status, doc_type,
|
||||
reverse_charge, vendor_snapshot, render_prefs, snapshot_origin, created_at, updated_at)
|
||||
VALUES (:number, :series_id, :invoice_date, :due_date, :client_id, :client_name,
|
||||
:client_address, :client_gstin, :po_number, :pos, :subtotal, :discount,
|
||||
:tax_type, :tax_rate, :cgst, :sgst, :igst, :total, :words, :bank_id,
|
||||
:bank_snapshot, :signature_path, :notes, 'issued', :doc_type,
|
||||
:reverse_charge, :vendor_snapshot, :render_prefs, 'issued', :now, :now)"#,
|
||||
named_params! {
|
||||
":number": number,
|
||||
":series_id": series_id,
|
||||
":invoice_date": input.invoice_date.trim(),
|
||||
":due_date": input.due_date.trim(),
|
||||
":client_id": client_id,
|
||||
":client_name": input.client_name,
|
||||
":client_address": input.client_address,
|
||||
":client_gstin": client_gstin,
|
||||
":po_number": input.po_number,
|
||||
":pos": stored_pos,
|
||||
":subtotal": paise_to_rupees(totals.subtotal),
|
||||
":discount": paise_to_rupees(totals.discount),
|
||||
":tax_type": derived.as_str(),
|
||||
":tax_rate": tax_rate,
|
||||
":cgst": paise_to_rupees(totals.cgst),
|
||||
":sgst": paise_to_rupees(totals.sgst),
|
||||
":igst": paise_to_rupees(totals.igst),
|
||||
":total": paise_to_rupees(totals.total),
|
||||
":words": words,
|
||||
":bank_id": input.bank_account_id,
|
||||
":bank_snapshot": bank,
|
||||
":signature_path": signature_path,
|
||||
":notes": input.notes,
|
||||
":doc_type": gst::doc_type(&settings.gst_registration),
|
||||
":reverse_charge": input.reverse_charge,
|
||||
":vendor_snapshot": snapshot,
|
||||
":render_prefs": prefs,
|
||||
":now": now,
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("UNIQUE") {
|
||||
format!("Invoice number {number} already exists. Start a new series or fix the counter.")
|
||||
} else {
|
||||
e.to_string()
|
||||
}
|
||||
})?;
|
||||
let invoice_id = tx.last_insert_rowid();
|
||||
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare(
|
||||
"INSERT INTO invoice_items
|
||||
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
(invoice_id, description, mode, rate, unit, quantity, amount, sort_order, hsn_sac)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(db)?;
|
||||
for (i, item) in input.items.iter().enumerate() {
|
||||
stmt.execute(params![
|
||||
invoice_id,
|
||||
@@ -193,10 +417,11 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
|
||||
item.rate,
|
||||
item.unit,
|
||||
item.quantity,
|
||||
item.amount,
|
||||
paise_to_rupees(gst::line_amount_paise(item)),
|
||||
i as i64,
|
||||
item.hsn_sac.trim(),
|
||||
])
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(db)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,11 +430,55 @@ pub fn create_invoice(state: State<AppState>, input: InvoiceInput) -> Result<Inv
|
||||
"UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
|
||||
params![series_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(db)?;
|
||||
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
if let Some(draft_id) = input.draft_id {
|
||||
tx.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![draft_id])
|
||||
.map_err(db)?;
|
||||
}
|
||||
|
||||
fetch_invoice(&conn, invoice_id).map_err(|e| e.to_string())
|
||||
tx.commit().map_err(db)?;
|
||||
fetch_invoice(conn, invoice_id).map_err(db)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn issue_invoice(
|
||||
state: State<AppState>,
|
||||
input: InvoiceInput,
|
||||
render_prefs: serde_json::Value,
|
||||
) -> Result<Invoice, String> {
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
issue_invoice_impl(&mut conn, &state.data_dir, input, &render_prefs)
|
||||
}
|
||||
|
||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
||||
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let changed = conn
|
||||
.execute(
|
||||
"UPDATE invoices
|
||||
SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2, updated_at = ?1
|
||||
WHERE id = ?3 AND status = 'issued'",
|
||||
params![now, reason.trim(), id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if changed == 0 {
|
||||
let status: Option<String> = conn
|
||||
.query_row("SELECT status FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Err(match status {
|
||||
None => "Invoice not found".to_string(),
|
||||
Some(s) => format!("Only an issued invoice can be cancelled (this one is {s})"),
|
||||
});
|
||||
}
|
||||
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result<Invoice, String> {
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
cancel_invoice_impl(&mut conn, id, &reason)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -246,10 +515,458 @@ pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
||||
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn save_draft_impl(
|
||||
conn: &Connection,
|
||||
id: Option<i64>,
|
||||
payload: &serde_json::Value,
|
||||
) -> Result<i64, String> {
|
||||
let text = serde_json::to_string(payload).map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
match id {
|
||||
Some(id) => {
|
||||
let changed = conn
|
||||
.execute(
|
||||
"UPDATE invoice_drafts SET payload = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![text, now, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if changed == 0 {
|
||||
return Err("Draft not found".into());
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_drafts (payload, updated_at) VALUES (?1, ?2)",
|
||||
params![text, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_drafts_impl(conn: &Connection) -> Result<Vec<DraftSummary>, String> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, payload, updated_at FROM invoice_drafts ORDER BY updated_at DESC, id DESC")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, payload, updated_at)| {
|
||||
let json: serde_json::Value = serde_json::from_str(&payload).unwrap_or_default();
|
||||
DraftSummary {
|
||||
id,
|
||||
updated_at,
|
||||
client_name: json.get("clientName").and_then(|v| v.as_str()).map(String::from),
|
||||
total: json.get("total").and_then(|v| v.as_f64()),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_draft_impl(conn: &Connection, id: i64) -> Result<serde_json::Value, String> {
|
||||
let text: String = conn
|
||||
.query_row("SELECT payload FROM invoice_drafts WHERE id = ?1", params![id], |r| r.get(0))
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "Draft not found".to_string())?;
|
||||
serde_json::from_str(&text).map_err(|e| format!("Draft is corrupted: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
pub fn save_draft(
|
||||
state: State<AppState>,
|
||||
id: Option<i64>,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<i64, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id])
|
||||
save_draft_impl(&conn, id, &payload)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_drafts(state: State<AppState>) -> Result<Vec<DraftSummary>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
list_drafts_impl(&conn)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_draft(state: State<AppState>, id: i64) -> Result<serde_json::Value, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
get_draft_impl(&conn, id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_draft(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn registered() -> Connection {
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET gst_registration = 'regular', vendor_gstin = '27AAPFU0939F1ZV',
|
||||
vendor_state_code = '27', vendor_pan = 'AAPFU0939F', default_tax_type = 'cgst_sgst'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn input(extra: serde_json::Value) -> InvoiceInput {
|
||||
let mut base = json!({
|
||||
"invoiceDate": "2026-04-01",
|
||||
"dueDate": "2026-05-01",
|
||||
"clientName": "Client Ltd",
|
||||
"taxType": "cgst_sgst",
|
||||
"taxRate": 18.0,
|
||||
"items": [{"description": "Design", "mode": "fixed", "amount": 7310.0}],
|
||||
});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
base[k] = v.clone();
|
||||
}
|
||||
serde_json::from_value(base).unwrap()
|
||||
}
|
||||
|
||||
fn issue(conn: &mut Connection, input: InvoiceInput) -> Result<Invoice, String> {
|
||||
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
|
||||
}
|
||||
|
||||
fn next_number(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT next_number FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn invoice_count(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issues_numbered_tax_invoices_with_derived_totals() {
|
||||
let mut conn = registered();
|
||||
let year = chrono::Local::now().format("%Y");
|
||||
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||
assert_eq!(first.number, format!("INV/{year}-001"));
|
||||
assert_eq!(first.status, "issued");
|
||||
assert_eq!(first.doc_type, "tax_invoice");
|
||||
assert_eq!(first.snapshot_origin, "issued");
|
||||
assert_eq!(first.subtotal, 7310.0);
|
||||
assert_eq!(first.cgst_amount, 657.9);
|
||||
assert_eq!(first.sgst_amount, 657.9);
|
||||
assert_eq!(first.igst_amount, 0.0);
|
||||
assert_eq!(first.total, 8625.8);
|
||||
assert_eq!(
|
||||
first.amount_in_words,
|
||||
"Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only"
|
||||
);
|
||||
assert_eq!(first.place_of_supply_state_code, "27");
|
||||
assert_eq!(first.items.len(), 1);
|
||||
assert_eq!(first.items[0].amount, 7310.0);
|
||||
|
||||
let second = issue(&mut conn, input(json!({}))).unwrap();
|
||||
assert_eq!(second.number, format!("INV/{year}-002"));
|
||||
// An empty place of supply is stored as the supplier's own state.
|
||||
assert_eq!(second.place_of_supply_state_code, "27");
|
||||
assert_eq!(next_number(&conn), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inter_state_supply_is_igst_with_hsn_and_reverse_charge() {
|
||||
let mut conn = registered();
|
||||
let inv = issue(
|
||||
&mut conn,
|
||||
input(json!({
|
||||
"placeOfSupplyStateCode": "29",
|
||||
"taxType": "igst",
|
||||
"taxRate": 5.0,
|
||||
"reverseCharge": true,
|
||||
"discount": 10.0,
|
||||
"items": [
|
||||
{"description": "Hours", "mode": "rate", "rate": 1200.0, "quantity": 1.5, "unit": "hour", "hsnSac": " 998314 "},
|
||||
{"description": "Fee", "mode": "fixed", "amount": 33.33},
|
||||
],
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inv.subtotal, 1833.33);
|
||||
assert_eq!(inv.discount, 10.0);
|
||||
// (1833.33 - 10.00) = 1823.33 -> 5% = 91.1665 -> 91.17
|
||||
assert_eq!(inv.igst_amount, 91.17);
|
||||
assert_eq!(inv.cgst_amount, 0.0);
|
||||
assert_eq!(inv.total, 1914.5);
|
||||
assert!(inv.reverse_charge);
|
||||
assert_eq!(inv.items[0].hsn_sac, "998314");
|
||||
assert_eq!(inv.items[0].amount, 1800.0);
|
||||
assert_eq!(inv.items[1].hsn_sac, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tax_type_mismatch_is_rejected_and_the_counter_stays() {
|
||||
let mut conn = registered();
|
||||
let err = issue(
|
||||
&mut conn,
|
||||
input(json!({"placeOfSupplyStateCode": "29", "taxType": "cgst_sgst"})),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.starts_with("Tax type \"cgst_sgst\" does not match \"igst\""), "{err}");
|
||||
let err = issue(&mut conn, input(json!({"taxType": "none"}))).unwrap_err();
|
||||
assert!(err.contains("does not match"), "{err}");
|
||||
assert_eq!(next_number(&conn), 1);
|
||||
assert_eq!(invoice_count(&conn), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_supplier_issues_a_plain_invoice_without_tax() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV'", []).unwrap();
|
||||
let inv = issue(
|
||||
&mut conn,
|
||||
input(json!({"taxType": "none", "taxRate": 18.0, "placeOfSupplyStateCode": "07"})),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inv.doc_type, "invoice");
|
||||
assert_eq!(inv.tax_type, "none");
|
||||
assert_eq!(inv.tax_rate, 0.0);
|
||||
assert_eq!(inv.total, 7310.0);
|
||||
assert_eq!(inv.cgst_amount + inv.sgst_amount + inv.igst_amount, 0.0);
|
||||
// An unregistered supplier's snapshot must not carry a GSTIN.
|
||||
let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap();
|
||||
assert_eq!(snap["vendorGstin"], "");
|
||||
assert_eq!(snap["gstRegistration"], "unregistered");
|
||||
|
||||
let err = issue(&mut conn, input(json!({"taxType": "igst"}))).unwrap_err();
|
||||
assert!(err.contains("does not match \"none\""), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_snapshot_comes_from_settings_not_input() {
|
||||
let mut conn = registered();
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET vendor_name = 'Real Name', logo_path = 'assets/logo-1.png',
|
||||
signatory_name = 'A Signer', signatory_designation = 'Partner'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Fields the webview might still send are ignored.
|
||||
let inv = issue(
|
||||
&mut conn,
|
||||
input(json!({
|
||||
"vendorName": "Forged", "vendorSnapshot": "{\"vendorName\":\"Forged\"}",
|
||||
"total": 1.0, "subtotal": 1.0, "cgstAmount": 99.0, "amountInWords": "Free",
|
||||
"bankSnapshot": "{\"bankName\":\"Forged\"}",
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
let snap: serde_json::Value = serde_json::from_str(&inv.vendor_snapshot).unwrap();
|
||||
assert_eq!(snap["vendorName"], "Real Name");
|
||||
assert_eq!(snap["vendorGstin"], "27AAPFU0939F1ZV");
|
||||
assert_eq!(snap["vendorStateCode"], "27");
|
||||
assert_eq!(snap["logoPath"], "assets/logo-1.png");
|
||||
assert!(snap["signaturePath"].is_null());
|
||||
assert_eq!(snap["gstRegistration"], "regular");
|
||||
assert_eq!(snap["signatoryName"], "A Signer");
|
||||
assert_eq!(snap["signatoryDesignation"], "Partner");
|
||||
assert_eq!(inv.total, 8625.8);
|
||||
assert_eq!(inv.bank_snapshot, "");
|
||||
assert!(inv.amount_in_words.contains("Eight Thousand"));
|
||||
|
||||
// Later settings edits do not touch the stored snapshot.
|
||||
conn.execute("UPDATE app_settings SET vendor_name = 'Renamed'", []).unwrap();
|
||||
let again = fetch_invoice(&conn, inv.id).unwrap();
|
||||
assert_eq!(again.vendor_snapshot, inv.vendor_snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_snapshot_is_built_from_the_bank_row() {
|
||||
let mut conn = registered();
|
||||
let bank_id: i64 = conn.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0)).unwrap();
|
||||
let inv = issue(&mut conn, input(json!({"bankAccountId": bank_id}))).unwrap();
|
||||
let bank: serde_json::Value = serde_json::from_str(&inv.bank_snapshot).unwrap();
|
||||
assert_eq!(bank["bankName"], "State Bank of India");
|
||||
let err = issue(&mut conn, input(json!({"bankAccountId": 9999}))).unwrap_err();
|
||||
assert!(err.contains("bank account"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_after_validation_leaves_the_series_untouched() {
|
||||
let mut conn = registered();
|
||||
// Occupy the next number so the INSERT hits the UNIQUE constraint mid-transaction.
|
||||
let year = chrono::Local::now().format("%Y");
|
||||
conn.execute(
|
||||
"INSERT INTO invoices (number, invoice_date, created_at, updated_at)
|
||||
VALUES (?1, '2026-01-01', 'now', 'now')",
|
||||
params![format!("INV/{year}-001")],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO invoice_drafts (payload, updated_at) VALUES ('{}', 'now')", [])
|
||||
.unwrap();
|
||||
let err = issue(&mut conn, input(json!({"saveClient": true, "draftId": 1}))).unwrap_err();
|
||||
assert!(err.contains("already exists"), "{err}");
|
||||
assert_eq!(next_number(&conn), 1);
|
||||
assert_eq!(invoice_count(&conn), 1);
|
||||
// The client insert and the draft delete were rolled back too.
|
||||
let clients: i64 = conn.query_row("SELECT COUNT(*) FROM clients", [], |r| r.get(0)).unwrap();
|
||||
let drafts: i64 = conn.query_row("SELECT COUNT(*) FROM invoice_drafts", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!((clients, drafts), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_failures_do_not_consume_a_number() {
|
||||
let mut conn = registered();
|
||||
let bad = [
|
||||
(json!({"items": []}), "at least one line"),
|
||||
(json!({"invoiceDate": "01/04/2026"}), "Invoice date"),
|
||||
(json!({"dueDate": "soon"}), "Due date"),
|
||||
(
|
||||
json!({"items": [{"mode": "rate", "rate": 0.0, "quantity": 2.0}]}),
|
||||
"greater than zero",
|
||||
),
|
||||
(json!({"items": [{"mode": "fixed", "amount": -1.0}]}), "zero or more"),
|
||||
(json!({"items": [{"mode": "weird", "amount": 1.0}]}), "unknown line type"),
|
||||
(json!({"clientGstin": "29ABCDE1234F1Z5"}), "Client GSTIN"),
|
||||
(json!({"placeOfSupplyStateCode": "99"}), "not a valid GST state code"),
|
||||
(json!({"taxRate": 150.0}), "between 0 and 100"),
|
||||
(json!({"signaturePath": "../voiced.db"}), "Signature image"),
|
||||
];
|
||||
for (extra, needle) in bad {
|
||||
let err = issue(&mut conn, input(extra)).unwrap_err();
|
||||
assert!(err.contains(needle), "expected {needle:?} in {err:?}");
|
||||
}
|
||||
assert_eq!(next_number(&conn), 1);
|
||||
assert_eq!(invoice_count(&conn), 0);
|
||||
|
||||
// A valid client GSTIN, lowercase, is accepted and normalised.
|
||||
let ok = issue(&mut conn, input(json!({"clientGstin": "29aagcb7383j1z4"}))).unwrap();
|
||||
assert_eq!(ok.client_gstin, "29AAGCB7383J1Z4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplier_settings_are_validated() {
|
||||
let mut conn = registered();
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1Z5'", []).unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("check digit"));
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = ''", []).unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("needs a GSTIN"));
|
||||
conn.execute("UPDATE app_settings SET vendor_gstin = '29AAGCB7383J1Z4'", []).unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code 29"));
|
||||
conn.execute(
|
||||
"UPDATE app_settings SET vendor_gstin = '27AAPFU0939F1ZV', vendor_pan = 'ABCDE1234F'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("PAN"));
|
||||
conn.execute("UPDATE app_settings SET vendor_pan = 'AAPFU0939F', vendor_state_code = '99'", [])
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("state code"));
|
||||
assert_eq!(next_number(&conn), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_series_that_break_the_rules_are_refused() {
|
||||
let mut conn = registered();
|
||||
conn.execute("UPDATE invoice_series SET prefix = 'AP 2026 '", []).unwrap();
|
||||
let err = issue(&mut conn, input(json!({}))).unwrap_err();
|
||||
assert!(err.contains("active invoice series"), "{err}");
|
||||
conn.execute("UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLMN', padding = 3", []).unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).is_err());
|
||||
// A counter that outgrows its padding can push the number past 16 characters.
|
||||
conn.execute(
|
||||
"UPDATE invoice_series SET prefix = 'ABCDEFGHIJKLM', padding = 3, next_number = 10000",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(issue(&mut conn, input(json!({}))).unwrap_err().contains("longer than 16"));
|
||||
assert_eq!(invoice_count(&conn), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuing_deletes_the_draft() {
|
||||
let mut conn = registered();
|
||||
let draft = save_draft_impl(&conn, None, &json!({"clientName": "X"})).unwrap();
|
||||
let other = save_draft_impl(&conn, None, &json!({"clientName": "Y"})).unwrap();
|
||||
issue(&mut conn, input(json!({"draftId": draft}))).unwrap();
|
||||
assert!(get_draft_impl(&conn, draft).is_err());
|
||||
assert!(get_draft_impl(&conn, other).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_path_is_stored_relative() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
|
||||
std::fs::write(dir.path().join("assets/signature-1.png"), b"x").unwrap();
|
||||
let mut conn = registered();
|
||||
let abs = dir.path().join("assets/signature-1.png");
|
||||
let inv = issue_invoice_impl(
|
||||
&mut conn,
|
||||
dir.path(),
|
||||
input(json!({"signaturePath": abs.to_str().unwrap()})),
|
||||
&json!({"theme": "plain"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inv.signature_path.as_deref(), Some("assets/signature-1.png"));
|
||||
let prefs: String = conn
|
||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_keeps_the_number_and_only_works_once() {
|
||||
let mut conn = registered();
|
||||
let inv = issue(&mut conn, input(json!({}))).unwrap();
|
||||
let cancelled = cancel_invoice_impl(&mut conn, inv.id, " duplicate ").unwrap();
|
||||
assert_eq!(cancelled.status, "cancelled");
|
||||
assert_eq!(cancelled.number, inv.number);
|
||||
assert_eq!(cancelled.cancel_reason, "duplicate");
|
||||
assert!(cancelled.cancelled_at.is_some());
|
||||
assert_eq!(cancelled.total, inv.total);
|
||||
|
||||
let err = cancel_invoice_impl(&mut conn, inv.id, "again").unwrap_err();
|
||||
assert!(err.contains("cancelled"), "{err}");
|
||||
assert!(cancel_invoice_impl(&mut conn, 999, "").unwrap_err().contains("not found"));
|
||||
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![inv.id]).unwrap();
|
||||
assert!(cancel_invoice_impl(&mut conn, inv.id, "").is_err());
|
||||
|
||||
// The number is never reused.
|
||||
let next = issue(&mut conn, input(json!({}))).unwrap();
|
||||
assert_ne!(next.number, inv.number);
|
||||
assert_eq!(next_number(&conn), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drafts_round_trip() {
|
||||
let conn = crate::db::open_in_memory().unwrap();
|
||||
let a = save_draft_impl(&conn, None, &json!({"clientName": "Acme", "total": 1180.5})).unwrap();
|
||||
let b = save_draft_impl(&conn, None, &json!({"notes": "no client yet"})).unwrap();
|
||||
assert_ne!(a, b);
|
||||
let c = save_draft_impl(&conn, Some(a), &json!({"clientName": "Acme 2", "total": 10})).unwrap();
|
||||
assert_eq!(c, a);
|
||||
assert!(save_draft_impl(&conn, Some(999), &json!({})).is_err());
|
||||
|
||||
assert_eq!(get_draft_impl(&conn, a).unwrap()["clientName"], "Acme 2");
|
||||
let list = list_drafts_impl(&conn).unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
let acme = list.iter().find(|d| d.id == a).unwrap();
|
||||
assert_eq!(acme.client_name.as_deref(), Some("Acme 2"));
|
||||
assert_eq!(acme.total, Some(10.0));
|
||||
let blank = list.iter().find(|d| d.id == b).unwrap();
|
||||
assert_eq!((blank.client_name.clone(), blank.total), (None, None));
|
||||
|
||||
conn.execute("DELETE FROM invoice_drafts WHERE id = ?1", params![a]).unwrap();
|
||||
assert!(get_draft_impl(&conn, a).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::db::format_number;
|
||||
use crate::models::InvoiceSeries;
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Row};
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
fn map_series(row: &Row) -> rusqlite::Result<InvoiceSeries> {
|
||||
@@ -52,32 +52,140 @@ pub fn list_series(state: State<AppState>) -> Result<Vec<InvoiceSeries>, String>
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Longest invoice number GST allows (India Compliance enforces the same limit).
|
||||
pub const MAX_NUMBER_LEN: usize = 16;
|
||||
|
||||
/// A series prefix starts with a letter or digit and then uses only letters, digits,
|
||||
/// '-' and '/'; the prefix plus the number padding must fit in 16 characters.
|
||||
pub fn validate_series_format(prefix: &str, padding: i64) -> Result<(), String> {
|
||||
let mut chars = prefix.chars();
|
||||
match chars.next() {
|
||||
None => return Err("Series prefix cannot be empty".into()),
|
||||
Some(c) if !c.is_ascii_alphanumeric() => {
|
||||
return Err("Series prefix must start with a letter or digit".into())
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '/') {
|
||||
return Err(
|
||||
"Series prefix may only contain letters, digits, '-' and '/' (no spaces)".into(),
|
||||
);
|
||||
}
|
||||
let length = prefix.len() as i64 + padding.max(1);
|
||||
if length > MAX_NUMBER_LEN as i64 {
|
||||
return Err(format!(
|
||||
"Series prefix plus number padding is {length} characters; invoice numbers can be at most {MAX_NUMBER_LEN}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deactivates the current series and starts a fresh one, resetting the counter to 1.
|
||||
/// The deactivate and insert happen in one transaction so a failure never leaves the
|
||||
/// app without an active series.
|
||||
pub fn start_new_series_impl(
|
||||
conn: &mut Connection,
|
||||
prefix: &str,
|
||||
padding: i64,
|
||||
) -> Result<InvoiceSeries, String> {
|
||||
let prefix = prefix.trim();
|
||||
let padding = padding.clamp(1, 8);
|
||||
validate_series_format(prefix, padding)?;
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
tx.execute("UPDATE invoice_series SET is_active = 0", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute(
|
||||
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
||||
VALUES (?1, ?2, 1, 1, ?3)",
|
||||
params![prefix, padding, chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let id = tx.last_insert_rowid();
|
||||
let series = tx
|
||||
.query_row(
|
||||
&format!("{} WHERE id = ?1", SERIES_SELECT),
|
||||
params![id],
|
||||
map_series,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(series)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_new_series(
|
||||
state: State<AppState>,
|
||||
prefix: String,
|
||||
padding: i64,
|
||||
) -> Result<InvoiceSeries, String> {
|
||||
let prefix = prefix.trim().to_string();
|
||||
if prefix.is_empty() {
|
||||
return Err("Series prefix cannot be empty".into());
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
start_new_series_impl(&mut conn, &prefix, padding)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn conn() -> Connection {
|
||||
crate::db::open_in_memory().unwrap()
|
||||
}
|
||||
let padding = padding.clamp(1, 8);
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("UPDATE invoice_series SET is_active = 0", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_series (prefix, padding, next_number, is_active, created_at)
|
||||
VALUES (?1, ?2, 1, 1, ?3)",
|
||||
params![prefix, padding, chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let id = conn.last_insert_rowid();
|
||||
conn.query_row(
|
||||
&format!("{} WHERE id = ?1", SERIES_SELECT),
|
||||
params![id],
|
||||
map_series,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
#[test]
|
||||
fn prefix_charset_and_length_rules() {
|
||||
assert!(validate_series_format("INV/2026-", 3).is_ok());
|
||||
assert!(validate_series_format("INV", 1).is_ok());
|
||||
assert!(validate_series_format("", 3).is_err());
|
||||
assert!(validate_series_format("-AP", 3).is_err());
|
||||
assert!(validate_series_format("/AP", 3).is_err());
|
||||
assert!(validate_series_format("AP 2026", 3).is_err());
|
||||
assert!(validate_series_format("AP_26", 3).is_err());
|
||||
assert!(validate_series_format("AP.26", 3).is_err());
|
||||
assert!(validate_series_format("ÄP", 3).is_err());
|
||||
// 8 + 8 = 16 fits, 9 + 8 = 17 does not.
|
||||
assert!(validate_series_format("ABCDEFGH", 8).is_ok());
|
||||
let err = validate_series_format("ABCDEFGHI", 8).unwrap_err();
|
||||
assert!(err.contains("17") && err.contains("16"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_series_deactivates_the_old_one() {
|
||||
let mut c = conn();
|
||||
let s = start_new_series_impl(&mut c, " FY27/ ", 4).unwrap();
|
||||
assert_eq!(s.prefix, "FY27/");
|
||||
assert_eq!(s.next_invoice_number, "FY27/0001");
|
||||
let active: i64 = c
|
||||
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(active, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_series_leaves_the_active_one_alone() {
|
||||
let mut c = conn();
|
||||
let before: i64 = c
|
||||
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(start_new_series_impl(&mut c, "bad prefix", 3).is_err());
|
||||
assert!(start_new_series_impl(&mut c, "ABCDEFGHIJKLMNOP", 3).is_err());
|
||||
let after: i64 = c
|
||||
.query_row("SELECT id FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_insert_rolls_back_the_deactivation() {
|
||||
let mut c = conn();
|
||||
// A trigger forces the INSERT to fail after the UPDATE has run.
|
||||
c.execute_batch(
|
||||
"CREATE TRIGGER no_new_series BEFORE INSERT ON invoice_series
|
||||
BEGIN SELECT RAISE(ABORT, 'blocked'); END;",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(start_new_series_impl(&mut c, "FY27", 3).is_err());
|
||||
let active: i64 = c
|
||||
.query_row("SELECT COUNT(*) FROM invoice_series WHERE is_active = 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(active, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::models::{BankAccount, Settings};
|
||||
use crate::AppState;
|
||||
use rusqlite::{params, Row};
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use tauri::State;
|
||||
|
||||
const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
||||
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
||||
signatory_designation";
|
||||
|
||||
fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||
Ok(Settings {
|
||||
vendor_name: row.get(0)?,
|
||||
vendor_address: row.get(1)?,
|
||||
@@ -84,7 +84,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
||||
get_settings(state)
|
||||
}
|
||||
|
||||
fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
|
||||
pub(crate) fn map_bank(row: &Row) -> rusqlite::Result<BankAccount> {
|
||||
Ok(BankAccount {
|
||||
id: Some(row.get(0)?),
|
||||
label: row.get(1)?,
|
||||
@@ -169,10 +169,94 @@ pub fn save_bank(state: State<AppState>, bank: BankAccount) -> Result<BankAccoun
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Deletes a bank account. If it was the default, the lowest remaining account
|
||||
/// becomes the default (or the default is cleared when none is left). Issued invoices
|
||||
/// keep their own `bank_snapshot`, so they are unaffected.
|
||||
pub fn delete_bank_impl(conn: &mut Connection, id: i64) -> Result<(), String> {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
let was_default: bool = tx
|
||||
.query_row(
|
||||
"SELECT COALESCE((SELECT is_default FROM bank_accounts WHERE id = ?1), 0) <> 0
|
||||
OR COALESCE((SELECT default_bank_id FROM app_settings WHERE id = 1), 0) = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
if was_default {
|
||||
let next: Option<i64> = tx
|
||||
.query_row("SELECT MIN(id) FROM bank_accounts", [], |r| r.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("UPDATE bank_accounts SET is_default = 0", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(next) = next {
|
||||
tx.execute("UPDATE bank_accounts SET is_default = 1 WHERE id = ?1", params![next])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.execute("UPDATE app_settings SET default_bank_id = ?1 WHERE id = 1", params![next])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_bank(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM bank_accounts WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
delete_bank_impl(&mut conn, id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn add_bank(conn: &Connection, name: &str, is_default: bool) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO bank_accounts (bank_name, is_default) VALUES (?1, ?2)",
|
||||
params![name, is_default],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn default_state(conn: &Connection) -> (Option<i64>, Vec<i64>) {
|
||||
let setting = conn
|
||||
.query_row("SELECT default_bank_id FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM bank_accounts WHERE is_default = 1 ORDER BY id")
|
||||
.unwrap();
|
||||
let flagged = stmt.query_map([], |r| r.get(0)).unwrap().map(Result::unwrap).collect();
|
||||
(setting, flagged)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
// The seeded "Primary" bank is the default; add two more.
|
||||
let seeded: i64 = conn
|
||||
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let b = add_bank(&conn, "B", false);
|
||||
let c = add_bank(&conn, "C", false);
|
||||
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
|
||||
|
||||
delete_bank_impl(&mut conn, seeded).unwrap();
|
||||
assert_eq!(default_state(&conn), (Some(b), vec![b]));
|
||||
delete_bank_impl(&mut conn, b).unwrap();
|
||||
assert_eq!(default_state(&conn), (Some(c), vec![c]));
|
||||
delete_bank_impl(&mut conn, c).unwrap();
|
||||
assert_eq!(default_state(&conn), (None, vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_non_default_bank_keeps_the_default() {
|
||||
let mut conn = crate::db::open_in_memory().unwrap();
|
||||
let seeded: i64 = conn
|
||||
.query_row("SELECT id FROM bank_accounts", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let b = add_bank(&conn, "B", false);
|
||||
delete_bank_impl(&mut conn, b).unwrap();
|
||||
assert_eq!(default_state(&conn), (Some(seeded), vec![seeded]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Connection, DbError> {
|
||||
let mut conn = Connection::open_in_memory()?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
migrations().to_latest(&mut conn)?;
|
||||
seed(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// `<data_dir>/assets/x.png` -> `assets/x.png`; anything else is returned unchanged.
|
||||
/// Legacy databases stored absolute paths, which break as soon as the data dir moves.
|
||||
fn relativize(path: &str, data_dir: &Path) -> String {
|
||||
match Path::new(path).strip_prefix(data_dir.join("assets")) {
|
||||
Ok(rest) if !rest.as_os_str().is_empty() => {
|
||||
let mut out = String::from("assets");
|
||||
for part in rest.components() {
|
||||
out.push('/');
|
||||
out.push_str(&part.as_os_str().to_string_lossy());
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite absolute asset paths under `<data_dir>/assets/` to relative ones in
|
||||
/// settings, invoices and the invoice vendor snapshots. Safe to run on every start.
|
||||
pub fn relativize_asset_paths(conn: &Connection, data_dir: &Path) -> rusqlite::Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
for (table, column, pk) in [
|
||||
("app_settings", "logo_path", "id"),
|
||||
("app_settings", "signature_path", "id"),
|
||||
("invoices", "signature_path", "id"),
|
||||
] {
|
||||
let rows: Vec<(i64, String)> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"SELECT {pk}, {column} FROM {table} WHERE {column} IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
for (id, path) in rows {
|
||||
let rel = relativize(&path, data_dir);
|
||||
if rel != path {
|
||||
tx.execute(
|
||||
&format!("UPDATE {table} SET {column} = ?1 WHERE {pk} = ?2"),
|
||||
params![rel, id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snapshots: Vec<(i64, String)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT id, vendor_snapshot FROM invoices WHERE vendor_snapshot IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
for (id, text) in snapshots {
|
||||
let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&text) else {
|
||||
continue;
|
||||
};
|
||||
let mut changed = false;
|
||||
for key in ["logoPath", "signaturePath"] {
|
||||
if let Some(path) = json.get(key).and_then(|v| v.as_str()) {
|
||||
let rel = relativize(path, data_dir);
|
||||
if rel != path {
|
||||
json[key] = serde_json::Value::String(rel);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
tx.execute(
|
||||
"UPDATE invoices SET vendor_snapshot = ?1 WHERE id = ?2",
|
||||
params![json.to_string(), id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
/// Format an invoice number from a series definition, e.g. "INV/2026-" + 1 -> "INV/2026-001".
|
||||
pub fn format_number(prefix: &str, padding: i64, number: i64) -> String {
|
||||
format!("{}{:0width$}", prefix, number, width = padding.max(1) as usize)
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
//! GST rules: state codes, tax-type derivation, GSTIN validation, integer-paise
|
||||
//! totals and amount in words. Everything here is a pure function.
|
||||
|
||||
use crate::models::InvoiceItem;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// GST state codes, copied from India Compliance (`STATE_NUMBERS` in
|
||||
/// `india_compliance/gst_india/constants/__init__.py`), including 96 and 97.
|
||||
pub const STATES: &[(&str, &str)] = &[
|
||||
("01", "Jammu and Kashmir"),
|
||||
("02", "Himachal Pradesh"),
|
||||
("03", "Punjab"),
|
||||
("04", "Chandigarh"),
|
||||
("05", "Uttarakhand"),
|
||||
("06", "Haryana"),
|
||||
("07", "Delhi"),
|
||||
("08", "Rajasthan"),
|
||||
("09", "Uttar Pradesh"),
|
||||
("10", "Bihar"),
|
||||
("11", "Sikkim"),
|
||||
("12", "Sampleachal Pradesh"),
|
||||
("13", "Nagaland"),
|
||||
("14", "Manipur"),
|
||||
("15", "Mizoram"),
|
||||
("16", "Tripura"),
|
||||
("17", "Meghalaya"),
|
||||
("18", "Assam"),
|
||||
("19", "West Bengal"),
|
||||
("20", "Jharkhand"),
|
||||
("21", "Odisha"),
|
||||
("22", "Chhattisgarh"),
|
||||
("23", "Madhya Pradesh"),
|
||||
("24", "Gujarat"),
|
||||
("26", "Dadra and Nagar Haveli and Daman and Diu"),
|
||||
("27", "Maharashtra"),
|
||||
("29", "Karnataka"),
|
||||
("30", "Goa"),
|
||||
("31", "Lakshadweep Islands"),
|
||||
("32", "Kerala"),
|
||||
("33", "Tamil Nadu"),
|
||||
("34", "Puducherry"),
|
||||
("35", "Andaman and Nicobar Islands"),
|
||||
("36", "Telangana"),
|
||||
("37", "Andhra Pradesh"),
|
||||
("38", "Ladakh"),
|
||||
("96", "Other Countries"),
|
||||
("97", "Other Territory"),
|
||||
];
|
||||
|
||||
pub fn state_exists(code: &str) -> bool {
|
||||
STATES.iter().any(|(c, _)| *c == code)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxType {
|
||||
None,
|
||||
CgstSgst,
|
||||
Igst,
|
||||
}
|
||||
|
||||
impl TaxType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TaxType::None => "none",
|
||||
TaxType::CgstSgst => "cgst_sgst",
|
||||
TaxType::Igst => "igst",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An unregistered supplier cannot charge GST. Otherwise the tax head follows the
|
||||
/// place of supply, which defaults to the supplier's own state when left empty.
|
||||
pub fn derive_tax_type(registration: &str, supplier_state: &str, pos_state: &str) -> TaxType {
|
||||
if registration == "unregistered" {
|
||||
return TaxType::None;
|
||||
}
|
||||
let pos = if pos_state.trim().is_empty() { supplier_state } else { pos_state };
|
||||
if pos == supplier_state {
|
||||
TaxType::CgstSgst
|
||||
} else {
|
||||
TaxType::Igst
|
||||
}
|
||||
}
|
||||
|
||||
/// Union territories without a legislature: Chandigarh, Dadra & Nagar Haveli and
|
||||
/// Daman & Diu, Lakshadweep, Andaman & Nicobar Islands, Ladakh.
|
||||
#[cfg_attr(not(test), allow(dead_code))] // used through `second_head_label`, which the PDF layer will call
|
||||
pub fn is_ut_without_legislature(code: &str) -> bool {
|
||||
matches!(code, "04" | "26" | "31" | "35" | "38")
|
||||
}
|
||||
|
||||
/// UTGST replaces SGST only when the supplier is in one of those territories.
|
||||
#[cfg_attr(not(test), allow(dead_code))] // consumed by the PDF layer in a later step
|
||||
pub fn second_head_label(supplier_state: &str) -> &'static str {
|
||||
if is_ut_without_legislature(supplier_state) {
|
||||
"UTGST"
|
||||
} else {
|
||||
"SGST"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn doc_type(registration: &str) -> &'static str {
|
||||
if registration == "unregistered" {
|
||||
"invoice"
|
||||
} else {
|
||||
"tax_invoice"
|
||||
}
|
||||
}
|
||||
|
||||
const GSTIN_CHARS: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
fn gstin_shape_ok(b: &[u8]) -> bool {
|
||||
b.len() == 15
|
||||
&& b[0..2].iter().all(u8::is_ascii_digit)
|
||||
&& b[2..7].iter().all(u8::is_ascii_uppercase)
|
||||
&& b[7..11].iter().all(u8::is_ascii_digit)
|
||||
&& b[11].is_ascii_uppercase()
|
||||
&& (b[12].is_ascii_uppercase() || (b'1'..=b'9').contains(&b[12]))
|
||||
&& b[13] == b'Z'
|
||||
&& (b[14].is_ascii_digit() || b[14].is_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Standard GSTIN mod-36 check character over the first 14 characters.
|
||||
fn gstin_check_char(first14: &[u8]) -> u8 {
|
||||
let sum: usize = first14
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, ch)| {
|
||||
let value = GSTIN_CHARS.iter().position(|c| c == ch).unwrap_or(0);
|
||||
let product = value * if i % 2 == 0 { 1 } else { 2 };
|
||||
product / 36 + product % 36
|
||||
})
|
||||
.sum();
|
||||
GSTIN_CHARS[(36 - sum % 36) % 36]
|
||||
}
|
||||
|
||||
pub fn validate_gstin(g: &str) -> Result<(), String> {
|
||||
let g = g.trim().to_ascii_uppercase();
|
||||
if g.len() != 15 || !g.is_ascii() {
|
||||
return Err("GSTIN must be 15 characters".into());
|
||||
}
|
||||
let b = g.as_bytes();
|
||||
if !gstin_shape_ok(b) {
|
||||
return Err("GSTIN format is invalid".into());
|
||||
}
|
||||
if !state_exists(&g[0..2]) {
|
||||
return Err(format!("GSTIN has an unknown state code {}", &g[0..2]));
|
||||
}
|
||||
let expected = gstin_check_char(&b[0..14]);
|
||||
if b[14] != expected {
|
||||
return Err(format!(
|
||||
"GSTIN check digit is wrong (expected {})",
|
||||
expected as char
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Characters 3 to 12 of a GSTIN are the holder's PAN.
|
||||
pub fn gstin_matches_pan(gstin: &str, pan: &str) -> bool {
|
||||
let gstin = gstin.trim().to_ascii_uppercase();
|
||||
let pan = pan.trim().to_ascii_uppercase();
|
||||
gstin.len() == 15 && gstin.is_ascii() && pan.len() == 10 && gstin[2..12] == pan
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Totals {
|
||||
pub subtotal: i64,
|
||||
pub discount: i64,
|
||||
pub taxable: i64,
|
||||
pub cgst: i64,
|
||||
pub sgst: i64,
|
||||
pub igst: i64,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
pub fn rupees_to_paise(rupees: f64) -> i64 {
|
||||
(rupees * 100.0).round() as i64
|
||||
}
|
||||
|
||||
/// Round-half-up integer division for non-negative operands.
|
||||
fn div_round(num: i128, den: i128) -> i64 {
|
||||
((num + den / 2) / den) as i64
|
||||
}
|
||||
|
||||
/// Amount of a single line in paise. Rate lines multiply the rounded rate by the
|
||||
/// (possibly fractional) quantity and round half up once.
|
||||
pub fn line_amount_paise(item: &InvoiceItem) -> i64 {
|
||||
if item.mode == "rate" {
|
||||
let rate = rupees_to_paise(item.rate);
|
||||
(rate as f64 * item.quantity).round() as i64
|
||||
} else {
|
||||
rupees_to_paise(item.amount)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_totals(
|
||||
items: &[InvoiceItem],
|
||||
discount_paise: i64,
|
||||
tax_type: TaxType,
|
||||
rate_bp: i64,
|
||||
) -> Totals {
|
||||
let subtotal: i64 = items.iter().map(line_amount_paise).sum();
|
||||
let discount = discount_paise.clamp(0, subtotal.max(0));
|
||||
let taxable = subtotal - discount;
|
||||
let (mut cgst, mut sgst, mut igst) = (0, 0, 0);
|
||||
match tax_type {
|
||||
TaxType::CgstSgst => {
|
||||
cgst = div_round(taxable as i128 * rate_bp as i128, 20_000);
|
||||
sgst = cgst;
|
||||
}
|
||||
TaxType::Igst => igst = div_round(taxable as i128 * rate_bp as i128, 10_000),
|
||||
TaxType::None => {}
|
||||
}
|
||||
Totals {
|
||||
subtotal,
|
||||
discount,
|
||||
taxable,
|
||||
cgst,
|
||||
sgst,
|
||||
igst,
|
||||
total: taxable + cgst + sgst + igst,
|
||||
}
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven",
|
||||
"Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen",
|
||||
];
|
||||
|
||||
const TENS: [&str; 10] = [
|
||||
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety",
|
||||
];
|
||||
|
||||
fn two_digits(n: u64) -> String {
|
||||
if n < 20 {
|
||||
return ONES[n as usize].to_string();
|
||||
}
|
||||
let ones = n % 10;
|
||||
let mut out = TENS[(n / 10) as usize].to_string();
|
||||
if ones > 0 {
|
||||
out.push(' ');
|
||||
out.push_str(ONES[ones as usize]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn three_digits(n: u64) -> String {
|
||||
let hundreds = n / 100;
|
||||
let rest = n % 100;
|
||||
let mut out = String::new();
|
||||
if hundreds > 0 {
|
||||
out.push_str(ONES[hundreds as usize]);
|
||||
out.push_str(" Hundred");
|
||||
}
|
||||
if rest > 0 {
|
||||
if !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(&two_digits(rest));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whole number to words in the Indian system (thousand, lakh, crore).
|
||||
fn whole_to_words(n: u64) -> String {
|
||||
if n == 0 {
|
||||
return "Zero".to_string();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let crore = n / 10_000_000;
|
||||
let mut rest = n % 10_000_000;
|
||||
if crore > 0 {
|
||||
parts.push(format!("{} Crore", whole_to_words(crore)));
|
||||
}
|
||||
let lakh = rest / 100_000;
|
||||
rest %= 100_000;
|
||||
if lakh > 0 {
|
||||
parts.push(format!("{} Lakh", two_digits(lakh)));
|
||||
}
|
||||
let thousand = rest / 1000;
|
||||
rest %= 1000;
|
||||
if thousand > 0 {
|
||||
parts.push(format!("{} Thousand", two_digits(thousand)));
|
||||
}
|
||||
if rest > 0 {
|
||||
parts.push(three_digits(rest));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// Same output as `amountInWords` in `src/lib/numberToWords.ts`,
|
||||
/// e.g. 2_500_000 paise -> "Indian Rupees Twenty Five Thousand Only".
|
||||
pub fn amount_in_words(paise: i64) -> String {
|
||||
let value = paise.unsigned_abs();
|
||||
let rupees = value / 100;
|
||||
let paise = value % 100;
|
||||
let mut out = format!("Indian Rupees {}", whole_to_words(rupees));
|
||||
if paise > 0 {
|
||||
out.push_str(&format!(" and {} Paise", two_digits(paise)));
|
||||
}
|
||||
out.push_str(" Only");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rate_item(rate: f64, quantity: f64) -> InvoiceItem {
|
||||
InvoiceItem {
|
||||
id: None,
|
||||
description: String::new(),
|
||||
mode: "rate".into(),
|
||||
rate,
|
||||
unit: "hour".into(),
|
||||
quantity,
|
||||
amount: 0.0,
|
||||
sort_order: 0,
|
||||
hsn_sac: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_item(amount: f64) -> InvoiceItem {
|
||||
InvoiceItem {
|
||||
mode: "fixed".into(),
|
||||
amount,
|
||||
..rate_item(0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_list_has_special_codes_and_unique_codes() {
|
||||
assert_eq!(STATES.len(), 38);
|
||||
assert!(STATES.contains(&("96", "Other Countries")));
|
||||
assert!(STATES.contains(&("97", "Other Territory")));
|
||||
assert!(STATES.contains(&("29", "Karnataka")));
|
||||
let mut codes: Vec<_> = STATES.iter().map(|(c, _)| *c).collect();
|
||||
codes.dedup();
|
||||
assert_eq!(codes.len(), STATES.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tax_type_derivation() {
|
||||
assert_eq!(derive_tax_type("unregistered", "29", "27"), TaxType::None);
|
||||
assert_eq!(derive_tax_type("unregistered", "29", ""), TaxType::None);
|
||||
assert_eq!(derive_tax_type("regular", "29", "29"), TaxType::CgstSgst);
|
||||
assert_eq!(derive_tax_type("regular", "29", ""), TaxType::CgstSgst);
|
||||
assert_eq!(derive_tax_type("regular", "29", "27"), TaxType::Igst);
|
||||
assert_eq!(derive_tax_type("regular", "29", "96"), TaxType::Igst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tax_type_serde_strings() {
|
||||
assert_eq!(serde_json::to_string(&TaxType::CgstSgst).unwrap(), "\"cgst_sgst\"");
|
||||
assert_eq!(serde_json::from_str::<TaxType>("\"igst\"").unwrap(), TaxType::Igst);
|
||||
assert_eq!(serde_json::from_str::<TaxType>("\"none\"").unwrap(), TaxType::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_territory_labels() {
|
||||
for code in ["04", "26", "31", "35", "38"] {
|
||||
assert!(is_ut_without_legislature(code));
|
||||
assert_eq!(second_head_label(code), "UTGST");
|
||||
}
|
||||
for code in ["07", "29", "34", "01", "96"] {
|
||||
assert!(!is_ut_without_legislature(code));
|
||||
assert_eq!(second_head_label(code), "SGST");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_type() {
|
||||
assert_eq!(doc_type("unregistered"), "invoice");
|
||||
assert_eq!(doc_type("regular"), "tax_invoice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gstin_checksum() {
|
||||
assert_eq!(validate_gstin("27AAPFU0939F1ZV"), Ok(()));
|
||||
assert_eq!(validate_gstin("07AAGFF2194N1Z1"), Ok(()));
|
||||
assert_eq!(validate_gstin("29AAGCB7383J1Z4"), Ok(()));
|
||||
assert_eq!(validate_gstin("29abcde1234f1zw"), Ok(()));
|
||||
// The algorithm gives W for this body, so a trailing 5 is a typo.
|
||||
assert_eq!(validate_gstin("29ABCDE1234F1ZW"), Ok(()));
|
||||
assert!(validate_gstin("29ABCDE1234F1Z5").unwrap_err().contains("check digit"));
|
||||
assert!(validate_gstin("27AAPFU0939F1ZX").unwrap_err().contains("check digit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gstin_rejects_bad_shapes() {
|
||||
assert!(validate_gstin("").unwrap_err().contains("15 characters"));
|
||||
assert!(validate_gstin("27AAPFU0939F1Z").unwrap_err().contains("15 characters"));
|
||||
assert!(validate_gstin("27AAPFU0939F1ZVV").unwrap_err().contains("15 characters"));
|
||||
assert!(validate_gstin("2XAAPFU0939F1ZV").unwrap_err().contains("format"));
|
||||
assert!(validate_gstin("27AAPFU0939F1AV").unwrap_err().contains("format"));
|
||||
// Valid shape and checksum but state 99 does not exist.
|
||||
let body = b"99AAPFU0939F1Z";
|
||||
let mut g = String::from_utf8(body.to_vec()).unwrap();
|
||||
g.push(gstin_check_char(body) as char);
|
||||
assert!(validate_gstin(&g).unwrap_err().contains("state code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_cross_check() {
|
||||
assert!(gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939F"));
|
||||
assert!(gstin_matches_pan("27AAPFU0939F1ZV", " aapfu0939f "));
|
||||
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", "AAPFU0939G"));
|
||||
assert!(!gstin_matches_pan("27AAPFU0939F1ZV", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_cgst_sgst_18_percent() {
|
||||
let t = compute_totals(&[fixed_item(7310.0)], 0, TaxType::CgstSgst, 1800);
|
||||
assert_eq!(t.subtotal, 731_000);
|
||||
assert_eq!(t.cgst, 65_790);
|
||||
assert_eq!(t.sgst, 65_790);
|
||||
assert_eq!(t.igst, 0);
|
||||
assert_eq!(t.total, 731_000 + 131_580);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_igst_5_percent_rounds_half_up() {
|
||||
// 33.33 x 5% = 1.6665 -> 1.67; split in two heads it would be 0.83 + 0.83.
|
||||
let igst = compute_totals(&[fixed_item(33.33)], 0, TaxType::Igst, 500);
|
||||
assert_eq!(igst.igst, 167);
|
||||
assert_eq!(igst.total, 3333 + 167);
|
||||
let split = compute_totals(&[fixed_item(33.33)], 0, TaxType::CgstSgst, 500);
|
||||
assert_eq!((split.cgst, split.sgst), (83, 83));
|
||||
assert_eq!(split.total, 3333 + 166);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_fractional_hours() {
|
||||
let t = compute_totals(&[rate_item(1200.0, 1.5)], 0, TaxType::None, 0);
|
||||
assert_eq!(t.subtotal, 180_000);
|
||||
assert_eq!(t.total, 180_000);
|
||||
// 99.99 x 2.5 = 249.975 -> 249.98
|
||||
assert_eq!(line_amount_paise(&rate_item(99.99, 2.5)), 24_998);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_discount_is_clamped() {
|
||||
let items = [fixed_item(100.0), fixed_item(50.5)];
|
||||
let over = compute_totals(&items, 99_999, TaxType::Igst, 1800);
|
||||
assert_eq!(over.discount, 15_050);
|
||||
assert_eq!(over.taxable, 0);
|
||||
assert_eq!(over.total, 0);
|
||||
let negative = compute_totals(&items, -500, TaxType::None, 0);
|
||||
assert_eq!(negative.discount, 0);
|
||||
assert_eq!(negative.total, 15_050);
|
||||
let partial = compute_totals(&items, 5050, TaxType::None, 0);
|
||||
assert_eq!(partial.taxable, 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totals_with_no_tax_ignore_the_rate() {
|
||||
let t = compute_totals(&[fixed_item(1000.0)], 0, TaxType::None, 1800);
|
||||
assert_eq!((t.cgst, t.sgst, t.igst), (0, 0, 0));
|
||||
assert_eq!(t.total, 100_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_match_the_typescript_implementation() {
|
||||
// Expected strings were produced by running src/lib/numberToWords.ts.
|
||||
let cases: &[(i64, &str)] = &[
|
||||
(0, "Indian Rupees Zero Only"),
|
||||
(1, "Indian Rupees Zero and One Paise Only"),
|
||||
(100, "Indian Rupees One Only"),
|
||||
(1950, "Indian Rupees Nineteen and Fifty Paise Only"),
|
||||
(10_000, "Indian Rupees One Hundred Only"),
|
||||
(10_101, "Indian Rupees One Hundred One and One Paise Only"),
|
||||
(
|
||||
99_999,
|
||||
"Indian Rupees Nine Hundred Ninety Nine and Ninety Nine Paise Only",
|
||||
),
|
||||
(100_000, "Indian Rupees One Thousand Only"),
|
||||
(
|
||||
1_234_567,
|
||||
"Indian Rupees Twelve Thousand Three Hundred Forty Five and Sixty Seven Paise Only",
|
||||
),
|
||||
(10_000_000, "Indian Rupees One Lakh Only"),
|
||||
(10_000_005, "Indian Rupees One Lakh and Five Paise Only"),
|
||||
(
|
||||
123_456_789,
|
||||
"Indian Rupees Twelve Lakh Thirty Four Thousand Five Hundred Sixty Seven and Eighty Nine Paise Only",
|
||||
),
|
||||
(1_000_000_000, "Indian Rupees One Crore Only"),
|
||||
(1210, "Indian Rupees Twelve and Ten Paise Only"),
|
||||
(65_790, "Indian Rupees Six Hundred Fifty Seven and Ninety Paise Only"),
|
||||
(
|
||||
8_625_820,
|
||||
"Indian Rupees Eighty Six Thousand Two Hundred Fifty Eight and Twenty Paise Only",
|
||||
),
|
||||
(
|
||||
99_999_999_999,
|
||||
"Indian Rupees Ninety Nine Crore Ninety Nine Lakh Ninety Nine Thousand Nine Hundred Ninety Nine and Ninety Nine Paise Only",
|
||||
),
|
||||
(250_000_000_000, "Indian Rupees Two Hundred Fifty Crore Only"),
|
||||
];
|
||||
for (paise, expected) in cases {
|
||||
assert_eq!(amount_in_words(*paise), *expected, "paise {paise}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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<AppState, Box<dyn std::error::Error>>
|
||||
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,
|
||||
|
||||
+26
-13
@@ -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<i64>,
|
||||
#[serde(default)]
|
||||
pub bank_snapshot: String,
|
||||
#[serde(default)]
|
||||
pub signature_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
#[serde(default)]
|
||||
pub save_client: bool,
|
||||
#[serde(default)]
|
||||
pub draft_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<InvoiceItem>,
|
||||
}
|
||||
|
||||
@@ -187,9 +182,18 @@ pub struct Invoice {
|
||||
pub bank_snapshot: String,
|
||||
pub signature_path: Option<String>,
|
||||
pub notes: String,
|
||||
/// draft | issued | cancelled
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub doc_type: String,
|
||||
pub reverse_charge: bool,
|
||||
/// JSON of the supplier details as they were when the invoice was issued; empty if unknown.
|
||||
pub vendor_snapshot: String,
|
||||
pub snapshot_origin: String,
|
||||
pub cancelled_at: Option<String>,
|
||||
pub cancel_reason: String,
|
||||
pub archived_pdf_sha256: Option<String>,
|
||||
pub items: Vec<InvoiceItem>,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub total: Option<f64>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user