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<BankBlock>,
50 pub logo: Option<String>,
52}
53
54#[derive(Debug, Serialize)]
55pub struct BankBlock {
56 pub lines: Vec<finance_core::entity::BankLine>,
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 =
139 line_total_discounted(it.qty, it.unit_price, it.discount_rate, it.discount_fixed);
140 let (disc_amt, disc_label) = if gross.0 != line.0 {
141 let diff = MinorUnits(gross.0 - line.0);
142 let label = if let Some(r) = it.discount_rate {
143 format!("rate:{}", r)
144 } else {
145 "fixed".into()
146 };
147 (Some(diff.as_major()), Some(label))
148 } else {
149 (None, None)
150 };
151
152 subtotal.0 += line.0;
153 let k = it.tax_rate.to_string();
154 let entry = by_rate.entry(k).or_insert(MinorUnits(0));
155 entry.0 += line.0;
156
157 items.push(ItemData {
158 description: it.description.clone(),
159 subtitle: it.subtitle.clone(),
160 qty: it.qty.to_f64().unwrap_or(0.0),
161 unit: it.unit.clone(),
162 unit_price: it.unit_price.as_major(),
163 tax_rate: it.tax_rate.to_f64().unwrap_or(0.0),
164 amount: line.as_major(),
165 gross: if disc_amt.is_some() {
166 Some(gross.as_major())
167 } else {
168 None
169 },
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) / (subtotal.0 as i128))
197 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_details.as_deref().and_then(|details| {
224 let lines = finance_core::entity::BankLine::parse_all(details);
225 if lines.is_empty() {
226 None
227 } else {
228 Some(BankBlock { lines })
229 }
230 }),
231 logo: None, },
233 client: ClientData {
234 name: client.name.clone(),
235 attn: client.attn.clone(),
236 address: client.address.clone(),
237 tax_id: client.tax_id.clone(),
238 },
239 invoice: InvoiceMeta {
240 number: inv.number.clone(),
241 issue_date: format_date(&inv.issue_date, profile.date_format),
242 due_date: format_date(&inv.due_date, profile.date_format),
243 terms: inv.terms.clone(),
244 currency: inv.currency.clone(),
245 symbol: inv.symbol.clone(),
246 tax_label: inv.tax_label.clone(),
247 title,
248 reverse_charge: inv.reverse_charge,
249 kind: if inv.kind == "credit_note" {
250 "credit-note".into()
251 } else {
252 "invoice".into()
253 },
254 credits_number: None, },
256 items,
257 totals: TotalsData {
258 subtotal: subtotal.as_major(),
259 tax_lines,
260 tax_total: tax_total.as_major(),
261 total: total.as_major(),
262 discount: if inv_discount_minor > 0 {
263 Some(MinorUnits(inv_discount_minor).as_major())
264 } else {
265 None
266 },
267 discount_label: inv_discount_label,
268 },
269 notes: inv.notes.clone().unwrap_or_default(),
270 qr: None,
271 }
272}
273
274pub fn encode_qr(data: &str) -> Option<QrData> {
278 if data.is_empty() {
279 return None;
280 }
281 let code =
282 qrcode::QrCode::with_error_correction_level(data.as_bytes(), qrcode::EcLevel::Q).ok()?;
283 let width = code.width();
284 let colors = code.to_colors();
285 let modules: Vec<Vec<bool>> = (0..width)
286 .map(|row| {
287 (0..width)
288 .map(|col| matches!(colors[row * width + col], qrcode::Color::Dark))
289 .collect()
290 })
291 .collect();
292 Some(QrData {
293 modules,
294 size: width as u32,
295 label: "Pay online".to_string(),
296 })
297}
298
299pub fn build_data_with_qr(
301 inv: &Invoice,
302 issuer: &Issuer,
303 client: &Client,
304 qr_data: Option<&str>,
305) -> InvoiceData {
306 let mut data = build_data(inv, issuer, client);
307 data.qr = qr_data.and_then(encode_qr);
308 data
309}
310
311pub fn render_invoice(
312 template: &str,
313 inv: &Invoice,
314 issuer: &Issuer,
315 client: &Client,
316 out_path: &Path,
317) -> Result<()> {
318 render_invoice_with_qr(template, inv, issuer, client, None, out_path)
319}
320
321pub fn render_invoice_with_qr(
322 template: &str,
323 inv: &Invoice,
324 issuer: &Issuer,
325 client: &Client,
326 qr_data: Option<&str>,
327 out_path: &Path,
328) -> Result<()> {
329 typst_assets::ensure_extracted()?;
330 if !typst_assets::has_template(template)? {
331 return Err(AppError::InvalidInput(format!(
332 "template '{template}' not found. Run: invoice template list"
333 )));
334 }
335
336 let mut data = build_data_with_qr(inv, issuer, client, qr_data);
337 data.issuer.logo = resolve_logo(issuer)?;
341 if inv.kind == "credit_note" {
343 if let Some(src_id) = inv.credits_invoice_id {
344 if let Ok(conn) = crate::db::open() {
345 if let Ok(list) = db::invoice_list(&conn, None, None) {
346 if let Some(src) = list.into_iter().find(|x| x.id == src_id) {
347 data.invoice.credits_number = Some(src.number);
348 }
349 }
350 }
351 }
352 }
353 let render_root = prepare_render_root(&data)?;
354 let root = render_root.path();
355 let template_path = root.join("templates").join(format!("{template}.typ"));
356
357 let status = Command::new("typst")
358 .arg("compile")
359 .arg("--root")
360 .arg(root)
361 .arg(&template_path)
362 .arg(out_path)
363 .status()
364 .map_err(|e| AppError::Render(format!("typst binary not found: {e}")))?;
365
366 if !status.success() {
367 return Err(AppError::Render(format!(
368 "typst compile exited with {}",
369 status.code().unwrap_or(-1)
370 )));
371 }
372
373 Ok(())
374}
375
376fn prepare_render_root(data: &InvoiceData) -> Result<tempfile::TempDir> {
377 let source_root = typst_assets::project_root()?;
378 let render_root = tempfile::Builder::new()
379 .prefix("invoice-cli-render-")
380 .tempdir()?;
381 copy_dir_contents(&source_root, render_root.path())?;
382 inject_sample_data(render_root.path(), data)?;
383 Ok(render_root)
384}
385
386fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
387 std::fs::create_dir_all(dst)?;
388 for entry in std::fs::read_dir(src)? {
389 let entry = entry?;
390 let src_path = entry.path();
391 let dst_path = dst.join(entry.file_name());
392 if src_path.is_dir() {
393 copy_dir_contents(&src_path, &dst_path)?;
394 } else {
395 std::fs::copy(&src_path, &dst_path)?;
396 }
397 }
398 Ok(())
399}
400
401fn resolve_logo(issuer: &Issuer) -> Result<Option<String>> {
406 let Some(src_raw) = &issuer.logo_path else {
407 return Ok(None);
408 };
409 let src_expanded = expand_tilde(src_raw);
410 let src = Path::new(&src_expanded);
411 if !src.exists() {
412 eprintln!(
414 "warning: logo '{}' not found for issuer '{}' — rendering without",
415 src.display(),
416 issuer.slug
417 );
418 return Ok(None);
419 }
420 let ext = src
421 .extension()
422 .and_then(|e| e.to_str())
423 .unwrap_or("png")
424 .to_lowercase();
425 let dest_rel = format!("shared/logo-{}.{ext}", issuer.slug);
426 let dest_abs = typst_assets::project_root()?.join(&dest_rel);
427 if let Some(parent) = dest_abs.parent() {
428 std::fs::create_dir_all(parent)?;
429 }
430 let needs_copy = match (std::fs::metadata(src), std::fs::metadata(&dest_abs)) {
432 (Ok(a), Ok(b)) => a.modified().ok() > b.modified().ok(),
433 _ => true,
434 };
435 if needs_copy {
436 std::fs::copy(src, &dest_abs)?;
437 }
438 Ok(Some(format!("/{dest_rel}")))
439}
440
441pub fn expand_tilde(s: &str) -> String {
442 if let Some(rest) = s.strip_prefix("~/") {
443 if let Ok(home) = std::env::var("HOME") {
444 return format!("{home}/{rest}");
445 }
446 }
447 s.to_string()
448}
449
450pub fn default_invoice_dir() -> std::path::PathBuf {
455 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
456 std::path::PathBuf::from(home)
457 .join("Documents")
458 .join("Invoices")
459}
460
461fn inject_sample_data(root: &Path, data: &InvoiceData) -> Result<()> {
462 let shared = root.join("shared");
463 let invoice_path = shared.join("invoice.typ");
464 let helpers_path = shared.join("_helpers.inc.typ");
465 let helpers = std::fs::read_to_string(&helpers_path)
466 .map_err(|e| AppError::Render(format!("missing _helpers.inc.typ: {e}")))?;
467
468 let sample = generate_sample_data_typ(data);
469 let full = format!(
470 "// Auto-generated by invoice-cli. Manual edits will be overwritten.\n\n{}\n\n{}",
471 sample, helpers
472 );
473 std::fs::write(&invoice_path, full)?;
474 Ok(())
475}
476
477fn generate_sample_data_typ(d: &InvoiceData) -> String {
478 format!(
479 "#let sample-data = (\n issuer: {},\n client: {},\n invoice: {},\n items: {},\n totals-override: {},\n notes: {},\n qr: {},\n)",
480 typst_dict_issuer(&d.issuer),
481 typst_dict_client(&d.client),
482 typst_dict_invoice(&d.invoice),
483 typst_array_items(&d.items),
484 typst_dict_totals(&d.totals),
485 typst_string(&d.notes),
486 typst_qr(&d.qr),
487 )
488}
489
490fn typst_dict_totals(t: &TotalsData) -> String {
491 let tax_lines: Vec<String> = t
492 .tax_lines
493 .iter()
494 .map(|tl| {
495 format!(
496 "(rate: {}, base: {}, amount: {})",
497 tl.rate, tl.base, tl.amount
498 )
499 })
500 .collect();
501 let tax_lines_str = if tax_lines.is_empty() {
502 "()".to_string()
503 } else {
504 format!("({},)", tax_lines.join(", "))
505 };
506 format!(
507 "(subtotal: {}, tax-lines: {}, tax-total: {}, total: {}, discount: {}, discount-label: {})",
508 t.subtotal,
509 tax_lines_str,
510 t.tax_total,
511 t.total,
512 t.discount
513 .map(|v| v.to_string())
514 .unwrap_or_else(|| "none".into()),
515 typst_opt(&t.discount_label),
516 )
517}
518
519fn typst_qr(qr: &Option<QrData>) -> String {
520 match qr {
521 None => "none".into(),
522 Some(q) => {
523 let rows: Vec<String> = q
524 .modules
525 .iter()
526 .map(|row| {
527 let cells: Vec<&str> = row
528 .iter()
529 .map(|&b| if b { "true" } else { "false" })
530 .collect();
531 format!("({})", cells.join(", "))
532 })
533 .collect();
534 format!(
535 "(modules: ({}), size: {}, label: {})",
536 rows.join(", "),
537 q.size,
538 typst_string(&q.label),
539 )
540 }
541 }
542}
543
544fn typst_string(s: &str) -> String {
545 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
546 format!("\"{}\"", escaped)
547}
548
549fn typst_opt(s: &Option<String>) -> String {
550 match s {
551 Some(v) => typst_string(v),
552 None => "none".into(),
553 }
554}
555
556fn typst_array(lines: &[String]) -> String {
557 let items: Vec<String> = lines.iter().map(|l| typst_string(l)).collect();
561 if items.is_empty() {
562 return "()".into();
563 }
564 format!("({},)", items.join(", "))
565}
566
567fn typst_dict_issuer(i: &IssuerData) -> String {
568 let bank = match &i.bank {
569 Some(b) => {
570 let line_dicts: Vec<String> = b
571 .lines
572 .iter()
573 .map(|l| {
574 format!(
575 "(label: {}, value: {})",
576 typst_string(&l.label),
577 typst_string(&l.value)
578 )
579 })
580 .collect();
581 let lines_array = if line_dicts.is_empty() {
583 "()".into()
584 } else {
585 format!("({},)", line_dicts.join(", "))
586 };
587 format!("(lines: {lines_array})")
588 }
589 None => "none".into(),
590 };
591 format!(
592 "(name: {}, legal-name: {}, tagline: {}, address: {}, email: {}, phone: {}, tax-id: {}, company-no: {}, bank: {}, logo: {})",
593 typst_string(&i.name),
594 typst_opt(&i.legal_name),
595 typst_opt(&i.tagline),
596 typst_array(&i.address),
597 typst_opt(&i.email),
598 typst_opt(&i.phone),
599 typst_opt(&i.tax_id),
600 typst_opt(&i.company_no),
601 bank,
602 typst_opt(&i.logo),
603 )
604}
605
606fn typst_dict_client(c: &ClientData) -> String {
607 format!(
608 "(name: {}, attn: {}, address: {}, tax-id: {})",
609 typst_string(&c.name),
610 typst_opt(&c.attn),
611 typst_array(&c.address),
612 typst_opt(&c.tax_id),
613 )
614}
615
616fn typst_dict_invoice(m: &InvoiceMeta) -> String {
617 format!(
618 "(number: {}, issue-date: {}, due-date: {}, terms: {}, currency: {}, symbol: {}, tax-label: {}, title: {}, reverse-charge: {}, kind: {}, credits-number: {})",
619 typst_string(&m.number),
620 typst_string(&m.issue_date),
621 typst_string(&m.due_date),
622 typst_string(&m.terms),
623 typst_string(&m.currency),
624 typst_string(&m.symbol),
625 typst_string(&m.tax_label),
626 typst_string(&m.title),
627 if m.reverse_charge { "true" } else { "false" },
628 typst_string(&m.kind),
629 typst_opt(&m.credits_number),
630 )
631}
632
633fn format_date(iso: &str, fmt: &str) -> String {
636 NaiveDate::parse_from_str(iso, "%Y-%m-%d")
637 .map(|d| d.format(fmt).to_string())
638 .unwrap_or_else(|_| iso.to_string())
639}
640
641fn typst_array_items(items: &[ItemData]) -> String {
642 let parts: Vec<String> = items
643 .iter()
644 .map(|it| {
645 format!(
646 "(description: {}, subtitle: {}, qty: {}, unit: {}, unit-price: {}, tax-rate: {}, amount: {}, gross: {}, discount: {}, discount-label: {})",
647 typst_string(&it.description),
648 typst_opt(&it.subtitle),
649 it.qty,
650 typst_string(&it.unit),
651 it.unit_price,
652 it.tax_rate,
653 it.amount,
654 it.gross.map(|v| v.to_string()).unwrap_or_else(|| "none".into()),
655 it.discount.map(|v| v.to_string()).unwrap_or_else(|| "none".into()),
656 typst_opt(&it.discount_label),
657 )
658 })
659 .collect();
660 if parts.is_empty() {
661 return "()".into();
662 }
663 format!("(\n {},\n )", parts.join(",\n "))
665}