koopa-admin-log/scripts/taler-landing/goa_amounts.py
Hernâni Marques 9e0f85024b
landing: harden GOA alt display for all three sites
Ship goa-amount.js inside each landing tree, add an inline fallback when
the asset is not yet deployed, keep CHF free of GOA unit names, fix
exchange amount_full wiring, and add test-landing-stats.sh (local + live).
2026-07-17 08:48:00 +02:00

318 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
"""GOA (and generic CUR:amount) alt-unit formatting for landing stats.
Matches exchange currency_specification.alt_unit_names:
0→GOA, 3→Kilo-GOA, 6→Mega-GOA, … and fractional scales.
Display prefers compact alt names for large values so landing tiles fit.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any, Dict, Optional, Tuple
# Default GOA ladder (same shape as exchange /config)
DEFAULT_ALT: Dict[str, str] = {
"24": "Yotta-GOA",
"21": "Zetta-GOA",
"18": "Exa-GOA",
"15": "Peta-GOA",
"12": "Tera-GOA",
"9": "Giga-GOA",
"6": "Mega-GOA",
"3": "Kilo-GOA",
"0": "GOA",
"-1": "Deci-GOA",
"-2": "Centi-GOA",
"-3": "Milli-GOA",
"-6": "Micro-GOA",
"-7": "Deci-Micro-GOA",
"-8": "Atomic-GOA",
}
# Use alt unit when |value| >= this (base units). Below: keep compact CUR:n.
ALT_THRESHOLD = Decimal("1000")
def parse_amount(s: Any) -> Tuple[str, Decimal]:
"""Parse 'GOA:12.5' or bare number → (currency, value)."""
if s is None:
return "GOA", Decimal(0)
if isinstance(s, (int, float, Decimal)):
return "GOA", Decimal(str(s))
text = str(s).strip()
if not text:
return "GOA", Decimal(0)
if ":" in text:
cur, rest = text.split(":", 1)
return (cur or "GOA").strip(), Decimal(rest.strip() or "0")
return "GOA", Decimal(text)
def fmt_coeff(v: Decimal) -> str:
if v == v.to_integral_value():
return format(int(v), "d")
# up to 4 significant fractional digits, strip trailing zeros
q = v.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
s = format(q, "f").rstrip("0").rstrip(".")
return s or "0"
def fmt_base(cur: str, val: Decimal) -> str:
if val == val.to_integral_value():
return f"{cur}:{int(val)}"
# preserve up to 8 fractional digits (Taler style)
s = format(val, "f").rstrip("0").rstrip(".")
return f"{cur}:{s}"
def format_amount_alt(
amount: Any,
alt: Optional[Dict[str, str]] = None,
*,
threshold: Decimal = ALT_THRESHOLD,
with_base: bool = False,
) -> Dict[str, Any]:
"""Return display fields for one amount.
Keys:
amount canonical CUR:n
amount_alt short display (e.g. "1.23 Mega-GOA")
amount_full "1.23 Mega-GOA (GOA:1230000)" when alt used
value float for JSON (may lose precision above 2^53 — also value_str)
value_str exact decimal string
"""
alt = alt or DEFAULT_ALT
try:
cur, val = parse_amount(amount)
except (InvalidOperation, ValueError, ArithmeticError):
raw = str(amount or "")
return {
"amount": raw,
"amount_alt": raw or "",
"amount_full": raw or "",
"value": 0.0,
"value_str": "0",
}
base = fmt_base(cur, val)
value_str = format(val, "f").rstrip("0").rstrip(".") if val != val.to_integral_value() else str(int(val))
try:
value_f = float(val)
except Exception:
value_f = 0.0
base_name = alt.get("0") or cur
# Never rename foreign currencies with a GOA alt map (merchant dual CHF+GOA).
cur_u = (cur or "").upper()
base_u = str(base_name).upper()
if cur_u and cur_u != "GOA" and (base_u == "GOA" or base_u.endswith("-GOA") or "GOA" in base_u):
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": value_f,
"value_str": value_str,
}
if val == 0:
# Keep canonical CUR:0 (avoids "0 GOA" for empty foreign balances)
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": 0.0,
"value_str": "0",
}
scales = []
for k, name in alt.items():
try:
scales.append((int(k), str(name)))
except Exception:
continue
scales.sort(key=lambda x: -x[0])
absval = abs(val)
# Below threshold: short base form (saves noise on small demo amounts)
if absval < threshold:
# Prefer "GOA:12.5" style (matches existing landings) when small
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": value_f,
"value_str": value_str,
}
chosen = None
for sc, name in scales:
unit = Decimal(10) ** sc
if unit <= 0:
continue
coeff = absval / unit
if coeff >= 1:
chosen = (sc, name, coeff if val >= 0 else -coeff)
break
if chosen is None or chosen[0] == 0:
return {
"amount": base,
"amount_alt": base,
"amount_full": base,
"value": value_f,
"value_str": value_str,
}
sc, name, coeff = chosen
short = f"{fmt_coeff(coeff)} {name}"
full = f"{short} ({base})" if with_base or True else short
return {
"amount": base,
"amount_alt": short,
"amount_full": full,
"value": value_f,
"value_str": value_str,
}
def attach_alt(obj: Dict[str, Any], amount_key: str = "amount", alt: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Mutate obj: ensure amount_alt / amount_full from amount_key."""
if not isinstance(obj, dict):
return obj
src = obj.get(amount_key)
if src is None:
return obj
f = format_amount_alt(src, alt)
obj[amount_key] = f["amount"]
obj["amount_alt"] = f["amount_alt"]
obj["amount_full"] = f["amount_full"]
if "value" not in obj:
obj["value"] = f["value"]
obj["value_str"] = f["value_str"]
return obj
def load_alt_from_config(url: str, timeout: float = 8.0) -> Dict[str, str]:
"""GET exchange/bank /config → alt_unit_names (fallback DEFAULT_ALT)."""
import json
import urllib.request
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
except Exception:
return dict(DEFAULT_ALT)
au = None
cs = data.get("currency_specification")
if isinstance(cs, dict):
au = cs.get("alt_unit_names")
if not au and isinstance(data.get("currencies"), dict):
for _code, spec in data["currencies"].items():
if isinstance(spec, dict) and spec.get("alt_unit_names"):
au = spec["alt_unit_names"]
break
if not isinstance(au, dict) or "0" not in au:
return dict(DEFAULT_ALT)
return {str(k): str(v) for k, v in au.items()}
def enrich_stats_tree(data: Dict[str, Any], alt: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Walk common landing stats.json shapes and add amount_alt fields."""
alt = alt or DEFAULT_ALT
if not isinstance(data, dict):
return data
# bank-style
for path in (
("flow", "incoming"),
("flow", "withdraw"),
("flow", "other_out"),
("withdraws",),
("withdraws", "last_24h"),
("withdraws", "last_7d"),
):
cur: Any = data
ok = True
for p in path:
if not isinstance(cur, dict) or p not in cur:
ok = False
break
cur = cur[p]
if ok and isinstance(cur, dict):
if "amount" in cur:
attach_alt(cur, "amount", alt)
if "total_amount" in cur:
f = format_amount_alt(cur["total_amount"], alt)
cur["total_amount"] = f["amount"]
cur["total_amount_alt"] = f["amount_alt"]
cur["total_amount_full"] = f["amount_full"]
if "last_amount" in cur and cur["last_amount"]:
f = format_amount_alt(cur["last_amount"], alt)
cur["last_amount"] = f["amount"]
cur["last_amount_alt"] = f["amount_alt"]
if isinstance(data.get("flow"), dict):
fl = data["flow"]
for k in ("total_in", "total_out"):
if fl.get(k):
f = format_amount_alt(fl[k], alt)
fl[k] = f["amount"]
fl[f"{k}_alt"] = f["amount_alt"]
fl[f"{k}_full"] = f["amount_full"]
if data.get("balance_explorer"):
f = format_amount_alt(data["balance_explorer"], alt)
data["balance_explorer"] = f["amount"]
data["balance_explorer_alt"] = f["amount_alt"]
data["balance_explorer_full"] = f["amount_full"]
for key in ("recent_withdraws", "recent_incoming", "recent_activity"):
rows = data.get(key)
if isinstance(rows, list):
for row in rows:
if isinstance(row, dict) and row.get("amount"):
attach_alt(row, "amount", alt)
# exchange-style top-level amounts
for k in (
"wire_in_amount",
"withdraw_amount",
"coins_remaining_amount",
):
if data.get(k):
f = format_amount_alt(data[k], alt)
data[k] = f["amount"]
data[f"{k}_alt"] = f["amount_alt"]
data[f"{k}_full"] = f["amount_full"]
for key in ("by_denom", "denom_ladder"):
rows = data.get(key)
if isinstance(rows, list):
for row in rows:
if isinstance(row, dict) and row.get("value"):
f = format_amount_alt(row["value"], alt)
row["value"] = f["amount"]
row["value_alt"] = f["amount_alt"]
# merchant dual currency
for block in data.get("by_currency") or []:
if not isinstance(block, dict):
continue
for k in ("amount_sum", "amount_paid_sum"):
if block.get(k):
f = format_amount_alt(block[k], alt)
block[k] = f["amount"]
block[f"{k}_alt"] = f["amount_alt"]
block[f"{k}_full"] = f["amount_full"]
for block in data.get("recent_activity_by_currency") or []:
if not isinstance(block, dict):
continue
for row in block.get("items") or []:
if isinstance(row, dict) and row.get("amount"):
attach_alt(row, "amount", alt)
data["alt_unit_names"] = alt
return data