Files
kvitt/docs/spikes/k0/app.py
T
jacobh2 2ce317f72c K0 spike: local Enable Banking test app
Stdlib Python app (docs/spikes/k0/app.py) on localhost:8765: lists Swedish ASPSPs, starts BankID authorisation with the registered https redirect and a paste box for the landed address, creates the session, fetches transactions with and without PSU headers (continuation_key paging), balances, diff between fetches and an aggregate analysis block. Key and state in the git-ignored docs/spikes/k0/.local/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:36:12 +02:00

1765 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""K0 spike: a local test app for Enable Banking (card account via BankID).
Python 3 standard library only. The RS256 JWT is signed by shelling out to openssl.
Run: python3 docs/spikes/k0/app.py (serves http://localhost:8765/ and opens the browser)
Config and state live in docs/spikes/k0/.local/ (git-ignored):
<application-id>.pem the private key from the Enable Banking control panel
config.json optional: {"application_id": "...", "key_file": "...pem"}
state.json session, account, settings and the last fetched transactions
stdout carries only method, path and status. Never response bodies, texts or amounts.
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import html
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from decimal import Decimal, InvalidOperation
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_LOCAL_DIR = os.path.join(HERE, ".local")
DEFAULT_API_BASE = "https://api.enablebanking.com"
DEFAULT_PORT = 8765
# The control panel rejects http://localhost ("uses unsupported scheme"), so the
# registered https URL is the default and the paste box is the main path. The
# localhost callback handler stays for a future sandbox or https tunnel.
REDIRECT_CHOICES = [
"https://kvitt.suorra.duckdns.org/cards/callback",
"http://localhost:8765/callback",
]
PASTE_HINT = (
"När BankID är klart hamnar du på en sida som inte laddar. "
"Kopiera hela adressen från adressfältet och klistra in den här."
)
HISTORY_CHOICES = [7, 30, 90, 180, 365, 730]
CONSENT_CAP_SECONDS = 180 * 24 * 3600
CONSENT_FALLBACK_SECONDS = 90 * 24 * 3600 # used only when the ASPSP list is not loaded
MATCH_WINDOW_DAYS = 7 # spike heuristic for pending->booked matching by amount and date
MAX_PAGES = 200
HIGHLIGHT = re.compile(r"\bSJ\b|SEB\s*Kort|Eurocard", re.IGNORECASE)
DEFAULT_ASPSP = re.compile(r"SJ\s*Prio", re.IGNORECASE)
UUID_PEM = re.compile(
r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.pem$"
)
PSU_HEADER_CANON = {
"psu-ip-address": "Psu-Ip-Address",
"psu-user-agent": "Psu-User-Agent",
"psu-referer": "Psu-Referer",
"psu-accept": "Psu-Accept",
"psu-accept-charset": "Psu-Accept-Charset",
"psu-accept-encoding": "Psu-Accept-Encoding",
"psu-accept-language": "Psu-Accept-Language",
"psu-geo-location": "Psu-Geo-Location",
}
LOCK = threading.RLock()
CFG = {
"local_dir": DEFAULT_LOCAL_DIR,
"api_base": DEFAULT_API_BASE,
"port": DEFAULT_PORT,
"open_browser": True,
}
LAST = {} # last API response or message shown on the page (in memory only)
JWT_CACHE = {"token": None, "exp": 0, "kid": None}
def log(msg):
print(time.strftime("%H:%M:%S"), msg, flush=True)
# ----------------------------------------------------------------------------- config and state
def local_path(name):
return os.path.join(CFG["local_dir"], name)
def read_json(path, default):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return default
except (OSError, ValueError) as e:
log("could not read %s: %s" % (os.path.basename(path), type(e).__name__))
return default
def load_config():
"""Return (application_id, key_path, problems)."""
problems = []
cfg = read_json(local_path("config.json"), {})
app_id = cfg.get("application_id") if isinstance(cfg, dict) else None
key_file = cfg.get("key_file") if isinstance(cfg, dict) else None
pems = []
try:
pems = sorted(n for n in os.listdir(CFG["local_dir"]) if UUID_PEM.match(n))
except FileNotFoundError:
problems.append("Mappen %s finns inte." % CFG["local_dir"])
if not app_id:
if len(pems) == 1:
m = UUID_PEM.match(pems[0])
app_id = m.group(1) if m else None
elif len(pems) > 1:
problems.append(
"Flera <id>.pem i .local; ange application_id i config.json."
)
if key_file:
key_path = key_file if os.path.isabs(key_file) else local_path(key_file)
elif app_id:
key_path = local_path(app_id + ".pem")
else:
key_path = None
if not app_id:
problems.append("Inget application id (lägg <id>.pem i .local eller config.json).")
if key_path and not os.path.isfile(key_path):
problems.append("Nyckelfilen %s saknas." % os.path.basename(key_path))
return app_id, key_path, problems
def default_state():
return {
"settings": {
"aspsp_name": "",
"history_days": 90,
"redirect_url": REDIRECT_CHOICES[0],
"auth_method": "",
"strategy": "default",
"psu_ip": "",
},
"aspsps": [],
"aspsps_fetched_at": None,
"pending_auth": None,
"session": None,
"account_uid": None,
"transactions": [],
"transactions_meta": None,
"previous_transactions": [],
"last_diff": None,
"balances": None,
"fetch_log": [],
}
def load_state():
st = default_state()
saved = read_json(local_path("state.json"), {})
if isinstance(saved, dict):
for k, v in saved.items():
if k == "settings" and isinstance(v, dict):
st["settings"].update(v)
else:
st[k] = v
return st
def save_state(st):
os.makedirs(CFG["local_dir"], exist_ok=True)
try:
os.chmod(CFG["local_dir"], 0o700)
except OSError as e:
log("chmod .local failed: %s" % type(e).__name__)
path = local_path("state.json")
tmp = path + ".tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(st, f, ensure_ascii=False, indent=1)
os.replace(tmp, path)
# ----------------------------------------------------------------------------- JWT
def b64url(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def openssl_bin():
return shutil.which("openssl") or "/usr/bin/openssl"
def make_jwt(app_id, key_path, now=None):
iat = int(now if now is not None else time.time())
header = {"typ": "JWT", "alg": "RS256", "kid": app_id}
payload = {
"iss": "enablebanking.com",
"aud": "api.enablebanking.com",
"iat": iat,
"exp": iat + 3600,
}
signing_input = (
b64url(json.dumps(header, separators=(",", ":")).encode())
+ "."
+ b64url(json.dumps(payload, separators=(",", ":")).encode())
)
proc = subprocess.run(
[openssl_bin(), "dgst", "-sha256", "-sign", key_path],
input=signing_input.encode("ascii"),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if proc.returncode != 0 or not proc.stdout:
raise RuntimeError(
"openssl kunde inte signera (exit %d): %s"
% (proc.returncode, proc.stderr.decode("utf-8", "replace").strip()[:300])
)
return signing_input + "." + b64url(proc.stdout), payload["exp"]
def get_jwt():
app_id, key_path, problems = load_config()
if not app_id or not key_path or not os.path.isfile(key_path):
raise RuntimeError("; ".join(problems) or "Nyckel saknas.")
now = time.time()
if JWT_CACHE["token"] and JWT_CACHE["kid"] == app_id and JWT_CACHE["exp"] - now > 300:
return JWT_CACHE["token"]
token, exp = make_jwt(app_id, key_path, now)
JWT_CACHE.update(token=token, exp=exp, kid=app_id)
return token
# ----------------------------------------------------------------------------- HTTP client
class ApiResult(object):
def __init__(self, status, data, raw_text, error=None):
self.status = status
self.data = data
self.raw_text = raw_text
self.error = error
@property
def ok(self):
return 200 <= self.status < 300 and self.error is None
def api(method, path, route, query=None, body=None, headers=None):
"""Call Enable Banking. `route` is the path template that is logged (no ids)."""
try:
token = get_jwt()
except (RuntimeError, OSError) as e:
return ApiResult(0, None, "", error=str(e))
url = CFG["api_base"].rstrip("/") + path
if query:
url += "?" + urllib.parse.urlencode(query)
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", "application/json")
for k, v in (headers or {}).items():
req.add_header(k, v)
status = 0
raw = b""
try:
with urllib.request.urlopen(req, timeout=90) as resp:
status = resp.status
raw = resp.read()
except urllib.error.HTTPError as e:
status = e.code
try:
raw = e.read()
except OSError:
raw = b""
except (urllib.error.URLError, OSError) as e:
log("EB %s %s network-error" % (method, route))
reason = getattr(e, "reason", e)
return ApiResult(0, None, "", error="Nätverksfel: %s" % reason)
log("EB %s %s %d" % (method, route, status))
text = raw.decode("utf-8", "replace")
try:
parsed = json.loads(text) if text.strip() else None
except ValueError:
parsed = None
return ApiResult(status, parsed, text)
def set_last(kind, title, result=None, message=None, section="top"):
entry = {"kind": kind, "title": title, "section": section, "at": now_iso()}
if result is not None:
entry["status"] = result.status
if result.error:
entry["body"] = result.error
elif result.data is not None:
entry["body"] = json.dumps(result.data, ensure_ascii=False, indent=2)
else:
entry["body"] = result.raw_text[:4000]
if message:
entry["message"] = message
LAST.clear()
LAST.update(entry)
def fail(title, result, section):
set_last("error", title, result=result, section=section)
def now_iso():
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
def public_ip():
try:
with urllib.request.urlopen("https://api.ipify.org?format=text", timeout=10) as r:
ip = r.read().decode("ascii", "replace").strip()
log("ipify GET / %d" % r.status)
if re.match(r"^[0-9a-fA-F:.]+$", ip):
return ip
except (urllib.error.URLError, OSError):
log("ipify GET / network-error")
return None
# ----------------------------------------------------------------------------- helpers on data
def chosen_aspsp(st):
name = st["settings"].get("aspsp_name")
for a in st.get("aspsps") or []:
if a.get("name") == name:
return a
return None
def default_aspsp_name(aspsps):
for a in aspsps:
if DEFAULT_ASPSP.search(a.get("name") or ""):
return a.get("name")
return ""
def mask(value):
s = str(value)
if len(s) <= 4:
return "••••"
return "••••" + s[-4:]
def mask_ids(obj):
"""Mask every string leaf of an identifier structure to its last 4 characters,
keeping scheme names readable."""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if k in ("scheme_name", "issuer") and isinstance(v, str):
out[k] = v
else:
out[k] = mask_ids(v)
return out
if isinstance(obj, list):
return [mask_ids(v) for v in obj]
if obj is None:
return None
return mask(obj)
def amount_of(t):
ta = t.get("transaction_amount") or {}
try:
val = abs(Decimal(str(ta.get("amount"))))
except (InvalidOperation, ValueError, TypeError):
return None
if t.get("credit_debit_indicator") == "DBIT":
val = -val
return val
def currency_of(t):
return (t.get("transaction_amount") or {}).get("currency") or ""
def fmt_kr(val):
if val is None:
return "?"
sign = "−" if val < 0 else "+"
s = "{:,.2f}".format(abs(val)).replace(",", " ").replace(".", ",")
return sign + s
def text_of(t):
parts = []
party = t.get("creditor") if t.get("credit_debit_indicator") == "DBIT" else t.get("debtor")
if isinstance(party, dict) and party.get("name"):
parts.append(str(party["name"]))
ri = t.get("remittance_information")
if isinstance(ri, list):
parts.extend(str(x) for x in ri if x)
elif ri:
parts.append(str(ri))
if t.get("note"):
parts.append(str(t["note"]))
return " / ".join(parts)
def date_of(t):
return t.get("transaction_date") or t.get("booking_date") or t.get("value_date")
def parse_date(s):
try:
return dt.date.fromisoformat(str(s)[:10])
except (ValueError, TypeError):
return None
def present(v):
return v is not None and v != "" and v != [] and v != {}
def flatten_presence(obj, prefix, out):
for k, v in obj.items():
p = prefix + k
if isinstance(v, dict) and v:
out[p] = True
flatten_presence(v, p + ".", out)
else:
out[p] = present(v)
def field_presence(txs):
seen = {}
for t in txs:
flat = {}
flatten_presence(t, "", flat)
for k, v in flat.items():
seen.setdefault(k, 0)
if v:
seen[k] += 1
return seen
# ----------------------------------------------------------------------------- diff
def _er(t):
v = t.get("entry_reference")
return str(v) if present(v) else None
def diff_transactions(prev, curr):
"""Match the previous fetch to the current one.
1. equal entry_reference;
2a. same amount, direction, currency, date and text (entry_reference ignored);
2b. same amount, direction and currency, date within MATCH_WINDOW_DAYS, only
when at least one side has no entry_reference (closest date wins).
"""
matched_prev = {}
matched_curr = {}
by = {}
prev_er = {}
for i, t in enumerate(prev):
er = _er(t)
if er is not None:
prev_er.setdefault(er, []).append(i)
for j, t in enumerate(curr):
er = _er(t)
if er is None:
continue
for i in prev_er.get(er, []):
if i not in matched_prev:
matched_prev[i] = j
matched_curr[j] = i
by[j] = "entry_reference"
break
def fp(t, with_text):
return (
str(amount_of(t)),
t.get("credit_debit_indicator"),
currency_of(t),
date_of(t) if with_text else None,
text_of(t) if with_text else None,
)
strict = {}
for i, t in enumerate(prev):
if i not in matched_prev:
strict.setdefault(fp(t, True), []).append(i)
for j, t in enumerate(curr):
if j in matched_curr:
continue
for i in strict.get(fp(t, True), []):
if i not in matched_prev:
matched_prev[i] = j
matched_curr[j] = i
by[j] = "amount_date"
break
for j, t in enumerate(curr):
if j in matched_curr:
continue
dj = parse_date(date_of(t))
best = None
for i, p in enumerate(prev):
if i in matched_prev:
continue
if fp(p, False) != fp(t, False):
continue
if _er(p) is not None and _er(t) is not None:
continue
di = parse_date(date_of(p))
if dj is None or di is None:
continue
gap = abs((dj - di).days)
if gap > MATCH_WINDOW_DAYS:
continue
if best is None or gap < best[0]:
best = (gap, i)
if best is not None:
i = best[1]
matched_prev[i] = j
matched_curr[j] = i
by[j] = "amount_window"
compare = [
"status",
"booking_date",
"value_date",
"transaction_date",
"entry_reference",
"transaction_id",
]
changed = []
stats = {
"pdng_to_book_by_entry_reference": 0,
"pdng_to_book_by_amount_date": 0,
"er_same": 0,
"er_changed": 0,
"er_appeared": 0,
"er_prev_unmatched": 0,
}
for j, i in sorted(matched_curr.items()):
p, c = prev[i], curr[j]
fields = [f for f in compare if p.get(f) != c.get(f)]
if text_of(p) != text_of(c):
fields.append("text")
if str(amount_of(p)) != str(amount_of(c)):
fields.append("amount")
if _er(p) is not None and _er(c) is not None:
if _er(p) == _er(c):
stats["er_same"] += 1
else:
stats["er_changed"] += 1
elif _er(p) is None and _er(c) is not None:
stats["er_appeared"] += 1
if p.get("status") == "PDNG" and c.get("status") == "BOOK":
if by[j] == "entry_reference":
stats["pdng_to_book_by_entry_reference"] += 1
else:
stats["pdng_to_book_by_amount_date"] += 1
if fields:
changed.append({"idx": j, "prev_idx": i, "fields": fields, "by": by[j]})
new = [j for j in range(len(curr)) if j not in matched_curr]
gone = [i for i in range(len(prev)) if i not in matched_prev]
stats["er_prev_unmatched"] = sum(1 for i in gone if _er(prev[i]) is not None)
return {
"new": new,
"changed": changed,
"disappeared": gone,
"matched_by": {str(j): by[j] for j in by},
"stats": stats,
"had_previous": bool(prev),
}
# ----------------------------------------------------------------------------- actions
def action_list_aspsps(st):
res = api("GET", "/aspsps", "/aspsps", query={"country": "SE"})
if not res.ok or not isinstance(res.data, dict):
fail("Lista banker misslyckades", res, "banker")
return
aspsps = res.data.get("aspsps") or []
for a in aspsps:
a.pop("logo", None)
st["aspsps"] = aspsps
st["aspsps_fetched_at"] = now_iso()
if not chosen_aspsp(st):
st["settings"]["aspsp_name"] = default_aspsp_name(aspsps)
set_last("ok", "Lista banker", message="%d banker i SE." % len(aspsps), section="banker")
def action_choose_aspsp(st, form):
name = form.get("aspsp", "")
if not any(a.get("name") == name for a in st.get("aspsps") or []):
set_last("error", "Välj bank", message="Okänd bank.", section="banker")
return
st["settings"]["aspsp_name"] = name
st["settings"]["auth_method"] = ""
set_last("ok", "Välj bank", message="Vald: %s" % name, section="banker")
def save_settings(st, form):
s = st["settings"]
try:
days = int(form.get("history_days", s["history_days"]))
if days in HISTORY_CHOICES:
s["history_days"] = days
except ValueError:
pass
choice = form.get("redirect_choice", "")
custom = form.get("redirect_custom", "").strip()
if choice == "custom" and custom:
s["redirect_url"] = custom
elif choice in REDIRECT_CHOICES:
s["redirect_url"] = choice
s["auth_method"] = form.get("auth_method", "")
if form.get("strategy") in ("default", "longest"):
s["strategy"] = form["strategy"]
def consent_seconds(aspsp):
if aspsp and isinstance(aspsp.get("maximum_consent_validity"), (int, float)):
return min(int(aspsp["maximum_consent_validity"]), CONSENT_CAP_SECONDS)
return CONSENT_FALLBACK_SECONDS
def build_auth_body(st, state_token, now=None):
s = st["settings"]
aspsp = chosen_aspsp(st)
now = now or dt.datetime.now(dt.timezone.utc)
# a few minutes' margin so the request never exceeds the ASPSP's maximum
valid_until = now + dt.timedelta(seconds=consent_seconds(aspsp) - 300)
body = {
"access": {"valid_until": valid_until.replace(microsecond=0).isoformat()},
"aspsp": {"name": s["aspsp_name"], "country": "SE"},
"state": state_token,
"redirect_url": s["redirect_url"],
"psu_type": "personal",
}
if s.get("auth_method"):
body["auth_method"] = s["auth_method"]
return body
def action_auth(st):
if not st["settings"].get("aspsp_name"):
set_last("error", "Anslut", message="Välj en bank först (Lista banker).", section="anslut")
return None
token = secrets.token_urlsafe(24)
body = build_auth_body(st, token)
res = api("POST", "/auth", "/auth", body=body)
if not res.ok or not isinstance(res.data, dict) or not res.data.get("url"):
fail("POST /auth misslyckades", res, "anslut")
return None
st["pending_auth"] = {
"state": token,
"aspsp": body["aspsp"],
"valid_until": body["access"]["valid_until"],
"redirect_url": body["redirect_url"],
"started_at": now_iso(),
"authorization_id": res.data.get("authorization_id"),
"url": res.data["url"],
}
set_last(
"ok",
"POST /auth (HTTP %d)" % res.status,
message="Bankens sida öppnas i en ny flik. Scanna BankID-QR-koden med telefonen. " + PASTE_HINT,
section="anslut",
)
return res.data["url"]
def extract_code_state(text):
"""Pull code, state, error and error_description out of any pasted URL or query."""
text = (text or "").strip()
found = {}
candidates = []
parsed = urllib.parse.urlparse(text)
candidates.append(parsed.query)
candidates.append(parsed.fragment)
if "?" in text:
candidates.append(text.split("?", 1)[1].split("#", 1)[0])
candidates.append(text.lstrip("?#"))
for c in candidates:
if not c:
continue
qs = urllib.parse.parse_qs(c, keep_blank_values=False)
for key in ("code", "state", "error", "error_description"):
if key not in found and qs.get(key):
found[key] = qs[key][0]
return found
def complete_auth(st, params, source):
if params.get("error"):
set_last(
"error",
"Banken svarade med fel (%s)" % source,
message="%s: %s" % (params.get("error"), params.get("error_description", "")),
section="anslut",
)
return
code = params.get("code")
if not code:
set_last("error", "Ingen code", message="Hittade ingen code i adressen.", section="anslut")
return
pending = st.get("pending_auth") or {}
if pending.get("state") and params.get("state") != pending.get("state"):
set_last(
"error",
"state stämmer inte",
message="state i adressen matchar inte den senaste anslutningen. Starta om Anslut med BankID.",
section="anslut",
)
return
res = api("POST", "/sessions", "/sessions", body={"code": code})
if not res.ok or not isinstance(res.data, dict):
fail("POST /sessions misslyckades", res, "anslut")
return
d = res.data
accounts = d.get("accounts") or []
st["session"] = {
"session_id": d.get("session_id"),
"valid_until": (d.get("access") or {}).get("valid_until"),
"aspsp": d.get("aspsp"),
"psu_type": d.get("psu_type"),
"accounts": accounts,
"created_at": now_iso(),
}
st["pending_auth"] = None
st["account_uid"] = default_account_uid(accounts)
st["balances"] = None
set_last(
"ok",
"Session skapad (%s)" % source,
message="%d konto(n). Giltig till %s." % (len(accounts), st["session"]["valid_until"]),
section="konton",
)
def default_account_uid(accounts):
if len(accounts) == 1:
return accounts[0].get("uid")
for a in accounts:
if (a.get("cash_account_type") or "").upper() == "CARD":
return a.get("uid")
return None
def action_choose_account(st, form):
uid = form.get("account_uid", "")
accounts = (st.get("session") or {}).get("accounts") or []
if not any(a.get("uid") == uid for a in accounts):
set_last("error", "Välj konto", message="Okänt konto.", section="konton")
return
st["account_uid"] = uid
set_last("ok", "Välj konto", message="Konto valt.", section="konton")
def action_check_session(st):
sess = st.get("session") or {}
if not sess.get("session_id"):
set_last("error", "Session", message="Ingen session.", section="konton")
return
res = api(
"GET",
"/sessions/" + urllib.parse.quote(sess["session_id"], safe=""),
"/sessions/{id}",
)
if not res.ok:
fail("GET /sessions/{id} misslyckades", res, "konton")
return
data = res.data if isinstance(res.data, dict) else {}
summary = {k: data.get(k) for k in ("status", "authorized", "created", "access", "psu_type")}
set_last(
"ok",
"GET /sessions/{id}",
message=json.dumps(summary, ensure_ascii=False),
section="konton",
)
def psu_headers(st, browser):
"""Headers that tell Enable Banking the user is present.
Always Psu-Ip-Address and Psu-User-Agent, plus every header the ASPSP lists in
required_psu_headers that the browser request lets us fill."""
s = st["settings"]
ip = (s.get("psu_ip") or "").strip()
note = []
if not ip:
ip = public_ip() or ""
if ip:
s["psu_ip"] = ip
note.append("PSU-IP hämtad från ipify")
if not ip:
ip = browser.get("client_ip") or "127.0.0.1"
note.append("PSU-IP: använder %s (ingen publik IP hittad)" % ip)
values = {
"psu-ip-address": ip,
"psu-user-agent": browser.get("user_agent") or "Mozilla/5.0",
"psu-referer": "http://localhost:%d/" % CFG["port"],
"psu-accept": browser.get("accept") or "text/html",
"psu-accept-charset": browser.get("accept_charset") or "utf-8",
"psu-accept-encoding": browser.get("accept_encoding") or "gzip, deflate",
"psu-accept-language": browser.get("accept_language") or "sv-SE,sv;q=0.9",
}
wanted = ["psu-ip-address", "psu-user-agent"]
aspsp = chosen_aspsp(st)
for h in (aspsp or {}).get("required_psu_headers") or []:
lh = str(h).lower()
if lh not in wanted:
wanted.append(lh)
headers = {}
for lh in wanted:
if lh in values:
headers[PSU_HEADER_CANON.get(lh, lh)] = values[lh]
else:
note.append("kan inte fylla %s" % lh)
return headers, note
def error_code(res):
if isinstance(res.data, dict):
return res.data.get("error") or res.data.get("code")
return res.error and "network"
def action_fetch(st, mode, browser):
uid = st.get("account_uid")
if not uid:
set_last("error", "Hämta", message="Inget konto valt.", section="hamta")
return
s = st["settings"]
date_from = (dt.date.today() - dt.timedelta(days=int(s["history_days"]))).isoformat()
query = {"date_from": date_from}
if s.get("strategy") == "longest":
query["strategy"] = "longest"
headers, notes = psu_headers(st, browser) if mode == "user" else ({}, [])
txs = []
pages = 0
empty_pages = 0
key = None
truncated = False
res = None
path = "/accounts/%s/transactions" % urllib.parse.quote(uid, safe="")
while True:
q = dict(query)
if key:
q["continuation_key"] = key
res = api("GET", path, "/accounts/{uid}/transactions", query=q, headers=headers)
pages += 1
if not res.ok or not isinstance(res.data, dict):
break
page = res.data.get("transactions") or []
if not page:
empty_pages += 1
txs.extend(page)
key = res.data.get("continuation_key")
if not key:
break
if pages >= MAX_PAGES:
truncated = True
break
entry = {
"at": now_iso(),
"mode": mode,
"strategy": s.get("strategy"),
"history_days": s["history_days"],
"date_from": date_from,
"psu_headers": sorted(headers.keys()),
"status": res.status if res else 0,
"pages": pages,
"empty_pages": empty_pages,
"truncated": truncated,
}
if res is None or not res.ok:
entry["error"] = error_code(res) if res else "none"
entry["ok"] = False
st["fetch_log"].append(entry)
label = "Uppdatera" if mode == "user" else "Bakgrundshämtning"
fail("%s: sida %d misslyckades" % (label, pages), res, "hamta")
if notes:
LAST["message"] = "; ".join(notes)
return
prev = st.get("transactions") or []
d = diff_transactions(prev, txs)
dates = [parse_date(date_of(t)) for t in txs]
dates = [x for x in dates if x]
entry.update(
{
"ok": True,
"count": len(txs),
"oldest": min(dates).isoformat() if dates else None,
"newest": max(dates).isoformat() if dates else None,
"new": len(d["new"]),
"changed": len(d["changed"]),
"disappeared": len(d["disappeared"]),
"had_previous": d["had_previous"],
}
)
entry.update(d["stats"])
st["fetch_log"].append(entry)
st["previous_transactions"] = prev
st["transactions"] = txs
st["transactions_meta"] = {
"at": entry["at"],
"mode": mode,
"date_from": date_from,
"pages": pages,
"strategy": s.get("strategy"),
}
st["last_diff"] = d
msg = "%d transaktioner, %d sidor. Nya %d, ändrade %d, borta %d." % (
len(txs),
pages,
len(d["new"]),
len(d["changed"]),
len(d["disappeared"]),
)
if notes:
msg += " (" + "; ".join(notes) + ")"
if truncated:
msg += " Avbröt efter %d sidor." % MAX_PAGES
label = "Uppdatera" if mode == "user" else "Bakgrundshämtning"
set_last("ok", label + " (HTTP %d)" % res.status, message=msg, section="kop")
def action_balances(st, browser):
uid = st.get("account_uid")
if not uid:
set_last("error", "Saldo", message="Inget konto valt.", section="hamta")
return
headers, notes = psu_headers(st, browser)
res = api(
"GET",
"/accounts/%s/balances" % urllib.parse.quote(uid, safe=""),
"/accounts/{uid}/balances",
headers=headers,
)
entry = {
"at": now_iso(),
"mode": "balances",
"status": res.status,
"ok": res.ok,
"psu_headers": sorted(headers.keys()),
}
if not res.ok or not isinstance(res.data, dict):
entry["error"] = error_code(res)
st["fetch_log"].append(entry)
fail("Saldo misslyckades", res, "hamta")
return
bal = res.data.get("balances") or []
entry["balance_types"] = sorted({str(b.get("balance_type")) for b in bal})
st["fetch_log"].append(entry)
st["balances"] = {"at": entry["at"], "balances": bal}
set_last("ok", "Saldo (HTTP %d)" % res.status, message="%d saldon." % len(bal), section="hamta")
if notes:
LAST["message"] += " (" + "; ".join(notes) + ")"
def action_disconnect(st):
sess = st.get("session") or {}
msg = "Lokalt tillstånd rensat."
if sess.get("session_id"):
res = api(
"DELETE",
"/sessions/" + urllib.parse.quote(sess["session_id"], safe=""),
"/sessions/{id}",
)
if not res.ok:
fail("DELETE /sessions/{id} misslyckades (lokalt tillstånd rensat ändå)", res, "koppla")
else:
set_last("ok", "Koppla från (HTTP %d)" % res.status, message=msg, section="koppla")
else:
set_last("ok", "Koppla från", message=msg, section="koppla")
# keep settings, the ASPSP list and the aggregate fetch log (no personal content)
keep = {
"settings": st["settings"],
"aspsps": st.get("aspsps") or [],
"aspsps_fetched_at": st.get("aspsps_fetched_at"),
"fetch_log": st.get("fetch_log") or [],
}
st.clear()
st.update(default_state())
st.update(keep)
st["fetch_log"].append({"at": now_iso(), "mode": "disconnect", "ok": True})
# ----------------------------------------------------------------------------- analysis
def pct(n, d):
return "%.1f" % (100.0 * n / d) if d else "-"
def analysis_text(st):
lines = []
txs = st.get("transactions") or []
meta = st.get("transactions_meta") or {}
aspsp = chosen_aspsp(st) or {}
sess = st.get("session") or {}
lines.append("K0 analys, genererad %s" % now_iso())
if aspsp:
mcv = aspsp.get("maximum_consent_validity")
lines.append(
"ASPSP: %s (%s), beta %s, maximum_consent_validity %s s (%s d), psu_types %s"
% (
aspsp.get("name"),
aspsp.get("country"),
aspsp.get("beta"),
mcv,
round(mcv / 86400.0, 1) if isinstance(mcv, (int, float)) else "?",
",".join(aspsp.get("psu_types") or []),
)
)
lines.append(
"auth_methods: %s; required_psu_headers: %s"
% (
"; ".join(
"%s/%s/%s" % (m.get("name"), m.get("approach"), m.get("psu_type"))
for m in aspsp.get("auth_methods") or []
)
or "-",
",".join(aspsp.get("required_psu_headers") or []) or "-",
)
)
else:
lines.append("ASPSP: inte vald")
if sess.get("session_id"):
accts = sess.get("accounts") or []
lines.append(
"Session: giltig till %s; konton %d; valutor %s; cash_account_type %s"
% (
(sess.get("valid_until") or "?")[:10],
len(accts),
",".join(sorted({str(a.get("currency")) for a in accts})),
",".join(sorted({str(a.get("cash_account_type")) for a in accts})),
)
)
else:
lines.append("Session: ingen")
lines.append("")
n = len(txs)
lines.append(
"Senaste lyckade hämtning: %s, läge %s, strategi %s, sidor %s"
% (meta.get("at", "-"), meta.get("mode", "-"), meta.get("strategy", "-"), meta.get("pages", "-"))
)
lines.append("Transaktioner: %d" % n)
if n:
dates = sorted(x for x in (parse_date(date_of(t)) for t in txs) if x)
bdates = sorted(x for x in (parse_date(t.get("booking_date")) for t in txs) if x)
req = parse_date(meta.get("date_from"))
oldest = dates[0] if dates else None
lines.append(
"Begärt från %s (%s d); äldsta nådda (transaction/booking/value) %s; äldsta booking_date %s; nyaste %s; täckning %s d av begärda %s d"
% (
meta.get("date_from"),
st["settings"].get("history_days"),
oldest,
bdates[0] if bdates else "-",
dates[-1] if dates else "-",
(dt.date.today() - oldest).days if oldest else "-",
(dt.date.today() - req).days if req else "-",
)
)
by_status = {}
by_dir = {}
for t in txs:
by_status[str(t.get("status"))] = by_status.get(str(t.get("status")), 0) + 1
k = str(t.get("credit_debit_indicator"))
by_dir[k] = by_dir.get(k, 0) + 1
lines.append("Status: " + ", ".join("%s %d" % kv for kv in sorted(by_status.items())))
lines.append("Riktning: " + ", ".join("%s %d" % kv for kv in sorted(by_dir.items())))
non_sek = [t for t in txs if currency_of(t) and currency_of(t) != "SEK"]
lines.append(
"Icke-SEK transaction_amount: %d (exchange_rate ifyllt i %d; valutor %s)"
% (
len(non_sek),
sum(1 for t in non_sek if present(t.get("exchange_rate"))),
",".join(sorted({currency_of(t) for t in non_sek})) or "-",
)
)
with_er = [t for t in txs if _er(t) is not None]
ers = [_er(t) for t in with_er]
er_by_status = {}
for t in txs:
s = str(t.get("status"))
tot, has = er_by_status.get(s, (0, 0))
er_by_status[s] = (tot + 1, has + (1 if _er(t) is not None else 0))
lines.append(
"entry_reference: finns i %d/%d (%s); unika %d; dubbletter %d"
% (
len(with_er),
n,
", ".join("%s %d/%d" % (s, h, tt) for s, (tt, h) in sorted(er_by_status.items())),
len(set(ers)),
len(ers) - len(set(ers)),
)
)
lines.append(
"transaction_id: finns i %d/%d"
% (sum(1 for t in txs if present(t.get("transaction_id"))), n)
)
lines.append(
"Text tom (creditor/debtor-namn, remittance_information, note): %d/%d"
% (sum(1 for t in txs if not text_of(t)), n)
)
lines.append("")
lines.append("Fältnärvaro (%% av %d rader, ifyllt):" % n)
for k, c in sorted(field_presence(txs).items()):
lines.append(" %-45s %6s%%" % (k, pct(c, n)))
log_entries = st.get("fetch_log") or []
tx_fetches = [e for e in log_entries if e.get("mode") in ("user", "background")]
ok_fetches = [e for e in tx_fetches if e.get("ok")]
lines.append("")
lines.append(
"Hämtningar: %d transaktionsanrop (%d lyckade), %d saldoanrop"
% (
len(tx_fetches),
len(ok_fetches),
sum(1 for e in log_entries if e.get("mode") == "balances"),
)
)
tot = {
"pdng_to_book_by_entry_reference": 0,
"pdng_to_book_by_amount_date": 0,
"er_same": 0,
"er_changed": 0,
"er_appeared": 0,
"er_prev_unmatched": 0,
}
for e in ok_fetches:
for k in tot:
tot[k] += int(e.get(k) or 0)
lines.append(
"PDNG->BOOK (summerat över hämtningar): via entry_reference %d, via belopp+datum (exakt eller ±%d d) %d"
% (
tot["pdng_to_book_by_entry_reference"],
MATCH_WINDOW_DAYS,
tot["pdng_to_book_by_amount_date"],
)
)
lines.append(
"entry_reference mellan hämtningar: samma %d, ändrad %d, dök upp (saknades förut) %d, föregående med ER som försvann %d"
% (tot["er_same"], tot["er_changed"], tot["er_appeared"], tot["er_prev_unmatched"])
)
for mode in ("user", "background"):
last = [e for e in tx_fetches if e.get("mode") == mode]
if last:
e = last[-1]
lines.append(
"Senaste %s: HTTP %s%s, PSU-headers [%s]"
% (
"Uppdatera" if mode == "user" else "Bakgrundshämtning",
e.get("status"),
(" " + str(e.get("error"))) if e.get("error") else "",
",".join(e.get("psu_headers") or []),
)
)
bal = [e for e in log_entries if e.get("mode") == "balances"]
if bal:
e = bal[-1]
lines.append(
"Senaste Saldo: HTTP %s%s, balance_type %s"
% (
e.get("status"),
(" " + str(e.get("error"))) if e.get("error") else "",
",".join(e.get("balance_types") or []) or "-",
)
)
if log_entries:
lines.append("")
lines.append("Logg (UTC; läge, HTTP, sidor/tomma, rader, från, äldsta, ny/ändr/borta, PDNG->BOOK er/datum):")
for e in log_entries[-40:]:
if e.get("mode") in ("user", "background"):
lines.append(
" %s %-10s %s sidor %s/%s rader %s från %s äldsta %s ny %s ändr %s borta %s pb %s/%s%s"
% (
e.get("at"),
e.get("mode"),
e.get("status"),
e.get("pages"),
e.get("empty_pages"),
e.get("count", "-"),
e.get("date_from"),
e.get("oldest", "-"),
e.get("new", "-"),
e.get("changed", "-"),
e.get("disappeared", "-"),
e.get("pdng_to_book_by_entry_reference", "-"),
e.get("pdng_to_book_by_amount_date", "-"),
(" fel " + str(e.get("error"))) if e.get("error") else "",
)
)
else:
lines.append(
" %s %-10s %s%s"
% (
e.get("at"),
e.get("mode"),
e.get("status", ""),
(" fel " + str(e.get("error"))) if e.get("error") else "",
)
)
return "\n".join(lines)
# ----------------------------------------------------------------------------- HTML
CSS = """
:root{--fg:#1b1b1b;--bg:#fafafa;--mut:#666;--hl:#fff3c4;--ok:#e6f4ea;--err:#fde7e7;--bd:#ddd}
@media (prefers-color-scheme: dark){:root{--fg:#eee;--bg:#161616;--mut:#999;--hl:#4a3f12;--ok:#173322;--err:#4a1c1c;--bd:#333}}
body{font:14px/1.45 system-ui,sans-serif;color:var(--fg);background:var(--bg);max-width:1200px;margin:0 auto;padding:16px}
h1{font-size:20px}h2{font-size:16px;margin-top:28px;border-bottom:1px solid var(--bd);padding-bottom:4px}
table{border-collapse:collapse;width:100%;margin:8px 0}td,th{border-bottom:1px solid var(--bd);padding:4px 6px;text-align:left;vertical-align:top}
th{font-weight:600}.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.hl{background:var(--hl)}.ok{background:var(--ok);padding:8px}.err{background:var(--err);padding:8px}
.mut{color:var(--mut)}pre{white-space:pre-wrap;word-break:break-word;font-size:12px;margin:4px 0}
textarea{width:100%;font:12px ui-monospace,monospace}button{margin:2px 4px 2px 0}
.tag{font-size:11px;padding:1px 5px;border-radius:3px;border:1px solid var(--bd)}
.scroll{max-height:420px;overflow:auto}
.paste{border:2px solid var(--bd);padding:8px 12px;margin:12px 0}.paste.active{border-color:#c99a00;background:var(--hl)}
"""
def e(s):
return html.escape("" if s is None else str(s), quote=True)
def render_last():
if not LAST:
return ""
cls = "ok" if LAST.get("kind") == "ok" else "err"
out = ['<div class="%s" id="svar"><b>%s</b>' % (cls, e(LAST.get("title")))]
if "status" in LAST:
out.append(" <span class=tag>HTTP %s</span>" % e(LAST.get("status")))
out.append(" <span class=mut>%s</span>" % e(LAST.get("at")))
if LAST.get("message"):
out.append("<div>%s</div>" % e(LAST["message"]))
if LAST.get("body") and LAST.get("kind") != "ok":
out.append("<pre>%s</pre>" % e(LAST["body"]))
out.append("</div>")
return "".join(out)
def render_status(st):
app_id, key_path, problems = load_config()
sess = st.get("session") or {}
rows = [
("Application id", e(app_id) if app_id else "<b>saknas</b>"),
(
"Nyckel",
("hittad (%s)" % e(os.path.basename(key_path)))
if key_path and os.path.isfile(key_path)
else "<b>saknas</b>",
),
("openssl", e(openssl_bin())),
("API", e(CFG["api_base"])),
("Bank", e(st["settings"].get("aspsp_name")) or "<b>inte vald</b>"),
]
if sess.get("session_id"):
vu = sess.get("valid_until") or ""
left = ""
try:
vdt = dt.datetime.fromisoformat(vu.replace("Z", "+00:00"))
left = " (%d dagar kvar)" % (vdt - dt.datetime.now(dt.timezone.utc)).days
except ValueError:
pass
rows.append(("Session", "finns, giltig till %s%s" % (e(vu), e(left))))
else:
rows.append(("Session", "<b>ingen</b>"))
acct = current_account(st)
rows.append(
(
"Konto",
e("%s %s (%s)" % (acct.get("name") or "", acct.get("product") or "", acct.get("currency") or ""))
if acct
else "<b>inte valt</b>",
)
)
if st.get("pending_auth"):
rows.append(("Väntar på", "callback (startad %s)" % e(st["pending_auth"].get("started_at"))))
out = ["<table>"]
for k, v in rows:
out.append("<tr><th>%s</th><td>%s</td></tr>" % (e(k), v))
out.append("</table>")
if problems:
out.append('<div class="err">%s</div>' % "<br>".join(e(p) for p in problems))
return "".join(out)
def current_account(st):
uid = st.get("account_uid")
for a in (st.get("session") or {}).get("accounts") or []:
if a.get("uid") == uid:
return a
return None
def render_aspsps(st):
out = [
'<form method=post action="/action/list_aspsps"><button>Lista banker</button>'
' <span class=mut>GET /aspsps?country=SE %s</span></form>'
% (("(hämtad " + e(st.get("aspsps_fetched_at")) + ")") if st.get("aspsps_fetched_at") else "")
]
aspsps = st.get("aspsps") or []
if aspsps:
chosen = st["settings"].get("aspsp_name")
ordered = sorted(
aspsps, key=lambda a: (0 if HIGHLIGHT.search(a.get("name") or "") else 1, a.get("name") or "")
)
out.append('<form method=post action="/action/choose_aspsp"><div class=scroll><table>')
out.append(
"<tr><th></th><th>Namn</th><th>beta</th><th>Max samtycke</th><th>psu_types</th>"
"<th>auth_methods</th><th>required_psu_headers</th></tr>"
)
for a in ordered:
name = a.get("name") or ""
mcv = a.get("maximum_consent_validity")
methods = "; ".join(
"%s/%s/%s" % (m.get("name"), m.get("approach"), m.get("psu_type"))
for m in a.get("auth_methods") or []
)
out.append(
'<tr class="%s"><td><input type=radio name=aspsp value="%s"%s></td><td>%s</td><td>%s</td>'
'<td class=num>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'
% (
"hl" if HIGHLIGHT.search(name) else "",
e(name),
" checked" if name == chosen else "",
e(name),
e(a.get("beta")),
e("%.0f d" % (mcv / 86400.0)) if isinstance(mcv, (int, float)) else "?",
e(",".join(a.get("psu_types") or [])),
e(methods),
e(",".join(a.get("required_psu_headers") or [])),
)
)
out.append("</table></div><button>Välj bank</button></form>")
a = chosen_aspsp(st)
if a:
out.append("<p><b>Vald: %s</b></p><pre>%s</pre>" % (e(a.get("name")), e(json.dumps(a, ensure_ascii=False, indent=2))))
return "".join(out)
def render_connect(st):
s = st["settings"]
a = chosen_aspsp(st)
secs = consent_seconds(a)
days_opts = "".join(
'<option value="%d"%s>%d dagar</option>' % (d, " selected" if d == s["history_days"] else "", d)
for d in HISTORY_CHOICES
)
redirect = s.get("redirect_url")
radios = []
for r in REDIRECT_CHOICES:
radios.append(
'<label><input type=radio name=redirect_choice value="%s"%s> %s</label><br>'
% (e(r), " checked" if r == redirect else "", e(r))
)
custom = redirect not in REDIRECT_CHOICES
radios.append(
'<label><input type=radio name=redirect_choice value=custom%s> annan: </label>'
'<input name=redirect_custom size=60 value="%s">'
% (" checked" if custom else "", e(redirect if custom else ""))
)
methods = [m for m in (a or {}).get("auth_methods") or [] if m.get("psu_type") in (None, "personal")]
m_opts = ['<option value="">(bankens standard)</option>']
for m in methods:
m_opts.append(
'<option value="%s"%s>%s (%s)</option>'
% (e(m.get("name")), " selected" if m.get("name") == s.get("auth_method") else "", e(m.get("name")), e(m.get("approach")))
)
strat = "".join(
'<option value="%s"%s>%s</option>' % (v, " selected" if v == s.get("strategy") else "", v)
for v in ("default", "longest")
)
out = [
'<form method=post action="/action/settings">',
"<p>Historik: <select name=history_days>%s</select> &nbsp; strategi: <select name=strategy>%s</select>"
' <span class=mut>(longest = Enable Bankings strategy=longest)</span></p>' % (days_opts, strat),
"<p>Redirect URL (måste finnas registrerad i Enable Banking-appen):<br>%s</p>" % "".join(radios),
"<p>auth_method: <select name=auth_method>%s</select></p>" % "".join(m_opts),
"<p class=mut>access.valid_until = nu + %s dagar (%s, max 180) − 5 min; psu_type personal; aspsp {%s, SE}</p>"
% (
"%.1f" % (secs / 86400.0),
"bankens maximum_consent_validity" if a else "reserv 90 d, bankens max okänt",
e(s.get("aspsp_name") or "?"),
),
'<button name=op value=save>Spara</button> <button name=op value=auth><b>Anslut med BankID</b></button>',
"</form>",
]
pending = st.get("pending_auth") or {}
box_cls = "paste active" if pending else "paste"
out.append('<div class="%s" id=klistra><h3>Klistra in adressen du hamnade på</h3>' % box_cls)
out.append("<p><b>%s</b></p>" % e(PASTE_HINT))
if pending.get("url"):
out.append(
'<p class=mut>Bankens sida öppnades i en ny flik (startad %s). '
'<a href="%s" target=_blank rel=noopener>Öppna bankens sida igen</a></p>'
% (e(pending.get("started_at")), e(pending["url"]))
)
out.append(
'<form method=post action="/action/paste"><input name=url style="width:80%%" autofocus '
'placeholder="https://kvitt.suorra.duckdns.org/cards/callback?code=...&amp;state=..."> '
"<button><b>Använd adressen</b></button></form></div>"
)
out.append(
"<p class=mut>Reserv: http://localhost:%d/callback hanteras också direkt om den adressen "
"någon gång kan registreras (kontrollpanelen nekar http i dag).</p>" % CFG["port"]
)
return "".join(out)
def render_accounts(st):
sess = st.get("session") or {}
if not sess.get("session_id"):
return "<p class=mut>Ingen session ännu.</p>"
out = [
"<p>Session %s, giltig till %s, aspsp %s, psu_type %s, skapad %s</p>"
% (
e(mask(sess.get("session_id"))),
e(sess.get("valid_until")),
e((sess.get("aspsp") or {}).get("name")),
e(sess.get("psu_type")),
e(sess.get("created_at")),
),
'<form method=post action="/action/check_session"><button>Kontrollera session</button> <span class=mut>GET /sessions/{id}</span></form>',
'<form method=post action="/action/choose_account"><table><tr><th></th><th>uid</th><th>name</th><th>product / details</th>'
"<th>valuta</th><th>cash_account_type / usage</th><th>account_id (maskerat)</th><th>all_account_ids</th><th>identification_hash</th><th>credit_limit</th></tr>",
]
for a in sess.get("accounts") or []:
out.append(
"<tr><td><input type=radio name=account_uid value=\"%s\"%s></td><td><code>%s</code></td><td>%s</td><td>%s</td>"
"<td>%s</td><td>%s</td><td><pre>%s</pre></td><td><pre>%s</pre></td><td>%s</td><td>%s</td></tr>"
% (
e(a.get("uid")),
" checked" if a.get("uid") == st.get("account_uid") else "",
e(a.get("uid")),
e(a.get("name")),
e(" / ".join(x for x in (a.get("product"), a.get("details")) if x)),
e(a.get("currency")),
e(" / ".join(str(x) for x in (a.get("cash_account_type"), a.get("usage")) if x)),
e(json.dumps(mask_ids(a.get("account_id")), ensure_ascii=False)),
e(json.dumps(mask_ids(a.get("all_account_ids")), ensure_ascii=False)),
e(mask(a.get("identification_hash") or "")),
"finns" if present(a.get("credit_limit")) else "-",
)
)
out.append("</table><button>Välj konto</button></form>")
return "".join(out)
def render_fetch(st):
s = st["settings"]
meta = st.get("transactions_meta") or {}
out = [
'<form method=post action="/action/fetch">',
"<p>PSU IP-adress: <input name=psu_ip size=20 value=\"%s\"> <span class=mut>(tom = hämtas från api.ipify.org vid första Uppdatera)</span></p>"
% e(s.get("psu_ip")),
"<p>Från %s (idag − %d dagar), strategi %s</p>"
% (
e((dt.date.today() - dt.timedelta(days=int(s["history_days"]))).isoformat()),
int(s["history_days"]),
e(s.get("strategy")),
),
"<button name=op value=user><b>Uppdatera</b></button> <span class=mut>med PSU-headers (användaren närvarande)</span><br>",
"<button name=op value=background>Bakgrundshämtning</button> <span class=mut>utan PSU-headers</span><br>",
"<button name=op value=balances>Saldo</button> <span class=mut>GET /accounts/{uid}/balances med PSU-headers</span>",
"</form>",
]
if meta:
out.append(
"<p class=mut>Senast hämtat %s (%s, %s sidor).</p>"
% (e(meta.get("at")), e(meta.get("mode")), e(meta.get("pages")))
)
bal = st.get("balances")
if bal:
out.append("<table><tr><th>name</th><th>balance_type</th><th class=num>belopp</th><th>valuta</th><th>reference_date</th><th>last_change_date_time</th></tr>")
for b in bal.get("balances") or []:
ba = b.get("balance_amount") or {}
out.append(
"<tr><td>%s</td><td>%s</td><td class=num>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>"
% (
e(b.get("name")),
e(b.get("balance_type")),
e(ba.get("amount")),
e(ba.get("currency")),
e(b.get("reference_date")),
e(b.get("last_change_date_time")),
)
)
out.append("</table><p class=mut>Saldo hämtat %s</p>" % e(bal.get("at")))
return "".join(out)
def tx_row(t, tag, raw_id):
amt = amount_of(t)
return (
"<tr><td>%s</td><td>%s</td><td>%s</td><td class=num>%s</td><td>%s</td><td>%s</td><td>%s</td>"
"<td><code>%s</code></td><td><details><summary>rå</summary><pre>%s</pre></details></td></tr>"
% (
e(t.get("booking_date") or "-") + "<br><span class=mut>" + e(t.get("transaction_date") or "-") + "</span>",
tag,
e(text_of(t) or "-"),
e(fmt_kr(amt)) + " kr",
e(currency_of(t)),
e(t.get("credit_debit_indicator")),
e(t.get("status")),
e(t.get("entry_reference") or "-"),
e(json.dumps(t, ensure_ascii=False, indent=2)),
)
)
def render_purchases(st):
txs = st.get("transactions") or []
prev = st.get("previous_transactions") or []
d = st.get("last_diff") or {}
if not txs and not d:
return "<p class=mut>Inget hämtat ännu.</p>"
new = set(d.get("new") or [])
changed = {c["idx"]: c for c in d.get("changed") or []}
out = []
if d.get("had_previous"):
out.append(
"<p>Jämfört med förra hämtningen: <b>%d nya</b>, <b>%d ändrade</b>, <b>%d borta</b>.</p>"
% (len(new), len(changed), len(d.get("disappeared") or []))
)
else:
out.append("<p class=mut>Första hämtningen; inget att jämföra med.</p>")
out.append(
"<table><tr><th>booking_date<br><span class=mut>transaction_date</span></th><th>diff</th><th>text</th>"
"<th class=num>belopp</th><th>valuta</th><th>riktning</th><th>status</th><th>entry_reference</th><th></th></tr>"
)
order = sorted(range(len(txs)), key=lambda j: (str(date_of(txs[j]) or ""), j), reverse=True)
for j in order:
t = txs[j]
tag = ""
if d.get("had_previous") and j in new:
tag = "<span class=tag>ny</span>"
elif j in changed:
c = changed[j]
p = prev[c["prev_idx"]] if c["prev_idx"] < len(prev) else {}
st_change = ""
if p.get("status") != t.get("status"):
st_change = "%s→%s " % (p.get("status"), t.get("status"))
tag = "<span class=tag>ändrad %s</span><br><span class=mut>%s; via %s</span>" % (
e(st_change),
e(",".join(c["fields"])),
e(c["by"]),
)
out.append(tx_row(t, tag, j))
out.append("</table>")
gone = d.get("disappeared") or []
if gone:
out.append("<h3>Försvunna sedan förra hämtningen</h3><table>")
for i in gone:
if i < len(prev):
out.append(tx_row(prev[i], "<span class=tag>borta</span>", "p%d" % i))
out.append("</table>")
return "".join(out)
def render_page(st):
text = analysis_text(st)
return """<!doctype html><html lang=sv><head><meta charset=utf-8>
<meta name=viewport content="width=device-width, initial-scale=1"><title>K0 Enable Banking</title><style>%s</style></head><body>
<h1>K0: Enable Banking-test</h1>
%s
<h2 id=status>1. Status</h2>%s
<h2 id=banker>2. Banker</h2>%s
<h2 id=anslut>3–4. Anslut</h2>%s
<h2 id=konton>5. Konton</h2>%s
<h2 id=hamta>6. Hämta</h2>%s
<h2 id=kop>7. Köp</h2>%s
<h2 id=analys>8. Analys</h2>
<p class=mut>Bara aggregat; inga texter, belopp eller id:n. <button type=button onclick="navigator.clipboard.writeText(document.getElementById('analys-text').value).then(function(){this.textContent='Kopierad'}.bind(this))">Kopiera analys</button></p>
<textarea id=analys-text rows=24 readonly>%s</textarea>
<h2 id=koppla>9. Koppla från</h2>
<form method=post action="/action/disconnect" onsubmit="return confirm('Koppla från och rensa sessionen?')"><button>Koppla från</button>
<span class=mut>DELETE /sessions/{id}; rensar session, konton och transaktioner lokalt (inställningar, bankernas lista och den aggregerade loggen behålls)</span></form>
</body></html>""" % (
CSS,
render_last(),
render_status(st),
render_aspsps(st),
render_connect(st),
render_accounts(st),
render_fetch(st),
render_purchases(st),
e(text),
)
# ----------------------------------------------------------------------------- server
class Handler(BaseHTTPRequestHandler):
server_version = "K0/1"
def log_request(self, code="-", size="-"):
path = self.path.split("?", 1)[0]
log("LOCAL %s %s %s" % (self.command, path, code))
def log_message(self, fmt, *args): # silence the default request-line logging
return
def send_html(self, body, status=200):
raw = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)
def redirect(self, location):
self.send_response(303)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
self.end_headers()
def browser_info(self):
return {
"client_ip": self.client_address[0],
"user_agent": self.headers.get("User-Agent"),
"accept": self.headers.get("Accept"),
"accept_charset": self.headers.get("Accept-Charset"),
"accept_encoding": self.headers.get("Accept-Encoding"),
"accept_language": self.headers.get("Accept-Language"),
}
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/":
with LOCK:
st = load_state()
self.send_html(render_page(st))
return
if parsed.path in ("/callback", "/cards/callback"):
params = extract_code_state("?" + parsed.query)
with LOCK:
st = load_state()
complete_auth(st, params, "callback")
save_state(st)
self.redirect("/#svar")
return
if parsed.path == "/favicon.ico":
self.send_response(204)
self.end_headers()
return
self.send_html("<p>Hittades inte.</p>", 404)
def same_origin(self):
origin = self.headers.get("Origin") or self.headers.get("Referer") or ""
if not origin:
return True # curl and some browsers omit both on same-origin form posts
host = urllib.parse.urlparse(origin).netloc
return host in ("localhost:%d" % CFG["port"], "127.0.0.1:%d" % CFG["port"])
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
if not parsed.path.startswith("/action/") or not self.same_origin():
self.send_html("<p>Nekad.</p>", 403)
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length).decode("utf-8", "replace") if length else ""
form = {k: v[0] for k, v in urllib.parse.parse_qs(raw, keep_blank_values=True).items()}
name = parsed.path[len("/action/"):]
target = "/#svar"
with LOCK:
st = load_state()
if name == "list_aspsps":
action_list_aspsps(st)
elif name == "choose_aspsp":
action_choose_aspsp(st, form)
elif name == "settings":
save_settings(st, form)
if form.get("op") == "auth":
url = action_auth(st)
if url:
# the bank page opens in its own tab; this tab shows the paste box
if CFG["open_browser"]:
webbrowser.open_new_tab(url)
target = "/#klistra"
else:
set_last("ok", "Inställningar sparade", section="anslut")
elif name == "paste":
complete_auth(st, extract_code_state(form.get("url", "")), "inklistrad adress")
elif name == "choose_account":
action_choose_account(st, form)
elif name == "check_session":
action_check_session(st)
elif name == "fetch":
st["settings"]["psu_ip"] = form.get("psu_ip", "").strip()
op = form.get("op")
if op in ("user", "background"):
action_fetch(st, op, self.browser_info())
elif op == "balances":
action_balances(st, self.browser_info())
elif name == "disconnect":
action_disconnect(st)
else:
self.send_html("<p>Okänd åtgärd.</p>", 404)
return
save_state(st)
self.redirect(target)
class V6Server(ThreadingHTTPServer):
address_family = socket.AF_INET6
def main(argv=None):
p = argparse.ArgumentParser(description="K0 Enable Banking test app")
p.add_argument("--port", type=int, default=DEFAULT_PORT)
p.add_argument("--local-dir", default=DEFAULT_LOCAL_DIR, help="config and state folder")
p.add_argument("--api-base", default=DEFAULT_API_BASE, help="for tests against a fake API")
p.add_argument("--no-browser", action="store_true")
args = p.parse_args(argv)
CFG["port"] = args.port
CFG["local_dir"] = os.path.abspath(args.local_dir)
CFG["api_base"] = args.api_base
CFG["open_browser"] = not args.no_browser
os.makedirs(CFG["local_dir"], exist_ok=True)
try:
os.chmod(CFG["local_dir"], 0o700)
except OSError:
pass
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
# browsers may resolve localhost to ::1 first; serve the loopback there too if possible
try:
server6 = V6Server(("::1", args.port), Handler)
threading.Thread(target=server6.serve_forever, daemon=True).start()
except OSError:
pass
url = "http://localhost:%d/" % args.port
log("K0 listening on %s (state in %s)" % (url, CFG["local_dir"]))
if not args.no_browser:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
log("stopped")
return 0
if __name__ == "__main__":
sys.exit(main())