Skip to main content

invoice_cli/
render.rs

1// ═══════════════════════════════════════════════════════════════════════════
2// Render — generates shared/invoice.typ from DB data + static helpers,
3// then shells out to the `typst` binary to produce the PDF.
4// ═══════════════════════════════════════════════════════════════════════════
5
6use 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    /// Optional QR code matrix (boolean grid) generated from `invoice.pay_link`
28    /// or any other `qr_data`. `None` means no QR rendered for this invoice.
29    pub qr: Option<QrData>,
30}
31
32#[derive(Debug, Serialize)]
33pub struct QrData {
34    pub modules: Vec<Vec<bool>>,
35    pub size: u32,     // module count per side
36    pub label: String, // shown below the code ("Pay online", "Scan to pay", etc.)
37}
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    /// Typst-resolvable logo path (relative to --root). None when no logo.
51    pub logo: Option<String>,
52}
53
54#[derive(Debug, Serialize)]
55pub struct BankBlock {
56    /// Parsed Label/Value rows from Issuer::bank_details. Each line is
57    /// rendered as one row in the two-column payment block on the PDF.
58    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    /// "invoice" or "credit-note" (kebab-case for Typst-friendliness)
81    pub kind: String,
82    /// When kind == "credit-note", the source invoice number to reference.
83    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    /// Pre-discount line value (qty * unit_price) when a discount applies.
96    /// None when there is no discount on this line.
97    pub gross: Option<f64>,
98    /// Discount amount (positive number in major units) — either from a rate
99    /// or a fixed value, whichever applied. None when no discount.
100    pub discount: Option<f64>,
101    /// "rate:10" or "fixed" — for template styling.
102    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    /// Invoice-level discount in major units, if any. Applied between
112    /// subtotal and tax — i.e. subtotal_after_discount = subtotal - discount.
113    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    // Invoice-level discount: apply to the pre-tax subtotal (and proportionally
176    // adjust the tax bases so tax is calculated on post-discount amounts).
177    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    // Scale each tax base by (subtotal_after / subtotal_before) to keep tax
188    // accurate when an invoice-level discount applies. If subtotal is zero,
189    // no scaling happens.
190    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, // populated below by resolve_logo when rendering
232        },
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, // populated below
255        },
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
274/// Encode an arbitrary string (URL, EPC-QR payload, plain text) into a QR
275/// module matrix suitable for Typst rendering. Medium error-correction level
276/// (Q) — robust while keeping module count low for clean look.
277pub 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
299/// Convenience: build invoice data and attach a QR from `qr_data` if present.
300pub 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    // Copy logo into the assets dir so typst (sandboxed to --root=assets) can
338    // reach it. The dict field becomes a root-relative path like
339    // "/shared/logo-<slug>.png".
340    data.issuer.logo = resolve_logo(issuer)?;
341    // For credit notes, look up the referenced source invoice's number.
342    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
401/// Copy the issuer's logo file into `<assets>/shared/logo-<slug>.<ext>` so
402/// Typst can reference it (Typst is sandboxed to --root). Returns the
403/// root-relative path, or None if no logo is configured / the file doesn't
404/// exist.
405fn 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        // Configured but missing — warn by rendering without, don't hard-fail
413        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    // Copy only if source is newer or dest missing.
431    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
450/// Default directory for rendered invoice PDFs when the issuer has no
451/// `default_output_dir` set and the user didn't pass `--out`.
452/// Macs: `~/Documents/Invoices/`; Linux: `$XDG_DOCUMENTS_DIR/Invoices/`
453/// (falls back to `~/Documents/Invoices/`).
454pub 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    // Trailing comma is required — `("a")` is a string in Typst, `("a",)` is
558    // a single-element array. Always emit the comma so parsing is correct
559    // for any length ≥ 1.
560    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            // Trailing comma so single-line is treated as 1-array not string.
582            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
633/// Convert ISO 8601 date to the jurisdiction's display format.
634/// Falls back to the original string if parsing fails.
635fn 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    // Trailing comma ensures single-element case parses as array, not tuple.
664    format!("(\n    {},\n  )", parts.join(",\n    "))
665}