use chrono::{Datelike, Duration, NaiveDate};
use rust_decimal::Decimal;
use std::path::PathBuf;
use std::str::FromStr;
use crate::cli::{InvoiceCmd, InvoiceItemCmd};
use crate::db::{self, Invoice, InvoiceItem};
use crate::error::{AppError, Result};
use crate::money::MinorUnits;
use crate::output::{print_success, Ctx, Format};
use crate::render::{self, default_invoice_dir, expand_tilde};
pub fn run(cmd: InvoiceCmd, ctx: Ctx) -> Result<()> {
let mut conn = db::open()?;
match cmd {
InvoiceCmd::New {
r#as,
client,
items,
due,
terms,
notes,
currency,
reverse_charge,
pay_link,
discount_rate,
discount_fixed,
} => {
let client_row = db::client_by_slug(&conn, &client)?;
let issuer_slug = match r#as {
Some(s) => s,
None => client_row
.default_issuer_slug
.clone()
.or_else(|| {
crate::config::load()
.ok()
.and_then(|cfg| cfg.default_issuer)
})
.ok_or_else(|| {
AppError::InvalidInput(
"--as required (no default issuer on this client or in config)".into(),
)
})?,
};
let issuer = db::issuer_by_slug(&conn, &issuer_slug)?;
let profile = issuer.jurisdiction.profile();
let today = chrono::Local::now().date_naive();
let due_date = parse_due(&due, today)?;
let number = db::next_invoice_number(&conn, &issuer, today.year(), "invoice")?;
let use_currency = currency
.or(issuer.currency.clone())
.unwrap_or_else(|| profile.currency.to_string());
let derived_symbol = finance_core::money::currency_symbol(&use_currency);
let use_symbol = if issuer.currency.as_deref() == Some(use_currency.as_str()) {
issuer
.symbol
.clone()
.unwrap_or_else(|| derived_symbol.to_string())
} else if !derived_symbol.is_empty() {
derived_symbol.to_string()
} else {
profile.symbol.to_string()
};
let use_notes = notes.or_else(|| issuer.default_notes.clone());
if items.is_empty() {
return Err(AppError::InvalidInput(
"at least one --item is required to create an invoice".into(),
));
}
let parsed_items = parse_items(&conn, &items, &profile)?;
let (inv_discount_rate, inv_discount_fixed) =
parse_discount_pair(discount_rate.as_deref(), discount_fixed.as_deref())?;
let invoice = Invoice {
id: 0,
number: number.clone(),
issuer_id: issuer.id,
client_id: client_row.id,
issue_date: today.format("%Y-%m-%d").to_string(),
due_date: due_date.format("%Y-%m-%d").to_string(),
terms,
currency: use_currency,
symbol: use_symbol,
tax_label: profile.tax_label.to_string(),
status: "draft".into(),
notes: use_notes,
reverse_charge,
pay_link,
issued_at: None,
paid_at: None,
total_minor: None,
kind: "invoice".into(),
credits_invoice_id: None,
discount_rate: inv_discount_rate,
discount_fixed: inv_discount_fixed,
items: parsed_items,
};
let id = db::invoice_create(&mut conn, &invoice)?;
let mut out = invoice.clone();
out.id = id;
print_success(ctx, &out, |i| {
println!("created invoice {} (id {})", i.number, i.id)
});
Ok(())
}
InvoiceCmd::Edit {
number,
client,
due,
terms,
notes,
currency,
pay_link,
reverse_charge,
discount_rate,
discount_fixed,
} => {
let mut inv = db::invoice_get(&conn, &number)?;
if let Some(slug) = client {
let c = db::client_by_slug(&conn, &slug)?;
inv.client_id = c.id;
}
if let Some(d) = due {
let today = chrono::Local::now().date_naive();
let nd = parse_due(&d, today)?;
inv.due_date = nd.format("%Y-%m-%d").to_string();
}
if let Some(t) = terms {
inv.terms = t;
}
if let Some(n) = notes {
inv.notes = Some(n);
}
if let Some(c) = currency {
let derived_symbol = finance_core::money::currency_symbol(&c);
if !derived_symbol.is_empty() {
inv.symbol = derived_symbol.to_string();
}
inv.currency = c;
}
if let Some(p) = pay_link {
inv.pay_link = Some(p);
}
if let Some(rc) = reverse_charge {
inv.reverse_charge = rc;
}
if discount_rate.is_some() || discount_fixed.is_some() {
let (dr, df) =
parse_discount_pair(discount_rate.as_deref(), discount_fixed.as_deref())?;
inv.discount_rate = dr;
inv.discount_fixed = df;
}
db::invoice_update_draft(&conn, &inv)?;
print_success(ctx, &inv, |i| println!("edited invoice {}", i.number));
Ok(())
}
InvoiceCmd::Items(item_cmd) => run_items(item_cmd, ctx, &mut conn),
InvoiceCmd::CreditNote {
number,
full,
items,
notes,
pay_link,
} => {
let source = db::invoice_get(&conn, &number)?;
let issuer = db::issuer_list(&conn)?
.into_iter()
.find(|i| i.id == source.issuer_id)
.ok_or_else(|| AppError::NotFound("issuer".into()))?;
let profile = issuer.jurisdiction.profile();
let new_items: Vec<InvoiceItem> = if full {
source
.items
.iter()
.enumerate()
.map(|(pos, it)| InvoiceItem {
id: 0,
invoice_id: 0,
position: pos as i64,
description: it.description.clone(),
subtitle: it.subtitle.clone(),
qty: -it.qty,
unit: it.unit.clone(),
unit_price: it.unit_price,
tax_rate: it.tax_rate,
product_id: it.product_id,
discount_rate: it.discount_rate,
discount_fixed: it.discount_fixed,
})
.collect()
} else if !items.is_empty() {
let mut parsed = parse_items(&conn, &items, &profile)?;
normalize_credit_note_items(&mut parsed);
parsed
} else {
return Err(AppError::InvalidInput(
"pass --full or at least one --item".into(),
));
};
let today = chrono::Local::now().date_naive();
let new_number = db::next_invoice_number(&conn, &issuer, today.year(), "credit_note")?;
let invoice = Invoice {
id: 0,
number: new_number.clone(),
issuer_id: source.issuer_id,
client_id: source.client_id,
issue_date: today.format("%Y-%m-%d").to_string(),
due_date: today.format("%Y-%m-%d").to_string(),
terms: source.terms.clone(),
currency: source.currency.clone(),
symbol: source.symbol.clone(),
tax_label: source.tax_label.clone(),
status: "draft".into(),
notes,
reverse_charge: source.reverse_charge,
pay_link,
issued_at: None,
paid_at: None,
total_minor: None,
kind: "credit_note".into(),
credits_invoice_id: Some(source.id),
discount_rate: None,
discount_fixed: None,
items: new_items,
};
let id = db::invoice_create(&mut conn, &invoice)?;
let mut out = invoice.clone();
out.id = id;
print_success(ctx, &out, |i| {
println!(
"created credit note {} (against {}, id {})",
i.number, number, i.id
)
});
Ok(())
}
InvoiceCmd::Aging { issuer } => {
let list = db::invoice_list(&conn, Some("issued"), issuer.as_deref())?;
let today = chrono::Local::now().date_naive();
#[derive(Default, serde::Serialize)]
struct Bucket {
count: i64,
total_minor: i64,
}
let mut buckets: std::collections::BTreeMap<&'static str, Bucket> = Default::default();
for name in ["not_yet_due", "0_30", "31_60", "61_90", "90_plus"] {
buckets.insert(name, Bucket::default());
}
#[derive(serde::Serialize)]
struct Row {
number: String,
client_id: i64,
due_date: String,
days_overdue: i64,
total_minor: i64,
}
let mut rows: Vec<Row> = Vec::new();
for inv in &list {
let due = NaiveDate::parse_from_str(&inv.due_date, "%Y-%m-%d").unwrap_or(today);
let days_overdue = (today - due).num_days();
let bucket_name: &'static str = if days_overdue <= 0 {
"not_yet_due"
} else if days_overdue <= 30 {
"0_30"
} else if days_overdue <= 60 {
"31_60"
} else if days_overdue <= 90 {
"61_90"
} else {
"90_plus"
};
let total = inv.total_minor.unwrap_or(0);
let b = buckets.get_mut(bucket_name).unwrap();
b.count += 1;
b.total_minor += total;
rows.push(Row {
number: inv.number.clone(),
client_id: inv.client_id,
due_date: inv.due_date.clone(),
days_overdue,
total_minor: total,
});
}
let symbol = list
.first()
.map(|i| i.symbol.clone())
.unwrap_or_else(|| "$".into());
let payload = serde_json::json!({
"buckets": &buckets,
"invoices": &rows,
});
print_success(ctx, &payload, |_| {
println!("{:<14} {:>6} {:>14}", "bucket", "count", "total");
for (name, b) in &buckets {
let total = MinorUnits(b.total_minor).format_with_symbol(&symbol);
println!("{:<14} {:>6} {:>14}", name, b.count, total);
}
});
Ok(())
}
InvoiceCmd::Export {
from,
to,
format,
out,
issuer,
} => {
let list = db::invoice_list(&conn, None, issuer.as_deref())?;
let from_date = from
.as_deref()
.map(|s| {
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|e| AppError::InvalidInput(format!("bad --from '{s}': {e}")))
})
.transpose()?;
let to_date = to
.as_deref()
.map(|s| {
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|e| AppError::InvalidInput(format!("bad --to '{s}': {e}")))
})
.transpose()?;
let filtered: Vec<Invoice> = list
.into_iter()
.filter(|inv| {
let d = match NaiveDate::parse_from_str(&inv.issue_date, "%Y-%m-%d") {
Ok(d) => d,
Err(_) => return false,
};
if let Some(f) = from_date {
if d < f {
return false;
}
}
if let Some(t) = to_date {
if d > t {
return false;
}
}
true
})
.collect();
match format.as_str() {
"csv" => {
let issuers = db::issuer_list(&conn)?;
let clients = db::client_list(&conn)?;
let issuer_slug = |id: i64| -> String {
issuers
.iter()
.find(|x| x.id == id)
.map(|x| x.slug.clone())
.unwrap_or_default()
};
let client_slug = |id: i64| -> String {
clients
.iter()
.find(|x| x.id == id)
.map(|x| x.slug.clone())
.unwrap_or_default()
};
let mut csv = String::new();
csv.push_str("number,kind,issue_date,due_date,status,issued_at,paid_at,currency,total_minor,issuer_slug,client_slug,notes\n");
for inv in &filtered {
let row = [
csv_esc(&inv.number),
csv_esc(&inv.kind),
csv_esc(&inv.issue_date),
csv_esc(&inv.due_date),
csv_esc(&inv.status),
csv_esc(inv.issued_at.as_deref().unwrap_or("")),
csv_esc(inv.paid_at.as_deref().unwrap_or("")),
csv_esc(&inv.currency),
inv.total_minor.map(|t| t.to_string()).unwrap_or_default(),
csv_esc(&issuer_slug(inv.issuer_id)),
csv_esc(&client_slug(inv.client_id)),
csv_esc(inv.notes.as_deref().unwrap_or("")),
];
csv.push_str(&row.join(","));
csv.push('\n');
}
if let Some(path) = out {
std::fs::write(&path, csv)?;
print_success(
ctx,
&serde_json::json!({"path": path, "count": filtered.len()}),
|v| println!("exported {} invoices → {}", v["count"], v["path"]),
);
} else {
match ctx.format {
Format::Json => {
print_success(
ctx,
&serde_json::json!({"csv": csv, "count": filtered.len()}),
|_| {},
);
}
Format::Human => {
print!("{}", csv);
}
}
}
}
"json" => {
let serialized = serde_json::to_string_pretty(&filtered)?;
if let Some(path) = out {
std::fs::write(&path, &serialized)?;
print_success(
ctx,
&serde_json::json!({"path": path, "count": filtered.len()}),
|v| println!("exported {} invoices → {}", v["count"], v["path"]),
);
} else {
print_success(ctx, &filtered, |list| {
println!("{}", serde_json::to_string_pretty(list).unwrap());
});
}
}
other => {
return Err(AppError::InvalidInput(format!(
"unknown export format '{other}' — use csv or json"
)));
}
}
Ok(())
}
InvoiceCmd::Duplicate {
number,
client,
r#as,
due,
} => {
let source = db::invoice_get(&conn, &number)?;
let target_client = match client {
Some(slug) => db::client_by_slug(&conn, &slug)?,
None => db::client_list(&conn)?
.into_iter()
.find(|c| c.id == source.client_id)
.ok_or_else(|| AppError::NotFound("client".into()))?,
};
let target_issuer = match r#as {
Some(slug) => db::issuer_by_slug(&conn, &slug)?,
None => db::issuer_list(&conn)?
.into_iter()
.find(|i| i.id == source.issuer_id)
.ok_or_else(|| AppError::NotFound("issuer".into()))?,
};
let today = chrono::Local::now().date_naive();
let due_date = parse_due(&due, today)?;
let new_number =
db::next_invoice_number(&conn, &target_issuer, today.year(), "invoice")?;
let new_items: Vec<InvoiceItem> = source
.items
.iter()
.enumerate()
.map(|(pos, it)| InvoiceItem {
id: 0,
invoice_id: 0,
position: pos as i64,
description: it.description.clone(),
subtitle: it.subtitle.clone(),
qty: it.qty,
unit: it.unit.clone(),
unit_price: it.unit_price,
tax_rate: it.tax_rate,
product_id: it.product_id,
discount_rate: it.discount_rate,
discount_fixed: it.discount_fixed,
})
.collect();
let invoice = Invoice {
id: 0,
number: new_number.clone(),
issuer_id: target_issuer.id,
client_id: target_client.id,
issue_date: today.format("%Y-%m-%d").to_string(),
due_date: due_date.format("%Y-%m-%d").to_string(),
terms: source.terms.clone(),
currency: source.currency.clone(),
symbol: source.symbol.clone(),
tax_label: source.tax_label.clone(),
status: "draft".into(),
notes: source.notes.clone(),
reverse_charge: source.reverse_charge,
pay_link: None,
issued_at: None,
paid_at: None,
total_minor: None,
kind: "invoice".into(),
credits_invoice_id: None,
discount_rate: None,
discount_fixed: None,
items: new_items,
};
let id = db::invoice_create(&mut conn, &invoice)?;
let mut out = invoice.clone();
out.id = id;
print_success(ctx, &out, |i| {
println!("duplicated {} → {} (id {})", number, i.number, i.id)
});
Ok(())
}
InvoiceCmd::List {
status,
issuer,
overdue,
} => {
let mut list = db::invoice_list(&conn, status.as_deref(), issuer.as_deref())?;
if overdue {
let today = chrono::Local::now().date_naive();
list.retain(|inv| {
if inv.status == "paid" || inv.status == "void" {
return false;
}
match NaiveDate::parse_from_str(&inv.due_date, "%Y-%m-%d") {
Ok(d) => d < today,
Err(_) => false,
}
});
}
print_success(ctx, &list, |list| {
if list.is_empty() {
println!("no invoices.");
}
for i in list {
let total = i
.total_minor
.map(|t| MinorUnits(t).format_with_symbol(&i.symbol))
.unwrap_or_else(|| "-".into());
println!(
"{:<18} {:<8} {:<10} {:<5} {:>12}",
i.number, i.status, i.issue_date, i.currency, total
);
}
});
Ok(())
}
InvoiceCmd::Show { number } => {
let inv = db::invoice_get(&conn, &number)?;
print_success(ctx, &inv, |i| println!("{:#?}", i));
Ok(())
}
InvoiceCmd::Render {
number,
template,
out,
open,
} => {
let inv = db::invoice_get(&conn, &number)?;
let issuer_rows = db::issuer_list(&conn)?;
let issuer = issuer_rows
.into_iter()
.find(|i| i.id == inv.issuer_id)
.ok_or_else(|| AppError::NotFound("issuer".into()))?;
let client = db::client_list(&conn)?
.into_iter()
.find(|c| c.id == inv.client_id)
.ok_or_else(|| AppError::NotFound("client".into()))?;
let tmpl = template.unwrap_or_else(|| {
if let Some(ct) = client.default_template.as_ref() {
if crate::typst_assets::has_template(ct).unwrap_or(false) {
return ct.clone();
}
}
let stored = issuer.default_template.clone();
if crate::typst_assets::has_template(&stored).unwrap_or(false) {
stored
} else {
"vienna".into()
}
});
let filename = format!("{}.pdf", inv.number);
let out_path = match out {
Some(o) => expand_tilde(&o).into(),
None => {
let base: PathBuf = match issuer.default_output_dir.as_deref() {
Some(d) if !d.is_empty() => expand_tilde(d).into(),
_ => default_invoice_dir(),
};
std::fs::create_dir_all(&base)?;
base.join(&filename)
}
};
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
render::render_invoice_with_qr(
&tmpl,
&inv,
&issuer,
&client,
inv.pay_link.as_deref(),
&out_path,
)?;
let year = inv.issue_date.get(..4).unwrap_or("unknown");
let archive_dir = finance_core::paths::Paths::resolve()?
.data_dir
.join("rendered")
.join(year);
std::fs::create_dir_all(&archive_dir)?;
let archive_path = archive_dir.join(&filename);
std::fs::copy(&out_path, &archive_path)?;
if open {
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(&out_path).status();
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(&out_path)
.status();
}
print_success(
ctx,
&serde_json::json!({
"number": inv.number,
"path": out_path,
"archive": archive_path,
}),
|_| {
println!("rendered → {}", out_path.display());
println!("archived → {}", archive_path.display());
},
);
Ok(())
}
InvoiceCmd::Mark { number, status } => {
if !["draft", "issued", "paid", "void"].contains(&status.as_str()) {
return Err(AppError::InvalidInput(format!(
"status must be draft | issued | paid | void, got '{status}'"
)));
}
db::invoice_set_status(&conn, &number, &status)?;
print_success(
ctx,
&serde_json::json!({"number": number, "status": status}),
|v| println!("marked {} as {}", v["number"], v["status"]),
);
Ok(())
}
InvoiceCmd::Delete { number, force } => {
db::invoice_delete(&conn, &number, force)?;
print_success(ctx, &number, |n| println!("deleted invoice '{n}'"));
Ok(())
}
}
}
fn run_items(cmd: InvoiceItemCmd, ctx: Ctx, conn: &mut rusqlite::Connection) -> Result<()> {
match cmd {
InvoiceItemCmd::Add {
number,
spec,
subtitle,
discount_rate,
discount_fixed,
} => {
let inv = db::invoice_get(conn, &number)?;
let issuer = db::issuer_list(conn)?
.into_iter()
.find(|i| i.id == inv.issuer_id)
.ok_or_else(|| AppError::NotFound("issuer".into()))?;
let profile = issuer.jurisdiction.profile();
let specs = [spec.clone()];
let mut parsed = parse_items(conn, &specs, &profile)?;
let mut item = parsed
.pop()
.ok_or_else(|| AppError::InvalidInput(format!("no item parsed from '{spec}'")))?;
if let Some(st) = subtitle {
item.subtitle = Some(st);
}
let (dr, df) =
parse_discount_pair(discount_rate.as_deref(), discount_fixed.as_deref())?;
item.discount_rate = dr;
item.discount_fixed = df;
let item_id = db::invoice_item_add(conn, inv.id, &item)?;
print_success(
ctx,
&serde_json::json!({
"invoice": inv.number,
"item_id": item_id,
}),
|v| println!("added item to {} (item id {})", v["invoice"], v["item_id"]),
);
Ok(())
}
InvoiceItemCmd::Remove { number, position } => {
let inv = db::invoice_get(conn, &number)?;
db::invoice_item_remove(conn, inv.id, position)?;
print_success(
ctx,
&serde_json::json!({"invoice": inv.number, "position": position}),
|v| println!("removed item {} from {}", v["position"], v["invoice"]),
);
Ok(())
}
InvoiceItemCmd::Edit {
number,
position,
description,
subtitle,
qty,
unit,
price,
tax_rate,
discount_rate,
discount_fixed,
} => {
let inv = db::invoice_get(conn, &number)?;
let mut item = inv
.items
.iter()
.find(|it| it.position == position)
.cloned()
.ok_or_else(|| {
AppError::NotFound(format!(
"item at position {position} of invoice '{}'",
inv.number
))
})?;
if let Some(d) = description {
item.description = d;
}
if let Some(s) = subtitle {
item.subtitle = Some(s);
}
if let Some(q) = qty {
item.qty = Decimal::from_str(&q)
.map_err(|e| AppError::InvalidInput(format!("bad qty: {e}")))?;
}
if let Some(u) = unit {
item.unit = u;
}
if let Some(p) = price {
let d = Decimal::from_str(&p)
.map_err(|e| AppError::InvalidInput(format!("bad price: {e}")))?;
item.unit_price = MinorUnits::from_decimal(d);
}
if let Some(r) = tax_rate {
item.tax_rate = Decimal::from_str(&r)
.map_err(|e| AppError::InvalidInput(format!("bad tax rate: {e}")))?;
}
if discount_rate.is_some() || discount_fixed.is_some() {
let (dr, df) =
parse_discount_pair(discount_rate.as_deref(), discount_fixed.as_deref())?;
item.discount_rate = dr;
item.discount_fixed = df;
}
db::invoice_item_edit(conn, inv.id, position, &item)?;
print_success(
ctx,
&serde_json::json!({"invoice": inv.number, "position": position}),
|v| println!("edited item {} of {}", v["position"], v["invoice"]),
);
Ok(())
}
}
}
fn parse_discount_pair(
rate: Option<&str>,
fixed: Option<&str>,
) -> Result<(Option<Decimal>, Option<MinorUnits>)> {
match (rate, fixed) {
(Some(_), Some(_)) => Err(AppError::InvalidInput(
"pass at most one of --discount-rate / --discount-fixed".into(),
)),
(Some(r), None) => {
let d = Decimal::from_str(r)
.map_err(|e| AppError::InvalidInput(format!("bad discount rate: {e}")))?;
if d < Decimal::ZERO || d > Decimal::from(100) {
return Err(AppError::InvalidInput(
"discount rate must be between 0 and 100".into(),
));
}
Ok((Some(d), None))
}
(None, Some(f)) => {
let d = Decimal::from_str(f)
.map_err(|e| AppError::InvalidInput(format!("bad discount fixed: {e}")))?;
if d < Decimal::ZERO {
return Err(AppError::InvalidInput(
"discount fixed amount must be non-negative".into(),
));
}
Ok((None, Some(MinorUnits::from_decimal(d))))
}
(None, None) => Ok((None, None)),
}
}
fn csv_esc(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
let escaped = s.replace('"', "\"\"");
format!("\"{}\"", escaped)
} else {
s.to_string()
}
}
fn parse_due(s: &str, today: NaiveDate) -> Result<NaiveDate> {
if let Some(stripped) = s.strip_suffix('d') {
let days: i64 = stripped
.parse()
.map_err(|_| AppError::InvalidInput(format!("bad due '{s}'")))?;
return Ok(today + Duration::days(days));
}
NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|e| AppError::InvalidInput(format!("bad due date '{s}': {e}")))
}
fn parse_items(
conn: &rusqlite::Connection,
items: &[String],
profile: &crate::tax::TaxProfile,
) -> Result<Vec<InvoiceItem>> {
let mut out = Vec::new();
for (pos, spec) in items.iter().enumerate() {
let parts: Vec<&str> = spec.split(':').collect();
let item = match parts.as_slice() {
[slug] => item_from_product(conn, slug, Decimal::from(1), pos)?,
[slug, qty] => {
let q = Decimal::from_str(qty)
.map_err(|e| AppError::InvalidInput(format!("bad qty: {e}")))?;
item_from_product(conn, slug, q, pos)?
}
[desc, qty, price] => InvoiceItem {
id: 0,
invoice_id: 0,
position: pos as i64,
description: desc.to_string(),
subtitle: None,
qty: Decimal::from_str(qty)
.map_err(|e| AppError::InvalidInput(format!("bad qty: {e}")))?,
unit: "unit".into(),
unit_price: MinorUnits::from_decimal(Decimal::from_str(price).map_err(|e| {
AppError::InvalidInput(format!("bad price: {e}"))
})?),
tax_rate: Decimal::try_from(profile.default_rate).unwrap_or_default(),
product_id: None,
discount_rate: None,
discount_fixed: None,
},
[desc, qty, price, rate] => InvoiceItem {
id: 0,
invoice_id: 0,
position: pos as i64,
description: desc.to_string(),
subtitle: None,
qty: Decimal::from_str(qty)
.map_err(|e| AppError::InvalidInput(format!("bad qty: {e}")))?,
unit: "unit".into(),
unit_price: MinorUnits::from_decimal(Decimal::from_str(price).map_err(|e| {
AppError::InvalidInput(format!("bad price: {e}"))
})?),
tax_rate: Decimal::from_str(rate)
.map_err(|e| AppError::InvalidInput(format!("bad rate: {e}")))?,
product_id: None,
discount_rate: None,
discount_fixed: None,
},
_ => {
return Err(AppError::InvalidInput(format!(
"unrecognized item spec '{spec}' — use product-slug[:qty] or description:qty:price[:rate]"
)))
}
};
out.push(item);
}
Ok(out)
}
fn normalize_credit_note_items(items: &mut [InvoiceItem]) {
for item in items {
let line = crate::money::line_total_discounted(
item.qty,
item.unit_price,
item.discount_rate,
item.discount_fixed,
);
if line.0 > 0 {
item.qty = -item.qty;
}
}
}
fn item_from_product(
conn: &rusqlite::Connection,
slug: &str,
qty: Decimal,
pos: usize,
) -> Result<InvoiceItem> {
let p = db::product_by_slug(conn, slug)?;
Ok(InvoiceItem {
id: 0,
invoice_id: 0,
position: pos as i64,
description: p.description,
subtitle: p.subtitle,
qty,
unit: p.unit,
unit_price: p.unit_price,
tax_rate: p.tax_rate,
product_id: Some(p.id),
discount_rate: None,
discount_fixed: None,
})
}