1251 lines
46 KiB
Rust
1251 lines
46 KiB
Rust
use super::assets::relative_asset_path;
|
|
use super::payments::{derive_summary, summary_for, today_ist};
|
|
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::gst::{self, TaxType};
|
|
use crate::models::{
|
|
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary,
|
|
Settings,
|
|
};
|
|
use crate::AppState;
|
|
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,
|
|
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
|
cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, ''), archived_fingerprint, archived_at";
|
|
|
|
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|
Ok(Invoice {
|
|
id: row.get(0)?,
|
|
number: row.get(1)?,
|
|
series_id: row.get(2)?,
|
|
invoice_date: row.get(3)?,
|
|
due_date: row.get(4)?,
|
|
client_id: row.get(5)?,
|
|
client_name: row.get(6)?,
|
|
client_address: row.get(7)?,
|
|
client_gstin: row.get(8)?,
|
|
po_number: row.get(9)?,
|
|
place_of_supply_state_code: row.get(10)?,
|
|
subtotal: row.get(11)?,
|
|
discount: row.get(12)?,
|
|
tax_type: row.get(13)?,
|
|
tax_rate: row.get(14)?,
|
|
cgst_amount: row.get(15)?,
|
|
sgst_amount: row.get(16)?,
|
|
igst_amount: row.get(17)?,
|
|
total: row.get(18)?,
|
|
amount_in_words: row.get(19)?,
|
|
bank_account_id: row.get(20)?,
|
|
bank_snapshot: row.get(21)?,
|
|
signature_path: row.get(22)?,
|
|
notes: row.get(23)?,
|
|
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)?,
|
|
render_prefs: row.get(34)?,
|
|
archived_fingerprint: row.get(35)?,
|
|
archived_at: row.get(36)?,
|
|
items: Vec::new(),
|
|
payment: PaymentSummary::default(),
|
|
total_paise: 0,
|
|
})
|
|
}
|
|
|
|
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, 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| {
|
|
Ok(InvoiceItem {
|
|
id: Some(row.get(0)?),
|
|
description: row.get(1)?,
|
|
mode: row.get(2)?,
|
|
rate: row.get(3)?,
|
|
unit: row.get(4)?,
|
|
quantity: row.get(5)?,
|
|
amount: row.get(6)?,
|
|
sort_order: row.get(7)?,
|
|
hsn_sac: row.get(8)?,
|
|
})
|
|
})?;
|
|
rows.collect()
|
|
}
|
|
|
|
fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
|
|
let mut invoice = conn.query_row(
|
|
&format!("SELECT {} FROM invoices WHERE id = ?1", INVOICE_COLS),
|
|
params![id],
|
|
map_invoice,
|
|
)?;
|
|
invoice.items = fetch_items(conn, id)?;
|
|
invoice.total_paise = gst::rupees_to_paise(invoice.total);
|
|
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
|
|
Ok(invoice)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn peek_next_invoice_number(state: State<AppState>) -> Result<String, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let series: Option<(String, i64, i64)> = conn
|
|
.query_row(
|
|
"SELECT prefix, padding, next_number FROM invoice_series
|
|
WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
|
|
[],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
)
|
|
.optional()
|
|
.map_err(|e| e.to_string())?;
|
|
match series {
|
|
Some((prefix, padding, next)) => Ok(format_number(&prefix, padding, next)),
|
|
None => Err("No active invoice series. Create one under Series.".into()),
|
|
}
|
|
}
|
|
|
|
fn paise_to_rupees(paise: i64) -> f64 {
|
|
paise as f64 / 100.0
|
|
}
|
|
|
|
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,
|
|
"logoKnockoutPath": settings.logo_knockout_path,
|
|
"logoMeta": settings.logo_meta,
|
|
"logoIncludesName": settings.logo_includes_name,
|
|
"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(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
|
)
|
|
.optional()
|
|
.map_err(db)?
|
|
.ok_or_else(|| "No active invoice series. Create one under Series.".to_string())?;
|
|
|
|
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 {
|
|
Some(id) => Some(id),
|
|
None if input.save_client && !input.client_name.trim().is_empty() => {
|
|
tx.execute(
|
|
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
params![
|
|
input.client_name,
|
|
input.client_address,
|
|
client_gstin,
|
|
stored_pos,
|
|
input.po_number,
|
|
now,
|
|
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
|
|
],
|
|
)
|
|
.map_err(db)?;
|
|
Some(tx.last_insert_rowid())
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
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, 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| {
|
|
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, hsn_sac)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
)
|
|
.map_err(db)?;
|
|
for (i, item) in input.items.iter().enumerate() {
|
|
stmt.execute(params![
|
|
invoice_id,
|
|
item.description,
|
|
item.mode,
|
|
item.rate,
|
|
item.unit,
|
|
item.quantity,
|
|
paise_to_rupees(gst::line_amount_paise(item)),
|
|
i as i64,
|
|
item.hsn_sac.trim(),
|
|
])
|
|
.map_err(db)?;
|
|
}
|
|
}
|
|
|
|
// Advance the serial for the next invoice in this series.
|
|
tx.execute(
|
|
"UPDATE invoice_series SET next_number = next_number + 1 WHERE id = ?1",
|
|
params![series_id],
|
|
)
|
|
.map_err(db)?;
|
|
|
|
if let Some(draft_id) = input.draft_id {
|
|
tx.execute(
|
|
"DELETE FROM invoice_drafts WHERE id = ?1",
|
|
params![draft_id],
|
|
)
|
|
.map_err(db)?;
|
|
}
|
|
|
|
tx.commit().map_err(db)?;
|
|
fetch_invoice(conn, invoice_id).map_err(db)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn issue_invoice(
|
|
state: State<AppState>,
|
|
input: InvoiceInput,
|
|
render_prefs: serde_json::Value,
|
|
) -> Result<Invoice, String> {
|
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
issue_invoice_impl(&mut conn, &state.data_dir, input, &render_prefs)
|
|
}
|
|
|
|
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
|
pub fn cancel_invoice_impl(
|
|
conn: &mut Connection,
|
|
id: i64,
|
|
reason: &str,
|
|
) -> Result<Invoice, String> {
|
|
let payments: i64 = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM payments WHERE invoice_id = ?1",
|
|
params![id],
|
|
|r| r.get(0),
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
if payments > 0 {
|
|
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
|
}
|
|
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]
|
|
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
list_invoices_impl(&conn)
|
|
}
|
|
|
|
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
|
|
/// (`derive_summary`) so list and detail agree.
|
|
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
|
|
let today = today_ist();
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
|
|
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
|
|
FROM invoices i
|
|
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
|
|
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
|
|
ORDER BY i.id DESC",
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
let total: f64 = row.get(5)?;
|
|
let status: String = row.get(6)?;
|
|
let due_date: String = row.get(3)?;
|
|
let total_paise = gst::rupees_to_paise(total);
|
|
let payment = derive_summary(
|
|
&status,
|
|
total_paise,
|
|
row.get(9)?,
|
|
row.get(10)?,
|
|
&due_date,
|
|
&today,
|
|
);
|
|
Ok(InvoiceSummary {
|
|
id: row.get(0)?,
|
|
number: row.get(1)?,
|
|
invoice_date: row.get(2)?,
|
|
due_date,
|
|
client_name: row.get(4)?,
|
|
total,
|
|
status,
|
|
created_at: row.get(7)?,
|
|
po_number: row.get(8)?,
|
|
total_paise,
|
|
payment,
|
|
})
|
|
})
|
|
.map_err(|e| e.to_string())?
|
|
.collect::<rusqlite::Result<Vec<_>>>()
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
|
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct InvoiceLedgerRow {
|
|
pub id: i64,
|
|
pub client_gstin: String,
|
|
pub place_of_supply_code: String,
|
|
pub doc_type: String,
|
|
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
|
pub hsn_sac: Vec<String>,
|
|
/// Subtotal less discount.
|
|
pub taxable_paise: i64,
|
|
pub cgst_paise: i64,
|
|
pub sgst_paise: i64,
|
|
pub igst_paise: i64,
|
|
pub reverse_charge: bool,
|
|
}
|
|
|
|
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
|
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> =
|
|
std::collections::HashMap::new();
|
|
{
|
|
let mut stmt = conn
|
|
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
|
.map_err(|e| e.to_string())?;
|
|
let items = stmt
|
|
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
|
.map_err(|e| e.to_string())?;
|
|
for item in items {
|
|
let (id, code) = item.map_err(|e| e.to_string())?;
|
|
let list = hsn_by_invoice.entry(id).or_default();
|
|
if !code.is_empty() && !list.contains(&code) {
|
|
list.push(code);
|
|
}
|
|
}
|
|
}
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
|
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
|
FROM invoices ORDER BY id DESC",
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = stmt
|
|
.query_map([], |r| {
|
|
let id: i64 = r.get(0)?;
|
|
Ok(InvoiceLedgerRow {
|
|
id,
|
|
client_gstin: r.get(1)?,
|
|
place_of_supply_code: r.get(2)?,
|
|
doc_type: r.get(3)?,
|
|
hsn_sac: Vec::new(),
|
|
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
|
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
|
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
|
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
|
reverse_charge: r.get::<_, i64>(9)? != 0,
|
|
})
|
|
})
|
|
.map_err(|e| e.to_string())?
|
|
.collect::<rusqlite::Result<Vec<_>>>()
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|mut row| {
|
|
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
|
row
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
list_invoice_ledger_impl(&conn)
|
|
}
|
|
|
|
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
|
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
get_invoice_impl(&conn, id)
|
|
}
|
|
|
|
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 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())?;
|
|
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 ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
|
let mut conn = registered();
|
|
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
|
conn.execute(
|
|
"UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1",
|
|
params![first.id],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
|
params![first.id],
|
|
)
|
|
.unwrap();
|
|
let ledger = list_invoice_ledger_impl(&conn).unwrap();
|
|
assert_eq!(ledger.len(), 1);
|
|
let row = &ledger[0];
|
|
assert_eq!(row.id, first.id);
|
|
assert_eq!(row.place_of_supply_code, "27");
|
|
assert_eq!(row.doc_type, "tax_invoice");
|
|
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
|
assert_eq!(row.taxable_paise, 731_000);
|
|
assert_eq!(
|
|
(row.cgst_paise, row.sgst_paise, row.igst_paise),
|
|
(65_790, 65_790, 0)
|
|
);
|
|
assert!(!row.reverse_charge);
|
|
}
|
|
|
|
#[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',
|
|
logo_knockout_path = 'assets/logo-1-knockout.png', logo_meta = '{\"kind\":\"wordmark\"}',
|
|
logo_includes_name = 'yes', 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_eq!(snap["logoKnockoutPath"], "assets/logo-1-knockout.png");
|
|
assert_eq!(snap["logoMeta"], "{\"kind\":\"wordmark\"}");
|
|
assert_eq!(snap["logoIncludesName"], "yes");
|
|
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"], "Test Bank");
|
|
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\"}");
|
|
assert_eq!(inv.render_prefs, prefs);
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|