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<BankData>,
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 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    /// "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 = 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    // 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)
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, // populated below by resolve_logo when rendering
231        },
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, // populated below
250        },
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
269/// Encode an arbitrary string (URL, EPC-QR payload, plain text) into a QR
270/// module matrix suitable for Typst rendering. Medium error-correction level
271/// (Q) — robust while keeping module count low for clean look.
272pub 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
302/// Convenience: build invoice data and attach a QR from `qr_data` if present.
303pub 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    // Copy logo into the assets dir so typst (sandboxed to --root=assets) can
341    // reach it. The dict field becomes a root-relative path like
342    // "/shared/logo-<slug>.png".
343    data.issuer.logo = resolve_logo(issuer)?;
344    // For credit notes, look up the referenced source invoice's number.
345    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
380/// Copy the issuer's logo file into `<assets>/shared/logo-<slug>.<ext>` so
381/// Typst can reference it (Typst is sandboxed to --root). Returns the
382/// root-relative path, or None if no logo is configured / the file doesn't
383/// exist.
384fn 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        // Configured but missing — warn by rendering without, don't hard-fail
392        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    // Copy only if source is newer or dest missing.
406    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    // Trailing comma is required — `("a")` is a string in Typst, `("a",)` is
515    // a single-element array. Always emit the comma so parsing is correct
516    // for any length ≥ 1.
517    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
576/// Convert ISO 8601 date to the jurisdiction's display format.
577/// Falls back to the original string if parsing fails.
578fn 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    // Trailing comma ensures single-element case parses as array, not tuple.
607    format!("(\n    {},\n  )", parts.join(",\n    "))
608}