1use chrono::NaiveDate;
7use rust_decimal::prelude::*;
8use rust_decimal::Decimal;
9use serde::Serialize;
10use std::path::Path;
11use std::process::Command;
12use std::str::FromStr;
13
14use crate::db::{self, Client, Invoice, Issuer};
15use crate::error::{AppError, Result};
16use crate::money::{apply_rate, line_total, line_total_discounted, tax_amount, MinorUnits};
17use crate::typst_assets;
18
19#[derive(Debug, Serialize)]
20pub struct InvoiceData {
21 pub issuer: IssuerData,
22 pub client: ClientData,
23 pub invoice: InvoiceMeta,
24 pub items: Vec<ItemData>,
25 pub totals: TotalsData,
26 pub notes: String,
27 pub qr: Option<QrData>,
30}
31
32#[derive(Debug, Serialize)]
33pub struct QrData {
34 pub modules: Vec<Vec<bool>>,
35 pub size: u32, pub label: String, }
38
39#[derive(Debug, Serialize)]
40pub struct IssuerData {
41 pub name: String,
42 pub legal_name: Option<String>,
43 pub tagline: Option<String>,
44 pub address: Vec<String>,
45 pub email: Option<String>,
46 pub phone: Option<String>,
47 pub tax_id: Option<String>,
48 pub company_no: Option<String>,
49 pub bank: Option<BankData>,
50 pub logo: Option<String>,
52}
53
54#[derive(Debug, Serialize)]
55pub struct BankData {
56 pub name: String,
57 pub iban: String,
58 pub bic: String,
59}
60
61#[derive(Debug, Serialize)]
62pub struct ClientData {
63 pub name: String,
64 pub attn: Option<String>,
65 pub address: Vec<String>,
66 pub tax_id: Option<String>,
67}
68
69#[derive(Debug, Serialize)]
70pub struct InvoiceMeta {
71 pub number: String,
72 pub issue_date: String,
73 pub due_date: String,
74 pub terms: String,
75 pub currency: String,
76 pub symbol: String,
77 pub tax_label: String,
78 pub title: String,
79 pub reverse_charge: bool,
80 pub kind: String,
82 pub credits_number: Option<String>,
84}
85
86#[derive(Debug, Serialize)]
87pub struct ItemData {
88 pub description: String,
89 pub subtitle: Option<String>,
90 pub qty: f64,
91 pub unit: String,
92 pub unit_price: f64,
93 pub tax_rate: f64,
94 pub amount: f64,
95 pub gross: Option<f64>,
98 pub discount: Option<f64>,
101 pub discount_label: Option<String>,
103}
104
105#[derive(Debug, Serialize)]
106pub struct TotalsData {
107 pub subtotal: f64,
108 pub tax_lines: Vec<TaxLine>,
109 pub tax_total: f64,
110 pub total: f64,
111 pub discount: Option<f64>,
114 pub discount_label: Option<String>,
115}
116
117#[derive(Debug, Serialize)]
118pub struct TaxLine {
119 pub rate: f64,
120 pub base: f64,
121 pub amount: f64,
122}
123
124pub fn build_data(inv: &Invoice, issuer: &Issuer, client: &Client) -> InvoiceData {
125 let profile = issuer.jurisdiction.profile();
126 let title = if inv.kind == "credit_note" {
127 "Credit Note".to_string()
128 } else {
129 profile.title(issuer.tax_registered).to_string()
130 };
131
132 let mut items = Vec::with_capacity(inv.items.len());
133 let mut subtotal = MinorUnits(0);
134 let mut by_rate: std::collections::BTreeMap<String, MinorUnits> = Default::default();
135
136 for it in &inv.items {
137 let gross = line_total(it.qty, it.unit_price);
138 let line = line_total_discounted(
139 it.qty,
140 it.unit_price,
141 it.discount_rate,
142 it.discount_fixed,
143 );
144 let (disc_amt, disc_label) = if gross.0 != line.0 {
145 let diff = MinorUnits(gross.0 - line.0);
146 let label = if let Some(r) = it.discount_rate {
147 format!("rate:{}", r)
148 } else {
149 "fixed".into()
150 };
151 (Some(diff.as_major()), Some(label))
152 } else {
153 (None, None)
154 };
155
156 subtotal.0 += line.0;
157 let k = it.tax_rate.to_string();
158 let entry = by_rate.entry(k).or_insert(MinorUnits(0));
159 entry.0 += line.0;
160
161 items.push(ItemData {
162 description: it.description.clone(),
163 subtitle: it.subtitle.clone(),
164 qty: it.qty.to_f64().unwrap_or(0.0),
165 unit: it.unit.clone(),
166 unit_price: it.unit_price.as_major(),
167 tax_rate: it.tax_rate.to_f64().unwrap_or(0.0),
168 amount: line.as_major(),
169 gross: if disc_amt.is_some() { Some(gross.as_major()) } else { None },
170 discount: disc_amt,
171 discount_label: disc_label,
172 });
173 }
174
175 let (inv_discount_minor, inv_discount_label) = match (&inv.discount_rate, &inv.discount_fixed) {
178 (Some(r), _) => {
179 let cut = apply_rate(subtotal, *r);
180 (cut.0.min(subtotal.0), Some(format!("rate:{}", r)))
181 }
182 (None, Some(fx)) => (fx.0.min(subtotal.0), Some("fixed".into())),
183 _ => (0, None),
184 };
185
186 let subtotal_after_discount = MinorUnits(subtotal.0 - inv_discount_minor);
187 let mut tax_lines = Vec::new();
191 let mut tax_total = MinorUnits(0);
192 for (rate_str, base) in &by_rate {
193 let rate = Decimal::from_str(rate_str).unwrap_or_default();
194 let scaled_base = if subtotal.0 > 0 && inv_discount_minor > 0 {
195 MinorUnits(
196 ((base.0 as i128) * (subtotal_after_discount.0 as i128)
197 / (subtotal.0 as i128)) as i64,
198 )
199 } else {
200 *base
201 };
202 let amt = tax_amount(scaled_base, rate);
203 tax_total.0 += amt.0;
204 tax_lines.push(TaxLine {
205 rate: rate.to_f64().unwrap_or(0.0),
206 base: scaled_base.as_major(),
207 amount: amt.as_major(),
208 });
209 }
210
211 let total = MinorUnits(subtotal_after_discount.0 + tax_total.0);
212
213 InvoiceData {
214 issuer: IssuerData {
215 name: issuer.name.clone(),
216 legal_name: issuer.legal_name.clone(),
217 tagline: issuer.tagline.clone(),
218 address: issuer.address.clone(),
219 email: issuer.email.clone(),
220 phone: issuer.phone.clone(),
221 tax_id: issuer.tax_id.clone(),
222 company_no: issuer.company_no.clone(),
223 bank: issuer.bank_name.as_ref().and_then(|n| {
224 Some(BankData {
225 name: n.clone(),
226 iban: issuer.bank_iban.clone()?,
227 bic: issuer.bank_bic.clone()?,
228 })
229 }),
230 logo: None, },
232 client: ClientData {
233 name: client.name.clone(),
234 attn: client.attn.clone(),
235 address: client.address.clone(),
236 tax_id: client.tax_id.clone(),
237 },
238 invoice: InvoiceMeta {
239 number: inv.number.clone(),
240 issue_date: format_date(&inv.issue_date, profile.date_format),
241 due_date: format_date(&inv.due_date, profile.date_format),
242 terms: inv.terms.clone(),
243 currency: inv.currency.clone(),
244 symbol: inv.symbol.clone(),
245 tax_label: inv.tax_label.clone(),
246 title,
247 reverse_charge: inv.reverse_charge,
248 kind: if inv.kind == "credit_note" { "credit-note".into() } else { "invoice".into() },
249 credits_number: None, },
251 items,
252 totals: TotalsData {
253 subtotal: subtotal.as_major(),
254 tax_lines,
255 tax_total: tax_total.as_major(),
256 total: total.as_major(),
257 discount: if inv_discount_minor > 0 {
258 Some(MinorUnits(inv_discount_minor).as_major())
259 } else {
260 None
261 },
262 discount_label: inv_discount_label,
263 },
264 notes: inv.notes.clone().unwrap_or_default(),
265 qr: None,
266 }
267}
268
269pub fn encode_qr(data: &str) -> Option<QrData> {
273 if data.is_empty() {
274 return None;
275 }
276 let code = qrcode::QrCode::with_error_correction_level(
277 data.as_bytes(),
278 qrcode::EcLevel::Q,
279 )
280 .ok()?;
281 let width = code.width();
282 let colors = code.to_colors();
283 let modules: Vec<Vec<bool>> = (0..width)
284 .map(|row| {
285 (0..width)
286 .map(|col| {
287 matches!(
288 colors[row * width + col],
289 qrcode::Color::Dark
290 )
291 })
292 .collect()
293 })
294 .collect();
295 Some(QrData {
296 modules,
297 size: width as u32,
298 label: "Pay online".to_string(),
299 })
300}
301
302pub fn build_data_with_qr(
304 inv: &Invoice,
305 issuer: &Issuer,
306 client: &Client,
307 qr_data: Option<&str>,
308) -> InvoiceData {
309 let mut data = build_data(inv, issuer, client);
310 data.qr = qr_data.and_then(encode_qr);
311 data
312}
313
314pub fn render_invoice(
315 template: &str,
316 inv: &Invoice,
317 issuer: &Issuer,
318 client: &Client,
319 out_path: &Path,
320) -> Result<()> {
321 render_invoice_with_qr(template, inv, issuer, client, None, out_path)
322}
323
324pub fn render_invoice_with_qr(
325 template: &str,
326 inv: &Invoice,
327 issuer: &Issuer,
328 client: &Client,
329 qr_data: Option<&str>,
330 out_path: &Path,
331) -> Result<()> {
332 typst_assets::ensure_extracted()?;
333 if !typst_assets::has_template(template)? {
334 return Err(AppError::InvalidInput(format!(
335 "template '{template}' not found. Run: invoice template list"
336 )));
337 }
338
339 let mut data = build_data_with_qr(inv, issuer, client, qr_data);
340 data.issuer.logo = resolve_logo(issuer)?;
344 if inv.kind == "credit_note" {
346 if let Some(src_id) = inv.credits_invoice_id {
347 if let Ok(conn) = crate::db::open() {
348 if let Ok(list) = db::invoice_list(&conn, None, None) {
349 if let Some(src) = list.into_iter().find(|x| x.id == src_id) {
350 data.invoice.credits_number = Some(src.number);
351 }
352 }
353 }
354 }
355 }
356 inject_sample_data(&data)?;
357
358 let template_path = typst_assets::template_path(template)?;
359 let root = typst_assets::project_root()?;
360
361 let status = Command::new("typst")
362 .arg("compile")
363 .arg("--root")
364 .arg(&root)
365 .arg(&template_path)
366 .arg(out_path)
367 .status()
368 .map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?;
369
370 if !status.success() {
371 return Err(AppError::Render(format!(
372 "typst compile exited with {}",
373 status.code().unwrap_or(-1)
374 )));
375 }
376
377 Ok(())
378}
379
380fn resolve_logo(issuer: &Issuer) -> Result<Option<String>> {
385 let Some(src_raw) = &issuer.logo_path else {
386 return Ok(None);
387 };
388 let src_expanded = expand_tilde(src_raw);
389 let src = Path::new(&src_expanded);
390 if !src.exists() {
391 eprintln!("warning: logo '{}' not found for issuer '{}' — rendering without", src.display(), issuer.slug);
393 return Ok(None);
394 }
395 let ext = src
396 .extension()
397 .and_then(|e| e.to_str())
398 .unwrap_or("png")
399 .to_lowercase();
400 let dest_rel = format!("shared/logo-{}.{ext}", issuer.slug);
401 let dest_abs = typst_assets::project_root()?.join(&dest_rel);
402 if let Some(parent) = dest_abs.parent() {
403 std::fs::create_dir_all(parent)?;
404 }
405 let needs_copy = match (std::fs::metadata(src), std::fs::metadata(&dest_abs)) {
407 (Ok(a), Ok(b)) => a.modified().ok() > b.modified().ok(),
408 _ => true,
409 };
410 if needs_copy {
411 std::fs::copy(src, &dest_abs)?;
412 }
413 Ok(Some(format!("/{dest_rel}")))
414}
415
416fn expand_tilde(s: &str) -> String {
417 if let Some(rest) = s.strip_prefix("~/") {
418 if let Ok(home) = std::env::var("HOME") {
419 return format!("{home}/{rest}");
420 }
421 }
422 s.to_string()
423}
424
425fn inject_sample_data(data: &InvoiceData) -> Result<()> {
426 let shared = typst_assets::shared_dir()?;
427 let invoice_path = shared.join("invoice.typ");
428 let helpers_path = shared.join("_helpers.inc.typ");
429 let helpers = std::fs::read_to_string(&helpers_path)
430 .map_err(|e| AppError::Render(format!("missing _helpers.inc.typ: {e}")))?;
431
432 let sample = generate_sample_data_typ(data);
433 let full = format!(
434 "// Auto-generated by invoice-cli. Manual edits will be overwritten.\n\n{}\n\n{}",
435 sample, helpers
436 );
437 std::fs::write(&invoice_path, full)?;
438 Ok(())
439}
440
441fn generate_sample_data_typ(d: &InvoiceData) -> String {
442 format!(
443 "#let sample-data = (\n issuer: {},\n client: {},\n invoice: {},\n items: {},\n totals-override: {},\n notes: {},\n qr: {},\n)",
444 typst_dict_issuer(&d.issuer),
445 typst_dict_client(&d.client),
446 typst_dict_invoice(&d.invoice),
447 typst_array_items(&d.items),
448 typst_dict_totals(&d.totals),
449 typst_string(&d.notes),
450 typst_qr(&d.qr),
451 )
452}
453
454fn typst_dict_totals(t: &TotalsData) -> String {
455 let tax_lines: Vec<String> = t
456 .tax_lines
457 .iter()
458 .map(|tl| format!("(rate: {}, base: {}, amount: {})", tl.rate, tl.base, tl.amount))
459 .collect();
460 let tax_lines_str = if tax_lines.is_empty() {
461 "()".to_string()
462 } else {
463 format!("({},)", tax_lines.join(", "))
464 };
465 format!(
466 "(subtotal: {}, tax-lines: {}, tax-total: {}, total: {}, discount: {}, discount-label: {})",
467 t.subtotal,
468 tax_lines_str,
469 t.tax_total,
470 t.total,
471 t.discount.map(|v| v.to_string()).unwrap_or_else(|| "none".into()),
472 typst_opt(&t.discount_label),
473 )
474}
475
476fn typst_qr(qr: &Option<QrData>) -> String {
477 match qr {
478 None => "none".into(),
479 Some(q) => {
480 let rows: Vec<String> = q
481 .modules
482 .iter()
483 .map(|row| {
484 let cells: Vec<&str> = row
485 .iter()
486 .map(|&b| if b { "true" } else { "false" })
487 .collect();
488 format!("({})", cells.join(", "))
489 })
490 .collect();
491 format!(
492 "(modules: ({}), size: {}, label: {})",
493 rows.join(", "),
494 q.size,
495 typst_string(&q.label),
496 )
497 }
498 }
499}
500
501fn typst_string(s: &str) -> String {
502 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
503 format!("\"{}\"", escaped)
504}
505
506fn typst_opt(s: &Option<String>) -> String {
507 match s {
508 Some(v) => typst_string(v),
509 None => "none".into(),
510 }
511}
512
513fn typst_array(lines: &[String]) -> String {
514 let items: Vec<String> = lines.iter().map(|l| typst_string(l)).collect();
518 if items.is_empty() {
519 return "()".into();
520 }
521 format!("({},)", items.join(", "))
522}
523
524fn typst_dict_issuer(i: &IssuerData) -> String {
525 let bank = match &i.bank {
526 Some(b) => format!(
527 "(name: {}, iban: {}, bic: {})",
528 typst_string(&b.name),
529 typst_string(&b.iban),
530 typst_string(&b.bic)
531 ),
532 None => "none".into(),
533 };
534 format!(
535 "(name: {}, legal-name: {}, tagline: {}, address: {}, email: {}, phone: {}, tax-id: {}, company-no: {}, bank: {}, logo: {})",
536 typst_string(&i.name),
537 typst_opt(&i.legal_name),
538 typst_opt(&i.tagline),
539 typst_array(&i.address),
540 typst_opt(&i.email),
541 typst_opt(&i.phone),
542 typst_opt(&i.tax_id),
543 typst_opt(&i.company_no),
544 bank,
545 typst_opt(&i.logo),
546 )
547}
548
549fn typst_dict_client(c: &ClientData) -> String {
550 format!(
551 "(name: {}, attn: {}, address: {}, tax-id: {})",
552 typst_string(&c.name),
553 typst_opt(&c.attn),
554 typst_array(&c.address),
555 typst_opt(&c.tax_id),
556 )
557}
558
559fn typst_dict_invoice(m: &InvoiceMeta) -> String {
560 format!(
561 "(number: {}, issue-date: {}, due-date: {}, terms: {}, currency: {}, symbol: {}, tax-label: {}, title: {}, reverse-charge: {}, kind: {}, credits-number: {})",
562 typst_string(&m.number),
563 typst_string(&m.issue_date),
564 typst_string(&m.due_date),
565 typst_string(&m.terms),
566 typst_string(&m.currency),
567 typst_string(&m.symbol),
568 typst_string(&m.tax_label),
569 typst_string(&m.title),
570 if m.reverse_charge { "true" } else { "false" },
571 typst_string(&m.kind),
572 typst_opt(&m.credits_number),
573 )
574}
575
576fn format_date(iso: &str, fmt: &str) -> String {
579 NaiveDate::parse_from_str(iso, "%Y-%m-%d")
580 .map(|d| d.format(fmt).to_string())
581 .unwrap_or_else(|_| iso.to_string())
582}
583
584fn typst_array_items(items: &[ItemData]) -> String {
585 let parts: Vec<String> = items
586 .iter()
587 .map(|it| {
588 format!(
589 "(description: {}, subtitle: {}, qty: {}, unit: {}, unit-price: {}, tax-rate: {}, amount: {}, gross: {}, discount: {}, discount-label: {})",
590 typst_string(&it.description),
591 typst_opt(&it.subtitle),
592 it.qty,
593 typst_string(&it.unit),
594 it.unit_price,
595 it.tax_rate,
596 it.amount,
597 it.gross.map(|v| v.to_string()).unwrap_or_else(|| "none".into()),
598 it.discount.map(|v| v.to_string()).unwrap_or_else(|| "none".into()),
599 typst_opt(&it.discount_label),
600 )
601 })
602 .collect();
603 if parts.is_empty() {
604 return "()".into();
605 }
606 format!("(\n {},\n )", parts.join(",\n "))
608}