From dcbb161f208b080bdac25feb0f9c13957c924324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hern=C3=A2ni=20Marques?= Date: Thu, 9 Jul 2026 17:58:03 +0200 Subject: [PATCH] merchant: landing-stats + helpers; demo instance note --- .../demo-instance-goa-demo-cp4zqk.md | 63 +++ .../terms-src/merchant-tos-dual-v0.en.rst | 43 ++ .../taler-merchant/ensure_merchant_helpers.sh | 100 +++++ .../taler-merchant/landing-stats-merchant.sh | 396 ++++++++++++++++++ scripts/taler-merchant/setup_credit_facade.sh | 91 ++++ .../taler-merchant-wirewatch-supervise.sh | 26 ++ 6 files changed, 719 insertions(+) create mode 100644 configs/taler-merchant/demo-instance-goa-demo-cp4zqk.md create mode 100644 configs/taler-merchant/terms-src/merchant-tos-dual-v0.en.rst create mode 100755 scripts/taler-merchant/ensure_merchant_helpers.sh create mode 100644 scripts/taler-merchant/landing-stats-merchant.sh create mode 100755 scripts/taler-merchant/setup_credit_facade.sh create mode 100755 scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh diff --git a/configs/taler-merchant/demo-instance-goa-demo-cp4zqk.md b/configs/taler-merchant/demo-instance-goa-demo-cp4zqk.md new file mode 100644 index 0000000..95aa009 --- /dev/null +++ b/configs/taler-merchant/demo-instance-goa-demo-cp4zqk.md @@ -0,0 +1,63 @@ +# Demo merchant instance `goa-demo-cp4zqk` + +Created **2026-07-09** via self-provisioning (`POST /instances`, no SMS TAN). + +## Merchant + +| Field | Value | +|-------|--------| +| SPA | https://taler.hacktivism.ch/webui/ | +| Instance ID | `goa-demo-cp4zqk` | +| Display name | GOA Demo Shop cp4zqk | +| Instance password | **not in this repo** — `koopa-admin-secrets/koopa/host-root/taler-merchant/merchant-goa-demo-cp4zqk-password.txt` → `/root/…` | +| Backend base | `https://taler.hacktivism.ch/instances/goa-demo-cp4zqk/` | +| Self-provisioning | YES; `mandatory_tan_channels: []` | + +Login: SPA → instance id + password (or bearer `secret-token:$PASSWORD`). + +## Bank account (linked) + +| Field | Value | +|-------|--------| +| Bank SPA | https://bank.hacktivism.ch/webui/ | +| Username | `goa-demo-cp4zqk` | +| Password | **not in this repo** — `koopa-admin-secrets/koopa/host-root/taler-bank/bank-goa-demo-cp4zqk-password.txt` → `/root/…` | +| Payto | `payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk` | +| Seed balance | **GOA:5000** (admin credit) | +| Debit threshold | GOA:100000 (default) | + +Merchant wire account (private API): + +```http +POST /instances/goa-demo-cp4zqk/private/accounts +Authorization: Bearer secret-token:… +{ "payto_uri": "payto://x-taler-bank/bank.hacktivism.ch/goa-demo-cp4zqk?receiver-name=GOA%20Demo%20Shop%20cp4zqk" } +``` + +Verified: account **active**, `h_wire` present. + +### Credit facade (automatic settlement import) + +Merchant **wirewatch** imports bank credits via the **Taler Revenue API** (not the exchange wire-gateway): + +| Field | Value | +|-------|--------| +| `credit_facade_url` | `https://bank.hacktivism.ch/accounts/goa-demo-cp4zqk/taler-revenue/` | +| credentials | **bearer** bank access token (Basic is rejected on history) | +| process | `taler-merchant-wirewatch` (+ `depositcheck`) | + +Setup helper (host root): `scripts/taler-merchant/setup_credit_facade.sh` + +Without this facade, bank credits still land (exchange→merchant), but orders stay `wired=false` / `private/transfers` empty. + +## Related config + +- Exchanges: **CHF** `taler-ops` + **GOA** `exchange.hacktivism.ch` (both enabled) +- SMS TAN: off +- Bank DEFAULT_EXCHANGE: `https://exchange.hacktivism.ch/` +- Container hosts: public IP `212.51.151.254` for `exchange.hacktivism.ch` (not 127.0.0.1) + +### SPA / KYC for this GOA payto + +- **hacktivism / GOA:** `status=ready` (settlement path) +- **taler-ops / CHF:** `unsupported-account` — expected (x-taler-bank vs CHF/IBAN) diff --git a/configs/taler-merchant/terms-src/merchant-tos-dual-v0.en.rst b/configs/taler-merchant/terms-src/merchant-tos-dual-v0.en.rst new file mode 100644 index 0000000..29bc489 --- /dev/null +++ b/configs/taler-merchant/terms-src/merchant-tos-dual-v0.en.rst @@ -0,0 +1,43 @@ +No Formal Terms · Dual Currency Notice +====================================== + +This is a **self-hosted GNU Taler merchant backend** at +``taler.hacktivism.ch`` (hacktivism.ch). + +**No formal terms of service** from Taler Operations AG (or any other +third-party portal operator) apply to this instance. This short notice +is the site policy for using the backend. + +Dual currency +------------- + +This backend is configured for **two currencies at once**: + +* **GOA** — explorational / experimental currency of the local + stack (``exchange.hacktivism.ch``, ``bank.hacktivism.ch``). + GOA is **not** legal tender. It has **no guaranteed real-world + value**, redemption, or convertibility. +* **CHF** — Swiss francs, a **real** currency. CHF amounts are + real money settled via the CHF exchange configured on this host + (taler-ops / TOPS infrastructure as deployed). Treat CHF with + the seriousness of ordinary payments. + +By creating a merchant instance, accepting payments, or otherwise +using this service you acknowledge that: + +* GOA is for exploration and testing only. +* CHF involves real money — only use funds you control and can + afford to risk on a self-hosted experimental stack. +* There is no guaranteed availability, support, or uptime. +* Operators may reset GOA state, change configuration, delete + instance data, or interrupt service without notice. +* Software is provided as-is, without warranty. + +If you do not agree, do not use this merchant backend. + +Related +------- + +* Exchange (GOA): https://exchange.hacktivism.ch/terms +* Bank intro: https://bank.hacktivism.ch/intro/ +* Merchant intro: https://taler.hacktivism.ch/intro/ diff --git a/scripts/taler-merchant/ensure_merchant_helpers.sh b/scripts/taler-merchant/ensure_merchant_helpers.sh new file mode 100755 index 0000000..b626dd4 --- /dev/null +++ b/scripts/taler-merchant/ensure_merchant_helpers.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Ensure merchant helper processes are running (no systemd). +# Prefer run as taler-merchant-httpd; root may use runuser. +# +# Usage: +# ensure_merchant_helpers.sh +# (as root) ensure_merchant_helpers.sh # re-exec as taler-merchant-httpd +set -euo pipefail + +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +LOG_DIR="${TALER_MERCHANT_LOG_DIR:-/var/log/taler-merchant}" + +# As root: start wirewatch supervisor (needs root for runuser), then re-exec as httpd. +if [ "$(id -un)" = "root" ]; then + mkdir -p "$LOG_DIR" + if [ -x /usr/local/bin/taler-merchant-wirewatch-supervise.sh ]; then + if ! ps -eo args= 2>/dev/null | grep -q 'taler-merchant-wirewatch-supervise\.sh'; then + echo "start: taler-merchant-wirewatch-supervise" + nohup /usr/local/bin/taler-merchant-wirewatch-supervise.sh \ + >>"$LOG_DIR/wirewatch-supervise.nohup" 2>&1 /dev/null || true + else + echo "already: taler-merchant-wirewatch-supervise" + fi + fi + exec runuser -u taler-merchant-httpd -- "$0" "$@" +fi + +if [ "$(id -un)" != "taler-merchant-httpd" ]; then + echo "run as taler-merchant-httpd or root" >&2 + exit 1 +fi + +mkdir -p "$LOG_DIR" +chmod 755 "$LOG_DIR" 2>/dev/null || true + +# Linux COMM is 15 chars — do not use pgrep -x for taler-merchant-wirewatch etc. +is_running() { + local bin="$1" + local base + base=$(basename "$bin") + ps -u "$(id -un)" -o args= 2>/dev/null | grep -qE "(^|/)[${base:0:1}]${base:1}( |$)" \ + || pgrep -u "$(id -un)" -f "(^|/)(${base})( |$)" >/dev/null 2>&1 +} + +start_one() { + local name="$1" + local bin="$2" + shift 2 + if is_running "$bin"; then + echo "already: $name" + return 0 + fi + echo "start: $name" + nohup "$bin" "$@" >>"$LOG_DIR/${name}.log" 2>&1 /dev/null || true + local i + for i in 1 2 3 4 5 6; do + sleep 0.4 + if is_running "$bin"; then + echo " ok: $name" + return 0 + fi + done + echo " FAIL: $name did not stay up (see $LOG_DIR/${name}.log)" >&2 + tail -20 "$LOG_DIR/${name}.log" 2>/dev/null || true + return 1 +} + +ec=0 +# Helpers needed for settlement / ops (not only httpd). +start_one taler-merchant-webhook /usr/bin/taler-merchant-webhook || ec=1 +start_one taler-merchant-kyccheck /usr/bin/taler-merchant-kyccheck || ec=1 +# Prefer already-running supervisor (started as root above); else bare wirewatch. +if ps -eo args= 2>/dev/null | grep -q 'taler-merchant-wirewatch-supervise\.sh'; then + echo "already: taler-merchant-wirewatch (via supervise)" +elif is_running /usr/bin/taler-merchant-wirewatch; then + echo "already: taler-merchant-wirewatch" +else + start_one taler-merchant-wirewatch /usr/bin/taler-merchant-wirewatch \ + -c "$CONF" -L INFO || ec=1 +fi +start_one taler-merchant-depositcheck /usr/bin/taler-merchant-depositcheck || ec=1 +start_one taler-merchant-exchangekeyupdate /usr/bin/taler-merchant-exchangekeyupdate || ec=1 +start_one taler-merchant-reconciliation /usr/bin/taler-merchant-reconciliation || ec=1 + +if ! is_running taler-merchant-httpd; then + echo "start: taler-merchant-httpd" + nohup /usr/bin/taler-merchant-httpd --log=info \ + >>"$LOG_DIR/taler-merchant-httpd-$(date +%Y-%m-%d).log" 2>&1 /dev/null || true + sleep 1 + is_running taler-merchant-httpd || ec=1 +fi + +echo "--- live merchant ---" +ps -eo pid,user,stat,etime,args 2>/dev/null \ + | grep taler-merchant | grep -vE 'grep| Z |ensure_merchant' || true + +exit "$ec" diff --git a/scripts/taler-merchant/landing-stats-merchant.sh b/scripts/taler-merchant/landing-stats-merchant.sh new file mode 100644 index 0000000..f8fe60d --- /dev/null +++ b/scripts/taler-merchant/landing-stats-merchant.sh @@ -0,0 +1,396 @@ +#!/bin/bash +# Run INSIDE taler-hacktivism. Writes /var/www/merchant-landing/stats.json +# +# Current taler-merchant schema: +# merchant.merchant_instances → merchant_serial, merchant_id +# merchant_instance_.* → per-instance tables +# +# IMPORTANT: +# - cron uses a minimal PATH. Without /usr/sbin, runuser is missing and +# every query used to fail silently → all zeros (and overwrote good data). +# - Never write stats.json on failure: leave the previous good file in place. +# - Avoid: psql -F$'\t' -v ON_ERROR_STOP=… (if -F loses the tab arg, -v is +# eaten as fieldsep and ON_ERROR_STOP becomes the *username*). +set -euo pipefail +export TZ="${TZ:-Europe/Zurich}" +LANDING_DIR="${LANDING_DIR:-/var/www/merchant-landing}" +OUT="$LANDING_DIR/stats.json" +RUN="$LANDING_DIR/stats-run.json" +TMP="${OUT}.tmp.$$" +DB="${MERCHANT_DB:-taler-merchant}" +ACTIVITY_LIMIT="${ACTIVITY_LIMIT:-12}" +ERRLOG="${LANDING_STATS_ERRLOG:-/var/log/landing-stats-merchant.err}" +mkdir -p "$LANDING_DIR" + +now_iso() { date +%Y-%m-%dT%H:%M%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/'; } +now_human() { date +"%Y-%m-%d %H:%M %Z"; } +json_str() { + printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n\r\t' ' ' | sed 's/ */ /g')" +} + +# Public run status for the intro page (never wipe stats.json on failure). +write_run() { + local ok_json="$1" msg="${2:-}" + cat >"$RUN" <&2 + rm -f "$TMP" /tmp/merch_cur_$$.tsv /tmp/merch_act_$$.tsv 2>/dev/null || true + exit 1 +} + +as_postgres() { + if command -v runuser >/dev/null 2>&1; then + runuser -u postgres -- "$@" + elif command -v su >/dev/null 2>&1; then + su -s /bin/bash postgres -c "$*" + else + return 127 + fi +} + +# pipe SQL on stdin — never -f on root-owned temps (postgres cannot read them) +psql_pipe() { + local fs=$'\t' ec + set +e + as_postgres psql -d "$DB" -At -F"$fs" 2>>"$ERRLOG" + ec=$? + set -e + return "$ec" +} + +# Required single-value query. Empty/fail → abort (no site write). +psqlq() { + local sql="$1" out ec + set +e + out=$(as_postgres psql -d "$DB" -At -c "$sql" 2>>"$ERRLOG") + ec=$? + set -e + if [ "$ec" -ne 0 ]; then + abort "psql failed (ec=$ec): ${sql:0:120}" + fi + # strip trailing newlines only + printf '%s' "$out" | tr -d '\r' +} + +# "64.03" or "64.03000001" → clean CUR:value using decimal string (no binary float) +fmt_cur_num() { + local c="$1" n="$2" + awk -v c="$c" -v n="$n" 'BEGIN{ + gsub(/ /,"",n) + if (n == "" || n+0 == 0 && n !~ /[1-9]/) { printf "%s:0", c; exit } + if (n ~ /[eE]/) { + x = n+0 + s = sprintf("%.8f", x) + } else { + s = n + } + if (s ~ /\./) { + split(s, a, ".") + whole = a[1]; frac = substr(a[2] "00000000", 1, 8) + extra = substr(a[2], 9, 1) + if (extra != "" && extra+0 >= 5) { + f = frac+0 + 1 + if (f >= 100000000) { whole = whole+1; f = f - 100000000 } + frac = sprintf("%08d", f) + } + sub(/0+$/, "", frac) + if (frac == "") printf "%s:%s", c, whole + else printf "%s:%s.%s", c, whole, frac + } else { + printf "%s:%s", c, s + } + }' +} + +# --- preflight: tools + DB reachability (fail closed) --- +command -v psql >/dev/null 2>&1 || abort "psql not in PATH ($PATH)" +command -v runuser >/dev/null 2>&1 || command -v su >/dev/null 2>&1 || abort "neither runuser nor su in PATH" + +PROBE=$(psqlq "SELECT 1;") +[ "$PROBE" = "1" ] || abort "postgres not reachable (SELECT 1 → '$PROBE')" + +SERIALS=$(psqlq "SELECT merchant_serial::text FROM merchant.merchant_instances ORDER BY merchant_serial;") +# If table exists but we got nothing, still ok (empty install). If table missing, psqlq aborted. + +# Build list of existing instance schemas +SCHEMAS="" +INSTANCES=0 +while read -r serial; do + [ -z "$serial" ] && continue + # only pure integers + [[ "$serial" =~ ^[0-9]+$ ]] || abort "bad merchant_serial='$serial'" + sch="merchant_instance_${serial}" + exists=$(psqlq "SELECT 1 FROM pg_namespace WHERE nspname = '${sch}';") + if [ "$exists" != "1" ]; then + # schema lag — skip, do not abort (instance row without schema yet) + continue + fi + INSTANCES=$((INSTANCES + 1)) + SCHEMAS="${SCHEMAS}${SCHEMAS:+ }$sch:$serial" +done <<<"$SERIALS" + +# Sanity: if instance schemas exist in PG but SERIALS empty → query path broken +NS_COUNT=$(psqlq "SELECT count(*)::text FROM pg_namespace WHERE nspname LIKE 'merchant_instance_%';") +NS_COUNT=$(printf '%s' "$NS_COUNT" | tr -d '[:space:]') +if [ "${NS_COUNT:-0}" -gt 0 ] && [ "$INSTANCES" -eq 0 ]; then + abort "pg has ${NS_COUNT} merchant_instance_* schemas but resolved INSTANCES=0 (query/PATH bug)" +fi + +# --- dual-currency aggregation entirely in PostgreSQL (numeric) --- +{ + echo "SELECT" + echo " coalesce(nullif(split_part(c.contract_terms->>'amount', ':', 1), ''), '?') AS currency," + echo " count(*)::bigint," + echo " count(*) FILTER (WHERE c.paid)::bigint," + echo " count(*) FILTER (WHERE NOT c.paid)::bigint," + echo " count(*) FILTER (WHERE c.wired)::bigint," + echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric), 0), 8)::text," + echo " round(coalesce(sum(NULLIF(split_part(c.contract_terms->>'amount', ':', 2), '')::numeric) FILTER (WHERE c.paid), 0), 8)::text" + echo "FROM (" + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + echo "SELECT contract_terms, paid, wired FROM ${sch}.merchant_contract_terms" + done + if [ "$first" = 1 ]; then + echo "SELECT NULL::jsonb AS contract_terms, false AS paid, false AS wired WHERE false" + fi + echo ") c" + echo "WHERE c.contract_terms ? 'amount'" + echo "GROUP BY 1 ORDER BY 1;" +} | psql_pipe >"/tmp/merch_cur_$$.tsv" || abort "currency aggregate query failed" +CUR_TSV=$(cat "/tmp/merch_cur_$$.tsv" 2>/dev/null || true) +rm -f "/tmp/merch_cur_$$.tsv" + +TOTAL_CONTRACTS=0; TOTAL_PAID=0; TOTAL_WIRED=0; TOTAL_UNPAID=0 +declare -A C_CONTRACTS C_PAID C_UNPAID C_WIRED C_AMT C_AMT_PAID + +while IFS=$'\t' read -r cur contracts paid unpaid wired amt amt_paid; do + [ -z "${cur:-}" ] && continue + TOTAL_CONTRACTS=$((TOTAL_CONTRACTS + contracts)) + TOTAL_PAID=$((TOTAL_PAID + paid)) + TOTAL_UNPAID=$((TOTAL_UNPAID + unpaid)) + TOTAL_WIRED=$((TOTAL_WIRED + wired)) + C_CONTRACTS[$cur]=$contracts + C_PAID[$cur]=$paid + C_UNPAID[$cur]=$unpaid + C_WIRED[$cur]=$wired + C_AMT[$cur]=$amt + C_AMT_PAID[$cur]=$amt_paid +done <<<"$CUR_TSV" + +# deposits / refunds +DEPOSITS=0; REFUNDS=0 +for item in $SCHEMAS; do + sch=${item%%:*} + d=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_deposits;") + r=$(psqlq "SELECT count(*)::text FROM ${sch}.merchant_refunds;") + d=$(printf '%s' "$d" | tr -d '[:space:]') + r=$(printf '%s' "$r" | tr -d '[:space:]') + [[ "$d" =~ ^[0-9]+$ ]] || abort "bad deposits count for $sch: '$d'" + [[ "$r" =~ ^[0-9]+$ ]] || abort "bad refunds count for $sch: '$r'" + DEPOSITS=$((DEPOSITS + d)) + REFUNDS=$((REFUNDS + r)) +done + +# --- recent activity --- +PAY_LIMIT=10 +REF_LIMIT=5 +{ + echo "SELECT * FROM (" + echo "(" + echo "SELECT * FROM (" + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + cat <>'amount','') AS amount, + left(translate(coalesce(contract_terms->>'summary',''), E'\t\n\r', ' '), 64) AS summary, + CASE WHEN wired THEN 'wired' ELSE 'paid' END AS status +FROM ${sch}.merchant_contract_terms +WHERE paid +SQL + done + if [ "$first" = 1 ]; then + echo "SELECT 0::bigint AS ts, ''::text AS kind, ''::text AS order_id, ''::text AS amount, ''::text AS summary, ''::text AS status WHERE false" + fi + echo ") pay ORDER BY ts DESC LIMIT ${PAY_LIMIT}" + echo ")" + echo "UNION ALL" + echo "(" + echo "SELECT * FROM (" + first=1 + for item in $SCHEMAS; do + sch=${item%%:*} + if [ "$first" = 1 ]; then first=0; else echo " UNION ALL "; fi + cat <"/tmp/merch_act_$$.tsv" || abort "activity query failed" +ACT_TSV=$(cat "/tmp/merch_act_$$.tsv" 2>/dev/null || true) +rm -f "/tmp/merch_act_$$.tsv" + +# by_currency JSON — GOA then CHF then rest +CUR_JSON="[" +cf=1 +emit_cur() { + local cur="$1" + [ -n "${C_CONTRACTS[$cur]+x}" ] || return 1 + local amt_fmt paid_fmt + amt_fmt=$(fmt_cur_num "$cur" "${C_AMT[$cur]}") + paid_fmt=$(fmt_cur_num "$cur" "${C_AMT_PAID[$cur]}") + if [ "$cf" = 1 ]; then cf=0; else CUR_JSON="${CUR_JSON},"; fi + CUR_JSON="${CUR_JSON} + { + \"currency\": $(json_str "$cur"), + \"contracts\": ${C_CONTRACTS[$cur]:-0}, + \"paid\": ${C_PAID[$cur]:-0}, + \"unpaid\": ${C_UNPAID[$cur]:-0}, + \"wired\": ${C_WIRED[$cur]:-0}, + \"amount_sum\": $(json_str "$amt_fmt"), + \"amount_paid_sum\": $(json_str "$paid_fmt") + }" +} +emit_cur GOA || true +emit_cur CHF || true +for cur in "${!C_CONTRACTS[@]}"; do + case "$cur" in GOA|CHF) continue ;; esac + emit_cur "$cur" || true +done +CUR_JSON="${CUR_JSON} + ]" + +ACT_JSON="[" +af=1 +while IFS=$'\t' read -r ts kind oid amt sum st; do + [ -z "${ts:-}" ] && continue + [ "$ts" = "0" ] && [ -z "$kind" ] && continue + sec=$(awk -v t="$ts" 'BEGIN{printf "%d", int(t/1000000)}') + human_fmt=$(date -d "@${sec}" +"%Y-%m-%d %H:%M %Z" 2>/dev/null || echo "$sec") + iso=$(date -d "@${sec}" +"%Y-%m-%dT%H:%M:%S%z" 2>/dev/null | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/' || true) + if [ "$af" = 1 ]; then af=0; else ACT_JSON="${ACT_JSON},"; fi + ACT_JSON="${ACT_JSON} + { + \"ts_us\": ${ts:-0}, + \"ts\": $(json_str "$iso"), + \"ts_human\": $(json_str "$human_fmt"), + \"kind\": $(json_str "$kind"), + \"order_id\": $(json_str "$oid"), + \"amount\": $(json_str "$amt"), + \"summary\": $(json_str "$sum"), + \"status\": $(json_str "$st") + }" +done <<<"$ACT_TSV" +ACT_JSON="${ACT_JSON} + ]" + +GEN=$(now_iso) +HUMAN=$(now_human) + +# Live performance probes (exchange-style) +measure_ms() { + local url="$1" t + t=$(curl -skS -o /dev/null -m 8 -w '%{time_total}' "$url" 2>/dev/null || echo "") + [ -z "$t" ] && { echo "null"; return; } + awk -v t="$t" 'BEGIN{printf "%d", (t+0)*1000}' +} +num_or_null() { case "${1:-}" in ''|null) echo null ;; *) echo "$1" ;; esac; } +MERCHANT_PUBLIC_BASE="${MERCHANT_PUBLIC_URL:-https://taler.hacktivism.ch}" +MERCHANT_LOCAL="${MERCHANT_LOCAL_URL:-https://127.0.0.1:9010}" +CONFIG_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/config") +CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_PUBLIC_BASE}/config" 2>/dev/null || echo "000") +if [ "$CONFIG_HTTP" != "200" ]; then + CONFIG_MS=$(measure_ms "${MERCHANT_LOCAL}/config") + CONFIG_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_LOCAL}/config" 2>/dev/null || echo "000") +fi +TERMS_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/terms") +TERMS_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' -H "Accept: text/html" "${MERCHANT_PUBLIC_BASE}/terms" 2>/dev/null || echo "000") +WEBUI_MS=$(measure_ms "${MERCHANT_PUBLIC_BASE}/webui/") +WEBUI_HTTP=$(curl -skS -o /dev/null -m 8 -w '%{http_code}' "${MERCHANT_PUBLIC_BASE}/webui/" 2>/dev/null || echo "000") +LOADAVG="" +[ -r /proc/loadavg ] && LOADAVG=$(awk '{print $1","$2","$3}' /proc/loadavg) + +MEM_JSON='"container_rss_human": "—"' +MEM_HELPER="${MEM_HELPER:-/usr/local/lib/landing-mem-snapshot.sh}" +if [ -f "$MEM_HELPER" ]; then + # shellcheck disable=SC1090 + . "$MEM_HELPER" + mem_snapshot_json || true +fi + +# Atomic write only after full success +cat >"$TMP" <", + "dual_currency": true, + "currencies_note": "CHF (taler-ops) + GOA (hacktivism)", + "timezone": $(json_str "$TZ"), + "generated_at": $(json_str "$GEN"), + "generated_at_human": $(json_str "$HUMAN"), + "instances": ${INSTANCES:-0}, + "orders": ${TOTAL_CONTRACTS:-0}, + "paid": ${TOTAL_PAID:-0}, + "unpaid": ${TOTAL_UNPAID:-0}, + "wired": ${TOTAL_WIRED:-0}, + "deposits": ${DEPOSITS:-0}, + "refunds": ${REFUNDS:-0}, + "by_currency": $CUR_JSON, + "recent_activity": $ACT_JSON, + "performance": { + "config_http": $(json_str "$CONFIG_HTTP"), + "config_ms": $(num_or_null "$CONFIG_MS"), + "terms_http": $(json_str "$TERMS_HTTP"), + "terms_ms": $(num_or_null "$TERMS_MS"), + "webui_http": $(json_str "$WEBUI_HTTP"), + "webui_ms": $(num_or_null "$WEBUI_MS"), + "loadavg": $(json_str "${LOADAVG:-}"), + "memory": { +${MEM_JSON} + } + } +} +EOF + +# basic JSON sanity before publish +grep -q '"ok": true' "$TMP" || abort "tmp json missing ok:true" +mv -f "$TMP" "$OUT" +write_run true +echo "ok merchant instances=$INSTANCES orders=$TOTAL_CONTRACTS paid=$TOTAL_PAID refunds=$REFUNDS -> $OUT" diff --git a/scripts/taler-merchant/setup_credit_facade.sh b/scripts/taler-merchant/setup_credit_facade.sh new file mode 100755 index 0000000..3ab0403 --- /dev/null +++ b/scripts/taler-merchant/setup_credit_facade.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# Configure merchant bank account credit facade for automatic settlement import. +# +# Root cause (2026-07-09): merchant wirewatch must call the **Taler Revenue API** +# (`…/taler-revenue/`), not the exchange wire-gateway (`…/taler-wire-gateway/`). +# On this bank, history endpoints accept **Bearer** tokens only (Basic → 401). +# +# Usage (as root on koopa host): +# setup_credit_facade.sh +# MERCHANT_INSTANCE=goa-demo-cp4zqk setup_credit_facade.sh +# BANK_USER=… BANK_PW_FILE=… MERCHANT_PW_FILE=… setup_credit_facade.sh +# +# Effects: +# - PATCH /instances/$INST/private/accounts/$H_WIRE with credit_facade_* +# - long-lived refreshable bank token (1y) +# - restarts taler-merchant-wirewatch (once, after facade is set) +set -euo pipefail + +INST="${MERCHANT_INSTANCE:-goa-demo-cp4zqk}" +BANK_USER="${BANK_USER:-$INST}" +MERCHANT_PW_FILE="${MERCHANT_PW_FILE:-/root/merchant-${INST}-password.txt}" +BANK_PW_FILE="${BANK_PW_FILE:-/root/bank-${BANK_USER}-password.txt}" +MER_URL="${MERCHANT_URL:-https://127.0.0.1:9010}" +BANK_URL="${BANK_URL:-http://127.0.0.1:9012}" +PUBLIC_BANK="${PUBLIC_BANK_BASE:-https://bank.hacktivism.ch}" +FACADE_URL="${CREDIT_FACADE_URL:-${PUBLIC_BANK}/accounts/${BANK_USER}/taler-revenue/}" + +if [ "$(id -un)" != "root" ]; then + echo "run as root on koopa host" >&2 + exit 1 +fi +if [ ! -r "$MERCHANT_PW_FILE" ] || [ ! -r "$BANK_PW_FILE" ]; then + echo "need readable $MERCHANT_PW_FILE and $BANK_PW_FILE" >&2 + exit 1 +fi + +MPW=$(tr -d '\n' <"$MERCHANT_PW_FILE") +BPW=$(tr -d '\n' <"$BANK_PW_FILE") +AUTH="Authorization: Bearer secret-token:${MPW}" + +echo "=== bank token for $BANK_USER ===" +TOK=$(curl -sS -u "${BANK_USER}:${BPW}" -H 'Content-Type: application/json' \ + -d '{"scope":"readwrite","refreshable":true,"duration":{"d_us":31536000000000}}' \ + "${BANK_URL}/accounts/${BANK_USER}/token" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') +echo "tok_len=${#TOK}" + +echo "=== verify revenue history ===" +code=$(curl -sS -m 15 -o /tmp/rev-check.json -w "%{http_code}" \ + -H "Authorization: Bearer ${TOK}" \ + "${BANK_URL}/accounts/${BANK_USER}/taler-revenue/history?delta=-3") +echo "revenue_history_http=$code" +python3 -c 'import json;d=json.load(open("/tmp/rev-check.json"));print("incoming",len(d.get("incoming_transactions")or[]))' +[ "$code" = "200" ] || { echo "FAIL revenue history"; exit 1; } + +echo "=== PATCH credit_facade ($FACADE_URL) ===" +H_WIRE=$(curl -sk -H "$AUTH" "${MER_URL}/instances/${INST}/private/accounts" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["accounts"][0]["h_wire"])') +export FACADE_URL TOK +BODY=$(python3 - <<'PY' +import json, os +print(json.dumps({ + "credit_facade_url": os.environ["FACADE_URL"], + "credit_facade_credentials": {"type": "bearer", "token": os.environ["TOK"]}, +})) +PY +) +curl -sk -X PATCH -H "$AUTH" -H 'Content-Type: application/json' -d "$BODY" \ + "${MER_URL}/instances/${INST}/private/accounts/${H_WIRE}" \ + -w "PATCH_HTTP=%{http_code}\n" -o /dev/null + +echo "=== restart wirewatch in merchant container ===" +su - hernani -c 'podman exec taler-hacktivism bash -c " +set +e +for p in \$(ps -eo pid=,args= | awk \"\\\$0 ~ /\\/usr\\/bin\\/taler-merchant-wirewatch/ {print \\\$1}\"); do + kill \$p 2>/dev/null || true +done +sleep 1 +runuser -u taler-merchant-httpd -- nohup /usr/bin/taler-merchant-wirewatch \ + -c /etc/taler-merchant/taler-merchant.conf -L INFO \ + >>/var/log/taler-merchant/wirewatch.log 2>&1 & +sleep 2 +ps -eo pid,etime,args | grep -E \"[t]aler-merchant-wirewatch\" || echo FAIL_no_wirewatch +tail -8 /var/log/taler-merchant/wirewatch.log +"' + +echo "=== private/transfers (sample) ===" +curl -sk -H "$AUTH" "${MER_URL}/instances/${INST}/private/transfers" \ + | python3 -c 'import sys,json;d=json.load(sys.stdin);t=d.get("transfers")or[];print("transfers",len(t))' + +echo OK_credit_facade diff --git a/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh b/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh new file mode 100755 index 0000000..467c7c9 --- /dev/null +++ b/scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Run inside merchant container (as root). +# Restarts taler-merchant-wirewatch when it exits. +# Without systemd, wirewatch exits on PG config NOTIFY and would stay down. +set +e +LOG=/var/log/taler-merchant/wirewatch-supervise.log +WW_LOG=/var/log/taler-merchant/wirewatch.log +CONF="${TALER_MERCHANT_CONFIG:-/etc/taler-merchant/taler-merchant.conf}" +mkdir -p /var/log/taler-merchant +echo "$(date -u +%FT%TZ) supervise start" >>"$LOG" +for p in $(ps -eo pid=,args= | awk '$2=="/usr/bin/taler-merchant-wirewatch"{print $1}'); do + kill "$p" 2>/dev/null || true +done +sleep 1 +while true; do + echo "$(date -u +%FT%TZ) start wirewatch" >>"$LOG" + if [ "$(id -un)" = "root" ]; then + runuser -u taler-merchant-httpd -- /usr/bin/taler-merchant-wirewatch -c "$CONF" -L INFO >>"$WW_LOG" 2>&1 + ec=$? + else + /usr/bin/taler-merchant-wirewatch -c "$CONF" -L INFO >>"$WW_LOG" 2>&1 + ec=$? + fi + echo "$(date -u +%FT%TZ) wirewatch exit=$ec; sleep 2" >>"$LOG" + sleep 2 +done