Skip to content

Instantly share code, notes, and snippets.

@felixdorn
Created February 4, 2026 15:39
Show Gist options
  • Select an option

  • Save felixdorn/b702f7ba7da472d21e50a19c36b9bbdc to your computer and use it in GitHub Desktop.

Select an option

Save felixdorn/b702f7ba7da472d21e50a19c36b9bbdc to your computer and use it in GitHub Desktop.
// Variables injected by the generator
#let invoice_number = "{{.Number}}"
#let date_year = {{.DateYear}}
#let date_month = {{.DateMonth}}
#let date_day = {{.DateDay}}
#let payment_terms = "{{.PaymentTerms}}"
#let due_year = {{.DueYear}}
#let due_month = {{.DueMonth}}
#let due_day = {{.DueDay}}
#let client_name = "{{.ClientName}}"
#let client_address = "{{.ClientAddress}}"
#let client_email = "{{.ClientEmail}}"
#let client_extra = "{{.ClientExtra}}"
#let business_name = "{{.BusinessName}}"
#let business_address = "{{.BusinessAddress}}"
#let business_email = "{{.BusinessEmail}}"
#let business_phone = "{{.BusinessPhone}}"
#let business_siret = "{{.BusinessSIRET}}"
#let total = "{{.Total}}".replace("\\$", "$")
#let currency = "{{.Currency}}"
#let lang = "{{.Language}}"
#let account_holder = "{{.AccountHolder}}"
#let account_bic = "{{.AccountBIC}}"
#let account_iban = "{{.AccountIBAN}}"
#let account_number = "{{.AccountNumber}}"
#let routing_number = "{{.RoutingNumber}}"
#let account_type = "{{.AccountType}}"
#let bank_name = "{{.BankName}}"
#let bank_address = "{{.BankAddress}}"
#let reference = "{{.Reference}}"
#let internal_ref = "{{.InternalRef}}"
// Line items (injected)
{{.LineItemsTypst}}
// Design tokens
#let accent = rgb("#0065d8")
#let gray-50 = rgb("#f9fafb")
#let gray-200 = rgb("#e5e7eb")
#let gray-400 = rgb("#9ca3af")
#let gray-500 = rgb("#6b7280")
#let gray-900 = rgb("#111827")
// Document setup
#set page(
margin: (x: 50pt, y: 50pt),
footer: align(left)[#text(size: 8pt, fill: gray-400)[Internal reference: #internal_ref]]
)
#set text(font: "Inter", size: 9.5pt, fill: gray-900)
#set par(leading: 0.65em)
// French month names
#let french_months = (
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
)
// Date formatting function
#let format_date(year, month, day, language) = {
if language == "FR" {
[#day #french_months.at(month - 1) #year]
} else {
let date = datetime(year: year, month: month, day: day)
date.display("[day] [month repr:long] [year]")
}
}
// Due date formatting (handles payment terms)
#let format_due_date(terms, year, month, day, language) = {
if terms == "DUE_ON_RECEIPT" {
if language == "FR" { "À réception" } else { "Due on receipt" }
} else {
format_date(year, month, day, language)
}
}
// Translations
#let labels = if lang == "FR" {
(
invoice: "Facture",
from: "Émetteur",
to: "Destinataire",
date_issued: "Date d'émission",
date_due: "Date d'échéance",
invoice_number: "Numéro",
line_items: "Prestations",
description: "Description",
total: "Total",
siret_prefix: "SIRET",
)
} else {
(
invoice: "Invoice",
from: "From",
to: "Bill to",
date_issued: "Issued",
date_due: "Due",
invoice_number: "Number",
line_items: "Line items",
description: "Description",
total: "Total",
siret_prefix: "SIRET",
)
}
// PDF metadata
#set document(
title: labels.invoice + " #" + invoice_number,
author: business_name,
keywords: (internal_ref,),
)
// Section label helper (for From/To)
#let section-label(content) = {
text(size: 9.5pt, fill: gray-400, weight: "semibold")[#content]
}
// Section heading helper (for Line items/Payment details)
#let section-heading(content) = {
text(size: 14pt, weight: "semibold")[#content]
}
// ============ HEADER ============
#grid(
columns: (1fr, auto),
align: (left + horizon, right + horizon),
[
#text(size: 28pt, weight: "bold", fill: accent)[#labels.invoice]#text(size: 28pt, weight: "light", fill: gray-400)[\##invoice_number]
],
[
#set text(size: 9pt)
#table(
columns: (auto, auto),
column-gutter: 10pt,
stroke: none,
inset: (x: 0pt, y: 4pt),
align: (right, left),
text(fill: gray-500)[#labels.date_due], [*#format_due_date(payment_terms, due_year, due_month, due_day, lang)*],
text(fill: gray-500)[#labels.date_issued], [#format_date(date_year, date_month, date_day, lang)],
)
]
)
#v(4pt)
#line(length: 100%, stroke: 2.5pt + accent)
#v(24pt)
// ============ FROM / TO ============
#grid(
columns: (1fr, 1fr),
gutter: 40pt,
[
#section-label(labels.from)
#v(6pt)
#text(weight: "semibold")[#business_name.replace(" (EI)", "")]
#v(2pt)
#set text(size: 9pt)
#business_address \
#business_email \
#business_phone
#v(6pt)
#labels.siret_prefix: #business_siret
],
[
#section-label(labels.to)
#v(6pt)
#text(weight: "semibold")[#client_name]
#v(2pt)
#set text(size: 9pt)
#client_address \
#client_email
#if client_extra != "" [
#v(6pt)
RNA: #client_extra.replace("Numéro RNA: ", "").replace("RNA: ", "")
]
]
)
#v(32pt)
// ============ LINE ITEMS ============
#section-label(labels.line_items)
#table(
columns: (1fr, auto),
stroke: none,
inset: (x: 12pt, y: 10pt),
fill: (_, row) => if row == 0 { gray-50 } else { none },
table.hline(stroke: 1pt + gray-200),
[*#labels.description*], align(right)[*#labels.total*],
table.hline(stroke: 1pt + gray-200),
..line_items.map(item => (item.description, align(right, item.total))).flatten(),
table.hline(stroke: 1pt + gray-200),
)
#v(12pt)
// ============ TOTAL ============
#align(right)[
#rect(
fill: rgb("#dbeafe"),
stroke: 1pt + accent,
inset: (x: 20pt, y: 12pt),
)[
#text(fill: rgb("#1c398e"), size: 10pt)[#labels.total: ]
#text(fill: rgb("#1c398e"), size: 14pt, weight: "bold")[#total]
]
]
// TVA notice for French invoices
#if lang == "FR" [
#v(4pt)
#align(right)[
#text(size: 8pt, fill: gray-500, style: "italic")[TVA non applicable, art. 293 B du CGI]
]
]
#v(28pt)
// ============ PAYMENT DETAILS ============
#let payment_labels = if lang == "FR" {
(
title: "Informations de paiement",
holder: "Titulaire",
bic: "BIC",
iban: "IBAN",
account_number: "Numéro de compte",
routing_number: "Routing",
bank: "Banque",
reference: "Référence",
)
} else {
(
title: "Payment details",
holder: "Account holder",
bic: "Swift/BIC",
iban: "IBAN",
account_number: "Account number",
routing_number: "Routing number",
bank: "Bank",
reference: "Reference",
)
}
#section-label(payment_labels.title)
#let payment-rows = if account_type == "US_DOMESTIC" {
(
([*#payment_labels.holder*], account_holder),
([*#payment_labels.account_number*], text(font: "JetBrains Mono")[#account_number]),
([*#payment_labels.routing_number*], text(font: "JetBrains Mono")[#routing_number]),
([*#payment_labels.bic*], text(font: "JetBrains Mono")[#account_bic]),
([*#payment_labels.bank*], [#bank_name, #bank_address]),
([*#payment_labels.reference*], reference),
)
} else {
(
([*#payment_labels.holder*], account_holder),
([*#payment_labels.iban*], text(font: "JetBrains Mono")[#account_iban]),
([*#payment_labels.bic*], text(font: "JetBrains Mono")[#account_bic]),
([*#payment_labels.bank*], [#bank_name, #bank_address]),
([*#payment_labels.reference*], reference),
)
}
#table(
columns: (120pt, 1fr),
stroke: none,
inset: (x: 12pt, y: 10pt),
fill: (_, row) => if calc.odd(row) { gray-50 } else { white },
table.hline(stroke: 1pt + gray-200),
..payment-rows.map(row => (
table.vline(x: 1, stroke: 1pt + gray-200),
row.at(0),
row.at(1),
table.hline(stroke: 1pt + gray-200),
)).flatten(),
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment