Test test test
All checks were successful
Build and Deploy / deploy (push) Successful in 22s

This commit is contained in:
Henrik Jess Nielsen
2026-05-22 19:18:38 +02:00
parent 3f687bb212
commit a77c709d4d
4 changed files with 377 additions and 189 deletions

View File

@@ -20,7 +20,7 @@ router = APIRouter()
templates = Jinja2Templates(directory="src/templates")
def _client() -> TinkClient:
def _client(log_cb=None) -> TinkClient:
s = get_settings()
return TinkClient(
client_id=s.tink_client_id,
@@ -28,6 +28,7 @@ def _client() -> TinkClient:
redirect_uri=s.tink_redirect_uri,
api_base=s.tink_api_base,
link_base=s.tink_link_base,
on_request=log_cb,
)
@@ -35,6 +36,23 @@ def _session(request: Request) -> dict:
return request.session.setdefault("demo", {})
def _ctx(request: Request, extra: dict) -> dict:
"""Base template context — always includes session_customer."""
sess = _session(request)
return {"request": request, "session_customer": sess.get("external_user_id", ""), **extra}
def _logger(sess: dict):
"""Returns a callback that appends log entries to sess['api_log']."""
def cb(entry: dict):
log = sess.setdefault("api_log", [])
log.append(entry)
# keep last 50 entries
if len(log) > 50:
sess["api_log"] = log[-50:]
return cb
# ---------------------------------------------------------------------------
# Landing
# ---------------------------------------------------------------------------
@@ -57,7 +75,8 @@ async def reset_demo(request: Request):
@router.get("/demo/step/1", response_class=HTMLResponse)
async def step1(request: Request):
client = _client()
sess = _session(request)
client = _client(log_cb=_logger(sess))
s = get_settings()
error = None
result = None
@@ -70,12 +89,11 @@ async def step1(request: Request):
)
try:
result = await client.get_app_token(scope="user:create")
_session(request)["app_token"] = result["access_token"]
sess["app_token"] = result["access_token"]
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", {
"request": request,
return templates.TemplateResponse("step.html", _ctx(request, {
"step": 1,
"title": "Authenticate",
"subtitle": "Client Credentials Flow",
@@ -87,7 +105,7 @@ async def step1(request: Request):
"error": error,
"next_step": 2,
"prev_step": None,
})
}))
# ---------------------------------------------------------------------------
@@ -99,9 +117,46 @@ async def step2_get(request: Request):
sess = _session(request)
s = get_settings()
app_token = sess.get("app_token", "")
external_user_id = f"moneycapp-demo-{secrets.token_hex(4)}"
error = None
body = json.dumps({"external_user_id": external_user_id, "market": "DK", "locale": "da_DK"}, indent=2)
if not app_token:
error = "Mangler app token — gå tilbage til Step 1 og kør authentication først."
# If user already created this session, skip the form
existing_user_id = sess.get("user_id", "")
existing_external_id = sess.get("external_user_id", "")
return templates.TemplateResponse("step2.html", _ctx(request, {
"step": 2,
"error": error,
"existing_user_id": existing_user_id,
"existing_external_id": existing_external_id,
"app_token_ok": bool(app_token),
"next_step": 3,
"prev_step": 1,
}))
@router.post("/demo/step/2", response_class=HTMLResponse)
async def step2_post(request: Request,
customer_name: str = Form(default=""),
market: str = Form(default="DK")):
sess = _session(request)
s = get_settings()
app_token = sess.get("app_token", "")
error = None
result = None
# Build external_user_id from customer name
if customer_name.strip():
slug = customer_name.strip().lower().replace(" ", "-")
import re
slug = re.sub(r"[^a-z0-9\-]", "", slug)
external_user_id = f"moneycapp-{slug}"
else:
external_user_id = f"moneycapp-demo-{secrets.token_hex(4)}"
body = json.dumps({"external_user_id": external_user_id, "market": market, "locale": "da_DK"}, indent=2)
curl_example = (
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
@@ -109,33 +164,30 @@ async def step2_get(request: Request):
f" -d '{body}'"
)
error = None
result = None
if not app_token:
error = "Mangler app token — gå tilbage til Step 1 og kør authentication først."
error = "Mangler app token — gå tilbage til Step 1."
else:
try:
client = _client()
result = await client.create_user(app_token, external_user_id)
client = _client(log_cb=_logger(sess))
result = await client.create_user(app_token, external_user_id, market=market)
sess["user_id"] = result.get("user_id", "")
sess["external_user_id"] = external_user_id
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", {
"request": request,
return templates.TemplateResponse("step.html", _ctx(request, {
"step": 2,
"title": "Opret Bruger",
"subtitle": "Create Test Customer",
"subtitle": f"Kunde: {external_user_id}",
"endpoint": "POST /api/v1/user/create",
"api_version": "v1",
"description": "Vi opretter en ny Tink-bruger med et unikt external_user_id — det er dit interne kunde-ID. Tink returnerer et user_id som vi bruger i de næste kald.",
"description": f"Oprettet Tink-bruger med external_user_id <code class='text-violet-300 font-mono'>{external_user_id}</code> — dette er dit interne kunde-ID. Tink returnerer et user_id som bruges i næste kald.",
"curl_example": curl_example,
"result": result,
"error": error,
"next_step": 3,
"prev_step": 1,
})
}))
# ---------------------------------------------------------------------------
@@ -175,7 +227,11 @@ async def step3_get(request: Request):
try:
credentials = await client.list_credentials(user_token)
except Exception as e:
error = str(e)
# credentials:read may not be granted in simple Tink Link flow — not fatal
if "403" in str(e):
credentials = {"note": "credentials:read kræver authorization-grant flow"}
else:
error = str(e)
curl_example = (
"# Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig\n"
@@ -193,8 +249,7 @@ async def step3_get(request: Request):
" -d 'code=$CODE'"
)
return templates.TemplateResponse("step.html", {
"request": request,
return templates.TemplateResponse("step.html", _ctx(request, {
"step": 3,
"title": "Tilslut Bank",
"subtitle": "Tink Link — Bank Connection",
@@ -209,7 +264,7 @@ async def step3_get(request: Request):
"cb_success": cb_success,
"next_step": 4,
"prev_step": 2,
})
}))
@router.post("/demo/step/3", response_class=HTMLResponse)
@@ -217,7 +272,7 @@ async def step3_post(request: Request, code: str = Form(...)):
"""Manual code entry — exchange a code obtained via console.tink.com/callback."""
sess = _session(request)
try:
client = _client()
client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(
code=code.strip(),
redirect_uri=CONSOLE_CALLBACK,
@@ -238,7 +293,7 @@ async def tink_callback(request: Request, code: Optional[str] = None,
return RedirectResponse(f"/demo/step/3?error={error}")
if code:
try:
client = _client()
client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(code)
sess["user_token"] = tokens.get("access_token", "")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)

View File

@@ -40,7 +40,16 @@
<span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
</div>
</a>
<a href="/" class="text-sm text-slate-400 hover:text-white transition">← Restart demo</a>
<div class="flex items-center gap-3">
{% if session_customer %}
<div class="flex items-center gap-2 bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span class="text-xs text-slate-400">Kunde:</span>
<code class="text-xs text-emerald-300 font-mono font-semibold">{{ session_customer }}</code>
</div>
{% endif %}
<a href="/demo/reset" class="text-sm text-slate-400 hover:text-white transition">↺ Reset</a>
</div>
</div>
</nav>

157
src/templates/step2.html Normal file
View File

@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %} — Step 2: Opret Bruger{% endblock %}
{% block stepper %}
<div class="bg-slate-900/60 border-b border-slate-800">
<div class="max-w-6xl mx-auto px-4 py-3">
<div class="flex items-center gap-1 overflow-x-auto">
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %}
{% for i in range(1, 7) %}
<a href="/demo/step/{{ i }}"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
{% if i == 2 %}bg-violet-600 text-white font-semibold
{% elif i < 2 %}text-slate-300 hover:text-white
{% else %}text-slate-600 hover:text-slate-400{% endif %}">
<span class="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center
{% if i < 2 %}bg-slate-700 text-slate-300
{% elif i == 2 %}bg-violet-500 text-white
{% else %}bg-slate-800 text-slate-600{% endif %}">{{ i }}</span>
{{ step_names[i-1] }}
</a>
{% if i < 6 %}
<span class="text-slate-700"></span>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block content %}
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">2</span>
<div>
<h2 class="text-xl font-bold text-white">Opret Bruger</h2>
<p class="text-slate-400 text-sm">Create Test Customer</p>
</div>
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-slate-800 text-slate-400 border border-slate-700">v1</span>
</div>
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
Opret en Tink-bruger med dit eget <code class="text-violet-300">external_user_id</code>
dette er dit interne kunde-ID som du kan søge på i Tink Console.
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald.
</p>
</div>
{% if error %}
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-4 mb-6">
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
</div>
{% endif %}
{% if existing_user_id %}
<!-- Already created this session -->
<div class="bg-emerald-950/30 border border-emerald-800/40 rounded-xl p-5 mb-6">
<div class="flex items-center gap-3 mb-3">
<span class="w-8 h-8 rounded-full bg-emerald-600/20 border border-emerald-600/40 flex items-center justify-center text-emerald-400 text-lg"></span>
<div>
<p class="text-white font-semibold">Bruger allerede oprettet i denne session</p>
<p class="text-slate-400 text-sm">Du kan fortsætte til Step 3, eller oprette en ny bruger nedenfor.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
<div class="bg-slate-900 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">external_user_id</p>
<code class="text-emerald-300 font-mono text-sm font-semibold">{{ existing_external_id }}</code>
</div>
<div class="bg-slate-900 rounded-lg p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">Tink user_id</p>
<code class="text-slate-300 font-mono text-xs">{{ existing_user_id }}</code>
</div>
</div>
<div class="mt-4">
<a href="/demo/step/3"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
Fortsæt til Step 3 — Tilslut Bank →
</a>
</div>
</div>
<details class="mb-6">
<summary class="cursor-pointer text-sm text-slate-500 hover:text-slate-300 transition">Opret en ny bruger i stedet</summary>
<div class="mt-4">
{% else %}
<div>
{% endif %}
<!-- Create user form -->
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden max-w-xl">
<div class="px-5 py-4 border-b border-slate-800">
<p class="text-sm font-semibold text-white">Hvem opretter vi?</p>
<p class="text-xs text-slate-400 mt-0.5">external_user_id bruges til at identificere kunden i Tink Console</p>
</div>
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Kundenavn</label>
<input type="text" name="customer_name"
placeholder="fx. Henrik Jess, Kunde A, Test User 1"
value="Henrik Jess"
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm font-mono
placeholder-slate-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/30 transition">
<p class="text-xs text-slate-600 mt-1.5">→ bliver til <code class="text-violet-300/70">moneycapp-henrik-jess</code></p>
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label>
<select name="market"
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm
focus:outline-none focus:border-violet-500 transition">
<option value="DK" selected>DK — Danmark</option>
<option value="SE">SE — Sverige</option>
<option value="NO">NO — Norge</option>
<option value="GB">GB — United Kingdom</option>
<option value="DE">DE — Deutschland</option>
</select>
</div>
<button type="submit"
{% if not app_token_ok %}disabled{% endif %}
class="w-full py-3 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed
text-white rounded-xl text-sm font-semibold transition">
{% if app_token_ok %}Opret bruger i Tink →{% else %}Kør Step 1 først{% endif %}
</button>
</form>
</div>
{% if existing_user_id %}
</div>
</details>
{% else %}
</div>
{% endif %}
<!-- API info -->
<div class="mt-6 bg-slate-900/50 border border-slate-800 rounded-xl p-4">
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">API endpoint</p>
<code class="text-emerald-400 font-mono text-sm">POST https://api.tink.com/api/v1/user/create</code>
<div class="mt-3 bg-slate-950 rounded-lg p-3 overflow-x-auto">
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># Request body
{
"external_user_id": "moneycapp-&lt;kundenavn&gt;", ← dit interne ID
"market": "DK",
"locale": "da_DK"
}
# Response
{
"user_id": "abc123..." ← Tinks interne ID, brug dette fremadrettet
}</pre>
</div>
</div>
<!-- Navigation -->
<div class="mt-6 flex justify-start">
<a href="/demo/step/1"
class="px-4 py-2.5 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 rounded-xl text-sm transition">
← Step 1
</a>
</div>
{% endblock %}

View File

@@ -4,8 +4,9 @@ accounts (v2), transactions (v2), events (v2), and bank connectivity.
"""
import json
import time
import httpx
from typing import Optional
from typing import Optional, Callable
from dataclasses import dataclass, field
@@ -17,15 +18,57 @@ class TinkTokens:
external_user_id: str = ""
def _log_entry(method: str, url: str, req_body: dict | None,
status: int, resp_body: dict, duration_ms: int) -> dict:
return {
"method": method,
"url": url,
"req_body": req_body,
"status": status,
"resp_body": resp_body,
"duration_ms": duration_ms,
"ts": time.strftime("%H:%M:%S"),
"ok": status < 400,
}
class TinkClient:
def __init__(self, client_id: str, client_secret: str, redirect_uri: str,
api_base: str = "https://api.tink.com",
link_base: str = "https://link.tink.com"):
link_base: str = "https://link.tink.com",
on_request: Callable[[dict], None] | None = None):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.api_base = api_base.rstrip("/")
self.link_base = link_base.rstrip("/")
self.on_request = on_request # callback(log_entry) called after each API call
def _log(self, method: str, url: str, req_body, status: int,
resp_body: dict, duration_ms: int):
if self.on_request:
self.on_request(_log_entry(method, url, req_body, status, resp_body, duration_ms))
async def _get(self, url: str, headers: dict, params: dict | None = None) -> dict:
t0 = time.monotonic()
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers=headers, params=params)
ms = int((time.monotonic() - t0) * 1000)
body = resp.json() if resp.content else {}
self._log("GET", url, params, resp.status_code, body, ms)
resp.raise_for_status()
return body
async def _post(self, url: str, headers: dict,
data: dict | None = None, json_body: dict | None = None) -> dict:
t0 = time.monotonic()
async with httpx.AsyncClient() as client:
resp = await client.post(url, headers=headers, data=data, json=json_body)
ms = int((time.monotonic() - t0) * 1000)
body = resp.json() if resp.content else {}
self._log("POST", url, json_body or data, resp.status_code, body, ms)
resp.raise_for_status()
return body
# -------------------------------------------------------------------------
# Authentication
@@ -33,27 +76,19 @@ class TinkClient:
async def get_app_token(self, scope: str = "user:create") -> dict:
"""Client credentials flow — returns app-level token."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/oauth/token",
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials",
"scope": scope,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
return resp.json()
return await self._post(
f"{self.api_base}/api/v1/oauth/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials",
"scope": scope,
},
)
async def exchange_code_for_token(self, code: str,
redirect_uri: str | None = None) -> dict:
"""Authorization code → user-level access token.
Pass redirect_uri only when it differs from self.redirect_uri
(e.g. dev flow via console.tink.com/callback).
"""
data: dict = {
"client_id": self.client_id,
"client_secret": self.client_secret,
@@ -62,78 +97,48 @@ class TinkClient:
}
if redirect_uri:
data["redirect_uri"] = redirect_uri
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/oauth/token",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
return resp.json()
return await self._post(
f"{self.api_base}/api/v1/oauth/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=data,
)
# -------------------------------------------------------------------------
# Users (v1 — only version available)
# Users (v1)
# -------------------------------------------------------------------------
async def create_user(self, app_token: str, external_user_id: str,
market: str = "DK", locale: str = "da_DK") -> dict:
"""Create a Tink user. Returns user_id."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/user/create",
json={
"external_user_id": external_user_id,
"market": market,
"locale": locale,
},
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._post(
f"{self.api_base}/api/v1/user/create",
headers={"Authorization": f"Bearer {app_token}"},
json_body={"external_user_id": external_user_id, "market": market, "locale": locale},
)
async def get_user(self, user_token: str) -> dict:
"""Get current user info."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/api/v1/user",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/api/v1/user",
headers={"Authorization": f"Bearer {user_token}"},
)
async def get_authorization_grant_token(self, app_token: str, user_id: str,
scope: str) -> dict:
"""Get a delegate authorization grant for a specific user (form-encoded)."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
data={
"actor_client_id": self.client_id,
"user_id": user_id,
"scope": scope,
},
headers={
"Authorization": f"Bearer {app_token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
resp.raise_for_status()
return resp.json()
return await self._post(
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
headers={
"Authorization": f"Bearer {app_token}",
"Content-Type": "application/x-www-form-urlencoded",
},
data={"actor_client_id": self.client_id, "user_id": user_id, "scope": scope},
)
# -------------------------------------------------------------------------
# Tink Link — bank connection URL
# Tink Link — bank connection URL (no HTTP call, just URL builder)
# -------------------------------------------------------------------------
def get_tink_link_url(self, market: str = "DK",
authorization_code: str | None = None,
redirect_uri_override: str | None = None) -> str:
"""Build the Tink Link URL for bank connection.
Uses simplified flow (no authorization_code) by default,
matching the Tink Console demo URL pattern.
Use redirect_uri_override to swap in a different callback URL
(e.g. https://console.tink.com/callback for local dev).
"""
from urllib.parse import urlencode
params: dict = {
"client_id": self.client_id,
@@ -147,17 +152,14 @@ class TinkClient:
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
# -------------------------------------------------------------------------
# Credentials (bank connections) — v1
# Credentials (v1)
# -------------------------------------------------------------------------
async def list_credentials(self, user_token: str) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/api/v1/credentials/list",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/api/v1/credentials/list",
headers={"Authorization": f"Bearer {user_token}"},
)
# -------------------------------------------------------------------------
# Accounts — v2
@@ -165,28 +167,20 @@ class TinkClient:
async def list_accounts(self, user_token: str, page_size: int = 50,
page_token: Optional[str] = None) -> dict:
"""GET /data/v2/accounts — new v2 endpoint."""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/accounts",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/data/v2/accounts",
headers={"Authorization": f"Bearer {user_token}"},
params=params,
)
async def get_account(self, user_token: str, account_id: str) -> dict:
"""GET /data/v2/accounts/{id}"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/accounts/{account_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/data/v2/accounts/{account_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# -------------------------------------------------------------------------
# Transactions — v2
@@ -195,101 +189,74 @@ class TinkClient:
async def list_transactions(self, user_token: str, page_size: int = 25,
page_token: Optional[str] = None,
account_id: Optional[str] = None) -> dict:
"""GET /data/v2/transactions — new v2 endpoint."""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
if account_id:
params["accountIdIn"] = account_id
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/transactions",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/data/v2/transactions",
headers={"Authorization": f"Bearer {user_token}"},
params=params,
)
async def get_transaction(self, user_token: str, transaction_id: str) -> dict:
"""GET /data/v2/transactions/{id}"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/transactions/{transaction_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/data/v2/transactions/{transaction_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# -------------------------------------------------------------------------
# Events — v2
# -------------------------------------------------------------------------
async def list_account_transaction_events(
self, user_token: str,
page_size: int = 25,
self, user_token: str, page_size: int = 25,
page_token: Optional[str] = None,
) -> dict:
"""GET /events/v2/account-transactions"""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/events/v2/account-transactions",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/events/v2/account-transactions",
headers={"Authorization": f"Bearer {user_token}"},
params=params,
)
async def list_booked_transaction_events(
self, user_token: str,
page_size: int = 25,
self, user_token: str, page_size: int = 25,
page_token: Optional[str] = None,
) -> dict:
"""GET /events/v2/account-booked-transactions"""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/events/v2/account-booked-transactions",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/events/v2/account-booked-transactions",
headers={"Authorization": f"Bearer {user_token}"},
params=params,
)
# -------------------------------------------------------------------------
# Webhooks — app-level (uses client credentials token)
# Webhooks — app-level
# -------------------------------------------------------------------------
async def list_webhooks(self, app_token: str) -> dict:
"""GET /api/v1/webhooks"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/api/v1/webhooks",
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._get(
f"{self.api_base}/api/v1/webhooks",
headers={"Authorization": f"Bearer {app_token}"},
)
async def register_webhook(self, app_token: str, url: str,
enabled_events: list[str] | None = None) -> dict:
"""POST /api/v1/webhooks"""
payload = {
"url": url,
"enabledEvents": enabled_events or [
"account-booked-transaction:created",
"account-pending-transaction:created",
"account-pending-transaction:updated",
],
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/webhooks",
json=payload,
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()
return await self._post(
f"{self.api_base}/api/v1/webhooks",
headers={"Authorization": f"Bearer {app_token}"},
json_body={
"url": url,
"enabledEvents": enabled_events or [
"account-booked-transaction:created",
"account-pending-transaction:created",
"account-pending-transaction:updated",
],
},
)