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,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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user