invoice-cli 0.5.8

Beautiful invoices from the CLI — international, stateful, agent-friendly
Documentation
// ═══════════════════════════════════════════════════════════════════════════
// invoice-cli — static helpers shared by all templates.
// This file is bundled into shared/invoice.typ at render time alongside the
// generated sample-data block. Do not move or rename without updating the
// Rust renderer.
// ═══════════════════════════════════════════════════════════════════════════

#let money(amount, symbol: "S$", decimals: 2) = {
  let negative = amount < 0
  let a = if negative { -amount } else { amount }
  let whole = int(a)
  let frac = int(calc.round((a - whole) * calc.pow(10, decimals)))
  let s = str(whole)
  let chars = s.codepoints()
  let n = chars.len()
  let out = ""
  for i in range(n) {
    out += chars.at(i)
    let r = n - i - 1
    if r > 0 and calc.rem(r, 3) == 0 { out += "," }
  }
  let frac-str = str(frac)
  while frac-str.len() < decimals { frac-str = "0" + frac-str }
  let result = symbol + out + "." + frac-str
  if negative { "−" + result } else { result }
}

#let compute-totals(items) = {
  let subtotal = 0.0
  let by-rate = (:)
  for item in items {
    // Prefer the pre-computed `amount` (post-discount, from Rust) when
    // present; else fall back to qty × unit-price for legacy data.
    let line = if "amount" in item and item.amount != none { item.amount } else { item.qty * item.unit-price }
    subtotal += line
    let k = str(item.tax-rate)
    if k in by-rate {
      by-rate.insert(k, by-rate.at(k) + line)
    } else {
      by-rate.insert(k, line)
    }
  }
  let tax-lines = ()
  let tax-total = 0.0
  for (rate, base) in by-rate {
    let r = float(rate)
    let amt = base * r / 100.0
    tax-total += amt
    tax-lines.push((rate: r, base: base, amount: amt))
  }
  (
    subtotal: subtotal,
    tax-lines: tax-lines,
    tax-total: tax-total,
    total: subtotal + tax-total,
    discount: none,
    discount-label: none,
  )
}

// Use Rust-precomputed totals (precision + discount-aware) when available;
// otherwise compute from items. Templates call this rather than compute-totals
// directly so they always see consistent numbers.
#let resolve-totals(d) = {
  if "totals-override" in d and d.totals-override != none {
    d.totals-override
  } else {
    compute-totals(d.items)
  }
}

#let star-mark(size: 14pt, color: black) = {
  let s = size
  box(width: s, height: s, {
    place(top + left, polygon(
      fill: color,
      (s * 0.5, s * 0.0),
      (s * 0.58, s * 0.42),
      (s * 1.0, s * 0.5),
      (s * 0.58, s * 0.58),
      (s * 0.5, s * 1.0),
      (s * 0.42, s * 0.58),
      (s * 0.0, s * 0.5),
      (s * 0.42, s * 0.42),
    ))
  })
}

#let label(txt, size: 7.5pt, tracking: 0.8pt, fill: black, weight: "medium") = {
  text(size: size, weight: weight, fill: fill, tracking: tracking)[#upper(txt)]
}

#let hairline(color: rgb("#1a1a1a"), weight: 0.4pt) = line(length: 100%, stroke: weight + color)

#let tax-profiles = (
  uk:     (label: "VAT",        default-rate: 20.0, currency: "GBP", symbol: "£"),
  us:     (label: "Sales tax",  default-rate: 0.0,  currency: "USD", symbol: "$"),
  sg:     (label: "GST",        default-rate: 9.0,  currency: "SGD", symbol: "S$"),
  eu-de:  (label: "USt",        default-rate: 19.0, currency: "EUR", symbol: "€"),
  custom: (label: "Tax",        default-rate: 0.0,  currency: "",    symbol: ""),
)