merchant: landing-stats + helpers; demo instance note
This commit is contained in:
parent
027fb5bd5d
commit
dcbb161f20
6 changed files with 719 additions and 0 deletions
63
configs/taler-merchant/demo-instance-goa-demo-cp4zqk.md
Normal file
63
configs/taler-merchant/demo-instance-goa-demo-cp4zqk.md
Normal file
|
|
@ -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)
|
||||||
43
configs/taler-merchant/terms-src/merchant-tos-dual-v0.en.rst
Normal file
43
configs/taler-merchant/terms-src/merchant-tos-dual-v0.en.rst
Normal file
|
|
@ -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/
|
||||||
100
scripts/taler-merchant/ensure_merchant_helpers.sh
Executable file
100
scripts/taler-merchant/ensure_merchant_helpers.sh
Executable file
|
|
@ -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 &
|
||||||
|
disown 2>/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 &
|
||||||
|
disown 2>/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 &
|
||||||
|
disown 2>/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"
|
||||||
396
scripts/taler-merchant/landing-stats-merchant.sh
Normal file
396
scripts/taler-merchant/landing-stats-merchant.sh
Normal file
|
|
@ -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_<serial>.* → 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" <<EOF
|
||||||
|
{
|
||||||
|
"ok": ${ok_json},
|
||||||
|
"at": $(json_str "$(now_iso)"),
|
||||||
|
"at_human": $(json_str "$(now_human)"),
|
||||||
|
"error": $( [ -n "$msg" ] && json_str "$msg" || echo null )
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
# Do NOT touch $OUT — previous good stats stay live; site shows run note.
|
||||||
|
write_run false "$*"
|
||||||
|
printf '%s\n' "abort merchant-stats: $*" | tee -a "$ERRLOG" >&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 <<SQL
|
||||||
|
SELECT creation_time AS ts,
|
||||||
|
'payment'::text AS kind,
|
||||||
|
order_id,
|
||||||
|
coalesce(contract_terms->>'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 <<SQL
|
||||||
|
SELECT r.refund_timestamp AS ts,
|
||||||
|
'refund'::text AS kind,
|
||||||
|
ct.order_id,
|
||||||
|
(r.refund_amount).curr || ':' ||
|
||||||
|
CASE WHEN (r.refund_amount).frac = 0
|
||||||
|
THEN (r.refund_amount).val::text
|
||||||
|
ELSE trim(trailing '0' FROM trim(trailing '.' FROM (
|
||||||
|
((r.refund_amount).val + (r.refund_amount).frac::numeric / 100000000)::numeric(32,8)
|
||||||
|
)::text))
|
||||||
|
END AS amount,
|
||||||
|
left(translate(coalesce(r.reason,''), E'\t\n\r', ' '), 64) AS summary,
|
||||||
|
'refunded'::text AS status
|
||||||
|
FROM ${sch}.merchant_refunds r
|
||||||
|
JOIN ${sch}.merchant_contract_terms ct ON ct.order_serial = r.order_serial
|
||||||
|
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 ") ref ORDER BY ts DESC LIMIT ${REF_LIMIT}"
|
||||||
|
echo ")"
|
||||||
|
echo ") act ORDER BY ts DESC;"
|
||||||
|
} | psql_pipe >"/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" <<EOF
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"source": "merchant-db",
|
||||||
|
"schema": "merchant.merchant_instances + merchant_instance_<serial>",
|
||||||
|
"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"
|
||||||
91
scripts/taler-merchant/setup_credit_facade.sh
Executable file
91
scripts/taler-merchant/setup_credit_facade.sh
Executable file
|
|
@ -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
|
||||||
26
scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh
Executable file
26
scripts/taler-merchant/taler-merchant-wirewatch-supervise.sh
Executable file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue