use chrono::NaiveDate;
use rust_decimal::prelude::*;
use rust_decimal::Decimal;
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use std::str::FromStr;
use crate::db::{self, Client, Invoice, Issuer};
use crate::error::{AppError, Result};
use crate::money::{apply_rate, line_total, line_total_discounted, tax_amount, MinorUnits};
use crate::typst_assets;
#[derive(Debug, Serialize)]
pub struct InvoiceData {
pub issuer: IssuerData,
pub client: ClientData,
pub invoice: InvoiceMeta,
pub items: Vec<ItemData>,
pub totals: TotalsData,
pub notes: String,
pub qr: Option<QrData>,
}
#[derive(Debug, Serialize)]
pub struct QrData {
pub modules: Vec<Vec<bool>>,
pub size: u32, pub label: String, }
#[derive(Debug, Serialize)]
pub struct IssuerData {
pub name: String,
pub legal_name: Option<String>,
pub tagline: Option<String>,
pub address: Vec<String>,
pub email: Option<String>,
pub phone: Option<String>,
pub tax_id: Option<String>,
pub company_no: Option<String>,
pub bank: Option<BankBlock>,
pub logo: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct BankBlock {
pub lines: Vec<finance_core::entity::BankLine>,
}
#[derive(Debug, Serialize)]
pub struct ClientData {
pub name: String,
pub attn: Option<String>,
pub address: Vec<String>,
pub tax_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct InvoiceMeta {
pub number: String,
pub issue_date: String,
pub due_date: String,
pub terms: String,
pub currency: String,
pub symbol: String,
pub tax_label: String,
pub title: String,
pub reverse_charge: bool,
pub kind: String,
pub credits_number: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ItemData {
pub description: String,
pub subtitle: Option<String>,
pub qty: f64,
pub unit: String,
pub unit_price: f64,
pub tax_rate: f64,
pub amount: f64,
pub gross: Option<f64>,
pub discount: Option<f64>,
pub discount_label: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TotalsData {
pub subtotal: f64,
pub tax_lines: Vec<TaxLine>,
pub tax_total: f64,
pub total: f64,
pub discount: Option<f64>,
pub discount_label: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TaxLine {
pub rate: f64,
pub base: f64,
pub amount: f64,
}
pub fn build_data(inv: &Invoice, issuer: &Issuer, client: &Client) -> InvoiceData {
let profile = issuer.jurisdiction.profile();
let title = if inv.kind == "credit_note" {
"Credit Note".to_string()
} else {
profile.title(issuer.tax_registered).to_string()
};
let mut items = Vec::with_capacity(inv.items.len());
let mut subtotal = MinorUnits(0);
let mut by_rate: std::collections::BTreeMap<String, MinorUnits> = Default::default();
for it in &inv.items {
let gross = line_total(it.qty, it.unit_price);
let line =
line_total_discounted(it.qty, it.unit_price, it.discount_rate, it.discount_fixed);
let (disc_amt, disc_label) = if gross.0 != line.0 {
let diff = MinorUnits(gross.0 - line.0);
let label = if let Some(r) = it.discount_rate {
format!("rate:{}", r)
} else {
"fixed".into()
};
(Some(diff.as_major()), Some(label))
} else {
(None, None)
};
subtotal.0 += line.0;
let k = it.tax_rate.to_string();
let entry = by_rate.entry(k).or_insert(MinorUnits(0));
entry.0 += line.0;
items.push(ItemData {
description: it.description.clone(),
subtitle: it.subtitle.clone(),
qty: it.qty.to_f64().unwrap_or(0.0),
unit: it.unit.clone(),
unit_price: it.unit_price.as_major(),
tax_rate: it.tax_rate.to_f64().unwrap_or(0.0),
amount: line.as_major(),
gross: if disc_amt.is_some() {
Some(gross.as_major())
} else {
None
},
discount: disc_amt,
discount_label: disc_label,
});
}
let (inv_discount_minor, inv_discount_label) = match (&inv.discount_rate, &inv.discount_fixed) {
(Some(r), _) => {
let cut = apply_rate(subtotal, *r);
(cut.0.min(subtotal.0), Some(format!("rate:{}", r)))
}
(None, Some(fx)) => (fx.0.min(subtotal.0), Some("fixed".into())),
_ => (0, None),
};
let subtotal_after_discount = MinorUnits(subtotal.0 - inv_discount_minor);
let mut tax_lines = Vec::new();
let mut tax_total = MinorUnits(0);
for (rate_str, base) in &by_rate {
let rate = Decimal::from_str(rate_str).unwrap_or_default();
let scaled_base = if subtotal.0 > 0 && inv_discount_minor > 0 {
MinorUnits(
((base.0 as i128) * (subtotal_after_discount.0 as i128) / (subtotal.0 as i128))
as i64,
)
} else {
*base
};
let amt = tax_amount(scaled_base, rate);
tax_total.0 += amt.0;
tax_lines.push(TaxLine {
rate: rate.to_f64().unwrap_or(0.0),
base: scaled_base.as_major(),
amount: amt.as_major(),
});
}
let total = MinorUnits(subtotal_after_discount.0 + tax_total.0);
InvoiceData {
issuer: IssuerData {
name: issuer.name.clone(),
legal_name: issuer.legal_name.clone(),
tagline: issuer.tagline.clone(),
address: issuer.address.clone(),
email: issuer.email.clone(),
phone: issuer.phone.clone(),
tax_id: issuer.tax_id.clone(),
company_no: issuer.company_no.clone(),
bank: issuer.bank_details.as_deref().and_then(|details| {
let lines = finance_core::entity::BankLine::parse_all(details);
if lines.is_empty() {
None
} else {
Some(BankBlock { lines })
}
}),
logo: None, },
client: ClientData {
name: client.name.clone(),
attn: client.attn.clone(),
address: client.address.clone(),
tax_id: client.tax_id.clone(),
},
invoice: InvoiceMeta {
number: inv.number.clone(),
issue_date: format_date(&inv.issue_date, profile.date_format),
due_date: format_date(&inv.due_date, profile.date_format),
terms: inv.terms.clone(),
currency: inv.currency.clone(),
symbol: inv.symbol.clone(),
tax_label: inv.tax_label.clone(),
title,
reverse_charge: inv.reverse_charge,
kind: if inv.kind == "credit_note" {
"credit-note".into()
} else {
"invoice".into()
},
credits_number: None, },
items,
totals: TotalsData {
subtotal: subtotal.as_major(),
tax_lines,
tax_total: tax_total.as_major(),
total: total.as_major(),
discount: if inv_discount_minor > 0 {
Some(MinorUnits(inv_discount_minor).as_major())
} else {
None
},
discount_label: inv_discount_label,
},
notes: inv.notes.clone().unwrap_or_default(),
qr: None,
}
}
pub fn encode_qr(data: &str) -> Option<QrData> {
if data.is_empty() {
return None;
}
let code =
qrcode::QrCode::with_error_correction_level(data.as_bytes(), qrcode::EcLevel::Q).ok()?;
let width = code.width();
let colors = code.to_colors();
let modules: Vec<Vec<bool>> = (0..width)
.map(|row| {
(0..width)
.map(|col| matches!(colors[row * width + col], qrcode::Color::Dark))
.collect()
})
.collect();
Some(QrData {
modules,
size: width as u32,
label: "Pay online".to_string(),
})
}
pub fn build_data_with_qr(
inv: &Invoice,
issuer: &Issuer,
client: &Client,
qr_data: Option<&str>,
) -> InvoiceData {
let mut data = build_data(inv, issuer, client);
data.qr = qr_data.and_then(encode_qr);
data
}
pub fn render_invoice(
template: &str,
inv: &Invoice,
issuer: &Issuer,
client: &Client,
out_path: &Path,
) -> Result<()> {
render_invoice_with_qr(template, inv, issuer, client, None, out_path)
}
pub fn render_invoice_with_qr(
template: &str,
inv: &Invoice,
issuer: &Issuer,
client: &Client,
qr_data: Option<&str>,
out_path: &Path,
) -> Result<()> {
typst_assets::ensure_extracted()?;
if !typst_assets::has_template(template)? {
return Err(AppError::InvalidInput(format!(
"template '{template}' not found. Run: invoice template list"
)));
}
let mut data = build_data_with_qr(inv, issuer, client, qr_data);
data.issuer.logo = resolve_logo(issuer)?;
if inv.kind == "credit_note" {
if let Some(src_id) = inv.credits_invoice_id {
if let Ok(conn) = crate::db::open() {
if let Ok(list) = db::invoice_list(&conn, None, None) {
if let Some(src) = list.into_iter().find(|x| x.id == src_id) {
data.invoice.credits_number = Some(src.number);
}
}
}
}
}
let render_root = prepare_render_root(&data)?;
let root = render_root.path();
let template_path = root.join("templates").join(format!("{template}.typ"));
let status = Command::new("typst")
.arg("compile")
.arg("--root")
.arg(root)
.arg(&template_path)
.arg(out_path)
.status()
.map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?;
if !status.success() {
return Err(AppError::Render(format!(
"typst compile exited with {}",
status.code().unwrap_or(-1)
)));
}
Ok(())
}
fn prepare_render_root(data: &InvoiceData) -> Result<tempfile::TempDir> {
let source_root = typst_assets::project_root()?;
let render_root = tempfile::Builder::new()
.prefix("invoice-cli-render-")
.tempdir()?;
copy_dir_contents(&source_root, render_root.path())?;
inject_sample_data(render_root.path(), data)?;
Ok(render_root)
}
fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_contents(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn resolve_logo(issuer: &Issuer) -> Result<Option<String>> {
let Some(src_raw) = &issuer.logo_path else {
return Ok(None);
};
let src_expanded = expand_tilde(src_raw);
let src = Path::new(&src_expanded);
if !src.exists() {
eprintln!(
"warning: logo '{}' not found for issuer '{}' — rendering without",
src.display(),
issuer.slug
);
return Ok(None);
}
let ext = src
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let dest_rel = format!("shared/logo-{}.{ext}", issuer.slug);
let dest_abs = typst_assets::project_root()?.join(&dest_rel);
if let Some(parent) = dest_abs.parent() {
std::fs::create_dir_all(parent)?;
}
let needs_copy = match (std::fs::metadata(src), std::fs::metadata(&dest_abs)) {
(Ok(a), Ok(b)) => a.modified().ok() > b.modified().ok(),
_ => true,
};
if needs_copy {
std::fs::copy(src, &dest_abs)?;
}
Ok(Some(format!("/{dest_rel}")))
}
pub fn expand_tilde(s: &str) -> String {
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{home}/{rest}");
}
}
s.to_string()
}
pub fn default_invoice_dir() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(home)
.join("Documents")
.join("Invoices")
}
fn inject_sample_data(root: &Path, data: &InvoiceData) -> Result<()> {
let shared = root.join("shared");
let invoice_path = shared.join("invoice.typ");
let helpers_path = shared.join("_helpers.inc.typ");
let helpers = std::fs::read_to_string(&helpers_path)
.map_err(|e| AppError::Render(format!("missing _helpers.inc.typ: {e}")))?;
let sample = generate_sample_data_typ(data);
let full = format!(
"// Auto-generated by invoice-cli. Manual edits will be overwritten.\n\n{}\n\n{}",
sample, helpers
);
std::fs::write(&invoice_path, full)?;
Ok(())
}
fn generate_sample_data_typ(d: &InvoiceData) -> String {
format!(
"#let sample-data = (\n issuer: {},\n client: {},\n invoice: {},\n items: {},\n totals-override: {},\n notes: {},\n qr: {},\n)",
typst_dict_issuer(&d.issuer),
typst_dict_client(&d.client),
typst_dict_invoice(&d.invoice),
typst_array_items(&d.items),
typst_dict_totals(&d.totals),
typst_string(&d.notes),
typst_qr(&d.qr),
)
}
fn typst_dict_totals(t: &TotalsData) -> String {
let tax_lines: Vec<String> = t
.tax_lines
.iter()
.map(|tl| {
format!(
"(rate: {}, base: {}, amount: {})",
tl.rate, tl.base, tl.amount
)
})
.collect();
let tax_lines_str = if tax_lines.is_empty() {
"()".to_string()
} else {
format!("({},)", tax_lines.join(", "))
};
format!(
"(subtotal: {}, tax-lines: {}, tax-total: {}, total: {}, discount: {}, discount-label: {})",
t.subtotal,
tax_lines_str,
t.tax_total,
t.total,
t.discount
.map(|v| v.to_string())
.unwrap_or_else(|| "none".into()),
typst_opt(&t.discount_label),
)
}
fn typst_qr(qr: &Option<QrData>) -> String {
match qr {
None => "none".into(),
Some(q) => {
let rows: Vec<String> = q
.modules
.iter()
.map(|row| {
let cells: Vec<&str> = row
.iter()
.map(|&b| if b { "true" } else { "false" })
.collect();
format!("({})", cells.join(", "))
})
.collect();
format!(
"(modules: ({}), size: {}, label: {})",
rows.join(", "),
q.size,
typst_string(&q.label),
)
}
}
}
fn typst_string(s: &str) -> String {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped)
}
fn typst_opt(s: &Option<String>) -> String {
match s {
Some(v) => typst_string(v),
None => "none".into(),
}
}
fn typst_array(lines: &[String]) -> String {
let items: Vec<String> = lines.iter().map(|l| typst_string(l)).collect();
if items.is_empty() {
return "()".into();
}
format!("({},)", items.join(", "))
}
fn typst_dict_issuer(i: &IssuerData) -> String {
let bank = match &i.bank {
Some(b) => {
let line_dicts: Vec<String> = b
.lines
.iter()
.map(|l| {
format!(
"(label: {}, value: {})",
typst_string(&l.label),
typst_string(&l.value)
)
})
.collect();
let lines_array = if line_dicts.is_empty() {
"()".into()
} else {
format!("({},)", line_dicts.join(", "))
};
format!("(lines: {lines_array})")
}
None => "none".into(),
};
format!(
"(name: {}, legal-name: {}, tagline: {}, address: {}, email: {}, phone: {}, tax-id: {}, company-no: {}, bank: {}, logo: {})",
typst_string(&i.name),
typst_opt(&i.legal_name),
typst_opt(&i.tagline),
typst_array(&i.address),
typst_opt(&i.email),
typst_opt(&i.phone),
typst_opt(&i.tax_id),
typst_opt(&i.company_no),
bank,
typst_opt(&i.logo),
)
}
fn typst_dict_client(c: &ClientData) -> String {
format!(
"(name: {}, attn: {}, address: {}, tax-id: {})",
typst_string(&c.name),
typst_opt(&c.attn),
typst_array(&c.address),
typst_opt(&c.tax_id),
)
}
fn typst_dict_invoice(m: &InvoiceMeta) -> String {
format!(
"(number: {}, issue-date: {}, due-date: {}, terms: {}, currency: {}, symbol: {}, tax-label: {}, title: {}, reverse-charge: {}, kind: {}, credits-number: {})",
typst_string(&m.number),
typst_string(&m.issue_date),
typst_string(&m.due_date),
typst_string(&m.terms),
typst_string(&m.currency),
typst_string(&m.symbol),
typst_string(&m.tax_label),
typst_string(&m.title),
if m.reverse_charge { "true" } else { "false" },
typst_string(&m.kind),
typst_opt(&m.credits_number),
)
}
fn format_date(iso: &str, fmt: &str) -> String {
NaiveDate::parse_from_str(iso, "%Y-%m-%d")
.map(|d| d.format(fmt).to_string())
.unwrap_or_else(|_| iso.to_string())
}
fn typst_array_items(items: &[ItemData]) -> String {
let parts: Vec<String> = items
.iter()
.map(|it| {
format!(
"(description: {}, subtitle: {}, qty: {}, unit: {}, unit-price: {}, tax-rate: {}, amount: {}, gross: {}, discount: {}, discount-label: {})",
typst_string(&it.description),
typst_opt(&it.subtitle),
it.qty,
typst_string(&it.unit),
it.unit_price,
it.tax_rate,
it.amount,
it.gross.map(|v| v.to_string()).unwrap_or_else(|| "none".into()),
it.discount.map(|v| v.to_string()).unwrap_or_else(|| "none".into()),
typst_opt(&it.discount_label),
)
})
.collect();
if parts.is_empty() {
return "()".into();
}
format!("(\n {},\n )", parts.join(",\n "))
}