merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit 61a43265656d1530b745606c15615c871d284394
parent cd7ee096333262ace0d8117b4846c171752f4dfa
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  1 Jan 2026 13:45:41 +0100

first transaction chart template

Diffstat:
Mconfigure.ac | 1+
Acontrib/typst/Makefile.am | 8++++++++
Acontrib/typst/transactions.typ | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 364 insertions(+), 0 deletions(-)

diff --git a/configure.ac b/configure.ac @@ -494,6 +494,7 @@ AM_CONDITIONAL([HAVE_EXPERIMENTAL], [test "x$enable_experimental" = "xyes"]) AC_CONFIG_FILES([Makefile contrib/Makefile +contrib/typst/Makefile doc/Makefile doc/doxygen/Makefile src/Makefile diff --git a/contrib/typst/Makefile.am b/contrib/typst/Makefile.am @@ -0,0 +1,8 @@ +SUBDIRS = . + +formdatadir = $(datadir)/taler-merchant/typst-forms/ +dist_formdata_DATA = \ + transactions.typ + +EXTRA_DIST = \ + $(dist_formdata_DATA) diff --git a/contrib/typst/transactions.typ b/contrib/typst/transactions.typ @@ -0,0 +1,354 @@ +#import "@preview/cetz:0.4.2": canvas, draw, palette +#import "@preview/cetz-plot:0.1.3": plot, chart + +#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() + ] + ) + ] + ) + + // Helper function to format timeframe + let format_timeframe(d_us) = { + if d_us == "forever" { + "forever" + } else { + let us = int(d_us) + let s = calc.quo(us, 1000000) + let m = calc.quo(s, 60) + let h = calc.quo(m, 60) + let d = calc.quo(h, 24) + let w = calc.quo(d, 7) + + if calc.rem(us, 1000000) == 0 { + if calc.rem(s, 60) == 0 { + if calc.rem(m, 60) == 0 { + if calc.rem(h, 24) == 0 { + if calc.rem(d, 7) == 0 { + str(w) + " week" + if w != 1 { "s" } else { "" } + } else { + str(d) + " day" + if d != 1 { "s" } else { "" } + } + } else { + str(h) + " hour" + if h != 1 { "s" } else { "" } + } + } else { + str(m) + " minute" + if m != 1 { "s" } else { "" } + } + } else { + str(s) + " s" + } + } else { + str(us) + " μs" + } + } + } + + // Helper function to format timestamp; ignores leap seconds (too variable) + 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 { + // Convert Unix timestamp to human-readable format + 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) + + // Helper to check if year is leap year + let is_leap(y) = { + calc.rem(y, 4) == 0 and (calc.rem(y, 100) != 0 or calc.rem(y, 400) == 0) + } + + // Calculate year, month, day + let year = 1970 + let days_left = days_since_epoch + + // Find the year + 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 + } + } + + // Days in each month + 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) + } + + // Find month and day + 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 + + // Format with leading zeros + 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) + } + } + + + // Helper function to format timestamp; ignores leap seconds (too variable) + // Coarsen based on r_us (a value in milliseconds). + // If r_us is a minute, do not show seconds. + // If r_us is an hour, do not show minutes or seconds + // If r_us is a day, do not show hours, minutes or seconds. + // If r_us is a month, do not show days. + // If r_us is a year, do not show months. + // If mini is true, truly minify the result only showing + // the unit around the r_us granularity. + let format_round_timestamp(ts, r_us, mini) = { + if type(ts) == dictionary and "t_s" in ts { + let t_s = ts.t_s + if t_s == "never" { + "never" + } else { + // Convert Unix timestamp to human-readable format + 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) + + // Helper to check if year is leap year + let is_leap(y) = { + calc.rem(y, 4) == 0 and (calc.rem(y, 100) != 0 or calc.rem(y, 400) == 0) + } + + // Calculate year, month, day + let year = 1970 + let days_left = days_since_epoch + + // Find the year + 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 + } + } + + // Days in each month + 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) + } + + // Find month and day + 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 + + // Format with leading zeros + 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) } + + // Define thresholds in microseconds + let minute_us = 60 * 1000 * 1000 + let hour_us = 60 * minute_us + let day_us = 24 * hour_us + let month_us = 30 * day_us // Approximate: 30 days + let year_us = 365 * day_us // Approximate: 365 days + + // Build timestamp string based on r_us thresholds + if r_us >= year_us { + // Year or more: show only year + str(year) + } else if r_us >= month_us { + // Month or more: show year and month + if (mini) { m_str } else { str(year) + "-" + m_str } + } else if r_us >= day_us { + // Day or more: show year, month, and day + if (mini) { d_str + "d" } else { str(year) + "-" + m_str + "-" + d_str } + } else if r_us >= hour_us { + // Hour or more: show up to hours + if (mini) { h_str + "h" } else { str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":00 UTC" } + } else if r_us >= minute_us { + // Minute or more: show up to minutes + if (mini) { min_str + "m" } else { str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + " UTC" } + } else { + // Less than a minute: show full precision + if (mini) { s_str + "s" } else { str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + ":" + s_str + " UTC" } + } + } + } else { + str(ts) + } + } + + + heading(level: 1)[GNU Taler Merchant Accounting: #data.business] + + [Transaction report from + #underline([#format_round_timestamp(data.start_date,data.bucket_period.d_us,false)]) to + #underline([#format_round_timestamp(data.end_date,data.bucket_period.d_us,false)]) + with + #text(weight: "bold")[#format_timeframe(data.bucket_period.d_us)] + granularity. + ] + + let p = palette.new(colors: (blue, green, yellow, orange, purple, lime, teal, gray, red)) + v(1cm) + + for dchart in data.charts { + + heading(level: 2)[#dchart.chart_name] + + let data_groups = dchart.data_groups.map(dg => ( + values: dg.values, + start_date_str: format_round_timestamp(dg.start_date,data.bucket_period.d_us, false), + start_date_mini: format_round_timestamp(dg.start_date,data.bucket_period.d_us, true), + )) + + canvas(length: 1cm, { + import draw: * + + chart.columnchart( + size: (12, 8), + label-key: "start_date_mini", + value-key: "values", + x-label: [Time], + y-label: dchart.y-label, + x-tick-step: none, + y-tick-step: auto, + mode : if dchart.cummulative { "stacked" } else { "clustered" }, + // alternatives: "clustered" or "basic" or "stacked" + bar-style: p, + data_groups, + labels: dchart.labels, + legend: "east", + ) + }) + + v(1cm) + + let cols = (auto,) + dchart.labels.map(_ => auto) + if dchart.cummulative { + cols.push(auto) + } + + let aligns = (left,) + dchart.labels.map(_ => right) + if dchart.cummulative { + aligns.push(right) + } + + let header = ([*When*],) + dchart.labels.map(label => [*#label*]) + if dchart.cummulative { + header.push([*Total*]) + } + + let rows = data_groups.map(entry => { + let row = (entry.at("start_date_str"),) + row += entry.at("values").map(val => str(val)) + if dchart.cummulative { + row.push(str(entry.at("values").sum())) + } + row + }).flatten() + + align(center, + table( + columns: cols, + align: aligns, + ..header, + ..rows + )) + v(1cm) + + } // For all charts + +} + +// Load your JSON data +#form(( + business: "Example.com", + start_date: (t_s: 1764967786), + end_date: (t_s: 1767222000), + bucket_period: (d_us: 86400000000), + charts: ( + (chart_name: "Transaction volume", + y-label: "Volume", + data_groups: ( + (start_date: (t_s: 1766790000), + values: (10, 20, 30)), + (start_date: (t_s: 1766876400), + values: (10, 20, 30)), + (start_date: (t_s: 1766962800), + values: (10, 20, 30)), + (start_date: (t_s: 1767049200), + values: (10, 20, 30)), + (start_date: (t_s: 1767135600), + values: (10, 20, 30)), + ), + labels: ([EUR],[CHF],[HUF]), + cummulative: false, + ), + (chart_name: "Transaction rate", + y-label: "Rate", + data_groups: ( + (start_date: (t_s: 1764967786), + values: (10, 20, 30)), + (start_date: (t_s: 1766876400), + values: (10, 20, 30)), + (start_date: (t_s: 1766962800), + values: (10, 20, 30)), + (start_date: (t_s: 1767049200), + values: (10, 20, 30)), + (start_date: (t_s: 1767135600), + values: (10, 20, 30)), + ), + labels: ([Claimed],[Paid],[Settled]), + cummulative: true, + ), + ), +)) +\ No newline at end of file