commit 85b040a75c6185c4c5f0f0bd16f3aba4901e64fc
parent 46fb385b853733fa54234ae5cdf990a36a18c879
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 20 Feb 2026 23:44:13 +0100
add template for PDF for GET /private/orders
Diffstat:
2 files changed, 178 insertions(+), 0 deletions(-)
diff --git a/contrib/typst/Makefile.am b/contrib/typst/Makefile.am
@@ -2,6 +2,7 @@ SUBDIRS = .
formdatadir = $(datadir)/taler-merchant/typst-forms/
dist_formdata_DATA = \
+ orders.typ \
transactions.typ
EXTRA_DIST = \
diff --git a/contrib/typst/orders.typ b/contrib/typst/orders.typ
@@ -0,0 +1,177 @@
+// Helper function to format a Taler timestamp object {t_s: ...}
+#let format_timestamp(ts) = {
+ if type(ts) == dictionary and "t_s" in ts {
+ let t_s = ts.t_s
+ if t_s == "never" {
+ "never"
+ } else {
+ let seconds = int(t_s)
+ let days_since_epoch = calc.quo(seconds, 86400)
+ let remaining_seconds = calc.rem(seconds, 86400)
+ let hours = calc.quo(remaining_seconds, 3600)
+ let minutes = calc.quo(calc.rem(remaining_seconds, 3600), 60)
+ let secs = calc.rem(remaining_seconds, 60)
+
+ let is_leap(y) = {
+ calc.rem(y, 4) == 0 and (calc.rem(y, 100) != 0 or calc.rem(y, 400) == 0)
+ }
+
+ let year = 1970
+ let days_left = days_since_epoch
+ let done = false
+ while not done {
+ let days_in_year = if is_leap(year) { 366 } else { 365 }
+ if days_left >= days_in_year {
+ days_left = days_left - days_in_year
+ year = year + 1
+ } else {
+ done = true
+ }
+ }
+
+ let days_in_months = if is_leap(year) {
+ (31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ } else {
+ (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ }
+
+ let month = 1
+ for days_in_month in days_in_months {
+ if days_left >= days_in_month {
+ days_left = days_left - days_in_month
+ month = month + 1
+ } else {
+ break
+ }
+ }
+ let day = days_left + 1
+
+ let m_str = if month < 10 { "0" + str(month) } else { str(month) }
+ let d_str = if day < 10 { "0" + str(day) } else { str(day) }
+ let h_str = if hours < 10 { "0" + str(hours) } else { str(hours) }
+ let min_str = if minutes < 10 { "0" + str(minutes) } else { str(minutes) }
+ let s_str = if secs < 10 { "0" + str(secs) } else { str(secs) }
+
+ str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + ":" + s_str + " UTC"
+ }
+ } else {
+ str(ts)
+ }
+}
+
+// Format a Taler amount.
+// Taler serialises amounts as a plain string "CURRENCY:VALUE.FRACTION",
+// e.g. "EUR:5.50". If the value is null / none we render a dash.
+#let format_amount(a) = {
+ if a == none {
+ "-"
+ } else {
+ // a is already a human-readable string like "EUR:5.50"
+ str(a)
+ }
+}
+
+#let form(data) = {
+ set page(
+ paper: "a4",
+ margin: (left: 2cm, right: 2cm, top: 2cm, bottom: 2.5cm),
+ footer: context [
+ #grid(
+ columns: (1fr, 1fr),
+ align: (left, right),
+ text(size: 8pt)[],
+ text(size: 8pt)[
+ Page #here().page() of #counter(page).final().first()
+ ]
+ )
+ ]
+ )
+
+ heading(level: 1)[GNU Taler Merchant Orders: #data.business_name]
+
+ v(0.5cm)
+
+ table(
+ columns: (auto, auto, auto, auto, auto, auto, auto),
+ align: (left, left, left, right, right, center, center),
+ // Header row
+ table.header(
+ [*Order ID*],
+ // [*Row*],
+ [*Summary*],
+ [*Timestamp*],
+ [*Amount*],
+ [*Refund*],
+ // [*Pending refund*],
+ [*Refundable*],
+ [*Paid*],
+ ),
+ // Data rows
+ ..data.orders.map(o => (
+ o.order_id,
+ // str(o.row_id),
+ o.summary,
+ format_timestamp(o.timestamp),
+ format_amount(o.amount),
+ format_amount(o.at("refund_amount", default: none)),
+ // format_amount(o.at("pending_refund_amount", default: none)),
+ if o.refundable { "✓" } else { "✗" },
+ if o.paid { "✓" } else { "✗" },
+ )).flatten(),
+ // Bold totals row spanning the label across the first 4 columns,
+ // then the three amount totals, then two empty trailing cells.
+ table.footer(
+ table.cell(colspan: 3)[*Totals*],
+ table.cell(align: right)[
+ *#format_amount(data.total_amount)*
+ ],
+ table.cell(align: right)[
+ *#format_amount(data.total_refund_amount)*
+ ],
+ // table.cell(align: right)[
+ // *#format_amount(data.total_pending_refund_amount)*
+ // ],
+ [],
+ [],
+ ),
+ )
+}
+
+// Example usage:
+#form((
+ business_name: "example.com",
+ orders: (
+ (
+ order_id: "2025.001",
+ row_id: 1,
+ summary: "Some purchase",
+ timestamp: (t_s: 1764967786),
+ amount: "EUR:10.00",
+ paid: true,
+ refundable: false,
+ ),
+ (
+ order_id: "2025.002",
+ row_id: 2,
+ summary: "Refunded order",
+ timestamp: (t_s: 1764970000),
+ amount: "EUR:5.50",
+ refund_amount: "EUR:2.00",
+ pending_refund_amount: "EUR:1.00",
+ paid: true,
+ refundable: true,
+ ),
+ (
+ order_id: "2025.003",
+ row_id: 3,
+ summary: "Another order",
+ timestamp: (t_s: 1764975000),
+ amount: "EUR:3.25",
+ paid: false,
+ refundable: false,
+ ),
+ ),
+ total_amount: "EUR:18.75",
+ total_refund_amount: "EUR:2.00",
+ total_pending_refund_amount: "EUR:1.00",
+))