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") templates = Jinja2Templates(directory="src/templates")
def _client() -> TinkClient: def _client(log_cb=None) -> TinkClient:
s = get_settings() s = get_settings()
return TinkClient( return TinkClient(
client_id=s.tink_client_id, client_id=s.tink_client_id,
@@ -28,6 +28,7 @@ def _client() -> TinkClient:
redirect_uri=s.tink_redirect_uri, redirect_uri=s.tink_redirect_uri,
api_base=s.tink_api_base, api_base=s.tink_api_base,
link_base=s.tink_link_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", {}) 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 # Landing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -57,7 +75,8 @@ async def reset_demo(request: Request):
@router.get("/demo/step/1", response_class=HTMLResponse) @router.get("/demo/step/1", response_class=HTMLResponse)
async def step1(request: Request): async def step1(request: Request):
client = _client() sess = _session(request)
client = _client(log_cb=_logger(sess))
s = get_settings() s = get_settings()
error = None error = None
result = None result = None
@@ -70,12 +89,11 @@ async def step1(request: Request):
) )
try: try:
result = await client.get_app_token(scope="user:create") 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: except Exception as e:
error = str(e) error = str(e)
return templates.TemplateResponse("step.html", { return templates.TemplateResponse("step.html", _ctx(request, {
"request": request,
"step": 1, "step": 1,
"title": "Authenticate", "title": "Authenticate",
"subtitle": "Client Credentials Flow", "subtitle": "Client Credentials Flow",
@@ -87,7 +105,7 @@ async def step1(request: Request):
"error": error, "error": error,
"next_step": 2, "next_step": 2,
"prev_step": None, "prev_step": None,
}) }))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -99,9 +117,46 @@ async def step2_get(request: Request):
sess = _session(request) sess = _session(request)
s = get_settings() s = get_settings()
app_token = sess.get("app_token", "") 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 = ( curl_example = (
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n" f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n" f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
@@ -109,33 +164,30 @@ async def step2_get(request: Request):
f" -d '{body}'" f" -d '{body}'"
) )
error = None
result = None
if not app_token: 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: else:
try: try:
client = _client() client = _client(log_cb=_logger(sess))
result = await client.create_user(app_token, external_user_id) result = await client.create_user(app_token, external_user_id, market=market)
sess["user_id"] = result.get("user_id", "") sess["user_id"] = result.get("user_id", "")
sess["external_user_id"] = external_user_id sess["external_user_id"] = external_user_id
except Exception as e: except Exception as e:
error = str(e) error = str(e)
return templates.TemplateResponse("step.html", { return templates.TemplateResponse("step.html", _ctx(request, {
"request": request,
"step": 2, "step": 2,
"title": "Opret Bruger", "title": "Opret Bruger",
"subtitle": "Create Test Customer", "subtitle": f"Kunde: {external_user_id}",
"endpoint": "POST /api/v1/user/create", "endpoint": "POST /api/v1/user/create",
"api_version": "v1", "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, "curl_example": curl_example,
"result": result, "result": result,
"error": error, "error": error,
"next_step": 3, "next_step": 3,
"prev_step": 1, "prev_step": 1,
}) }))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -175,7 +227,11 @@ async def step3_get(request: Request):
try: try:
credentials = await client.list_credentials(user_token) credentials = await client.list_credentials(user_token)
except Exception as e: 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 = ( curl_example = (
"# Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig\n" "# Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig\n"
@@ -193,8 +249,7 @@ async def step3_get(request: Request):
" -d 'code=$CODE'" " -d 'code=$CODE'"
) )
return templates.TemplateResponse("step.html", { return templates.TemplateResponse("step.html", _ctx(request, {
"request": request,
"step": 3, "step": 3,
"title": "Tilslut Bank", "title": "Tilslut Bank",
"subtitle": "Tink Link — Bank Connection", "subtitle": "Tink Link — Bank Connection",
@@ -209,7 +264,7 @@ async def step3_get(request: Request):
"cb_success": cb_success, "cb_success": cb_success,
"next_step": 4, "next_step": 4,
"prev_step": 2, "prev_step": 2,
}) }))
@router.post("/demo/step/3", response_class=HTMLResponse) @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.""" """Manual code entry — exchange a code obtained via console.tink.com/callback."""
sess = _session(request) sess = _session(request)
try: try:
client = _client() client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token( tokens = await client.exchange_code_for_token(
code=code.strip(), code=code.strip(),
redirect_uri=CONSOLE_CALLBACK, 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}") return RedirectResponse(f"/demo/step/3?error={error}")
if code: if code:
try: try:
client = _client() client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(code) tokens = await client.exchange_code_for_token(code)
sess["user_token"] = tokens.get("access_token", "") sess["user_token"] = tokens.get("access_token", "")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303) 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> <span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
</div> </div>
</a> </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> </div>
</nav> </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 json
import time
import httpx import httpx
from typing import Optional from typing import Optional, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -17,15 +18,57 @@ class TinkTokens:
external_user_id: str = "" 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: class TinkClient:
def __init__(self, client_id: str, client_secret: str, redirect_uri: str, def __init__(self, client_id: str, client_secret: str, redirect_uri: str,
api_base: str = "https://api.tink.com", 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_id = client_id
self.client_secret = client_secret self.client_secret = client_secret
self.redirect_uri = redirect_uri self.redirect_uri = redirect_uri
self.api_base = api_base.rstrip("/") self.api_base = api_base.rstrip("/")
self.link_base = link_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 # Authentication
@@ -33,27 +76,19 @@ class TinkClient:
async def get_app_token(self, scope: str = "user:create") -> dict: async def get_app_token(self, scope: str = "user:create") -> dict:
"""Client credentials flow — returns app-level token.""" """Client credentials flow — returns app-level token."""
async with httpx.AsyncClient() as client: return await self._post(
resp = await client.post( f"{self.api_base}/api/v1/oauth/token",
f"{self.api_base}/api/v1/oauth/token", headers={"Content-Type": "application/x-www-form-urlencoded"},
data={ data={
"client_id": self.client_id, "client_id": self.client_id,
"client_secret": self.client_secret, "client_secret": self.client_secret,
"grant_type": "client_credentials", "grant_type": "client_credentials",
"scope": scope, "scope": scope,
}, },
headers={"Content-Type": "application/x-www-form-urlencoded"}, )
)
resp.raise_for_status()
return resp.json()
async def exchange_code_for_token(self, code: str, async def exchange_code_for_token(self, code: str,
redirect_uri: str | None = None) -> dict: 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 = { data: dict = {
"client_id": self.client_id, "client_id": self.client_id,
"client_secret": self.client_secret, "client_secret": self.client_secret,
@@ -62,78 +97,48 @@ class TinkClient:
} }
if redirect_uri: if redirect_uri:
data["redirect_uri"] = redirect_uri data["redirect_uri"] = redirect_uri
async with httpx.AsyncClient() as client: return await self._post(
resp = await client.post( f"{self.api_base}/api/v1/oauth/token",
f"{self.api_base}/api/v1/oauth/token", headers={"Content-Type": "application/x-www-form-urlencoded"},
data=data, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"}, )
)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Users (v1 — only version available) # Users (v1)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def create_user(self, app_token: str, external_user_id: str, async def create_user(self, app_token: str, external_user_id: str,
market: str = "DK", locale: str = "da_DK") -> dict: market: str = "DK", locale: str = "da_DK") -> dict:
"""Create a Tink user. Returns user_id.""" return await self._post(
async with httpx.AsyncClient() as client: f"{self.api_base}/api/v1/user/create",
resp = await client.post( headers={"Authorization": f"Bearer {app_token}"},
f"{self.api_base}/api/v1/user/create", json_body={"external_user_id": external_user_id, "market": market, "locale": locale},
json={ )
"external_user_id": external_user_id,
"market": market,
"locale": locale,
},
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()
async def get_user(self, user_token: str) -> dict: async def get_user(self, user_token: str) -> dict:
"""Get current user info.""" return await self._get(
async with httpx.AsyncClient() as client: f"{self.api_base}/api/v1/user",
resp = await client.get( headers={"Authorization": f"Bearer {user_token}"},
f"{self.api_base}/api/v1/user", )
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
async def get_authorization_grant_token(self, app_token: str, user_id: str, async def get_authorization_grant_token(self, app_token: str, user_id: str,
scope: str) -> dict: scope: str) -> dict:
"""Get a delegate authorization grant for a specific user (form-encoded).""" return await self._post(
async with httpx.AsyncClient() as client: f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
resp = await client.post( headers={
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate", "Authorization": f"Bearer {app_token}",
data={ "Content-Type": "application/x-www-form-urlencoded",
"actor_client_id": self.client_id, },
"user_id": user_id, data={"actor_client_id": self.client_id, "user_id": user_id, "scope": scope},
"scope": scope, )
},
headers={
"Authorization": f"Bearer {app_token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# 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", def get_tink_link_url(self, market: str = "DK",
authorization_code: str | None = None, authorization_code: str | None = None,
redirect_uri_override: str | None = None) -> str: 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 from urllib.parse import urlencode
params: dict = { params: dict = {
"client_id": self.client_id, "client_id": self.client_id,
@@ -147,17 +152,14 @@ class TinkClient:
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}" 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 def list_credentials(self, user_token: str) -> dict:
async with httpx.AsyncClient() as client: return await self._get(
resp = await client.get( f"{self.api_base}/api/v1/credentials/list",
f"{self.api_base}/api/v1/credentials/list", headers={"Authorization": f"Bearer {user_token}"},
headers={"Authorization": f"Bearer {user_token}"}, )
)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Accounts — v2 # Accounts — v2
@@ -165,28 +167,20 @@ class TinkClient:
async def list_accounts(self, user_token: str, page_size: int = 50, async def list_accounts(self, user_token: str, page_size: int = 50,
page_token: Optional[str] = None) -> dict: page_token: Optional[str] = None) -> dict:
"""GET /data/v2/accounts — new v2 endpoint."""
params: dict = {"pageSize": page_size} params: dict = {"pageSize": page_size}
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
async with httpx.AsyncClient() as client: return await self._get(
resp = await client.get( f"{self.api_base}/data/v2/accounts",
f"{self.api_base}/data/v2/accounts", headers={"Authorization": f"Bearer {user_token}"},
params=params, params=params,
headers={"Authorization": f"Bearer {user_token}"}, )
)
resp.raise_for_status()
return resp.json()
async def get_account(self, user_token: str, account_id: str) -> dict: async def get_account(self, user_token: str, account_id: str) -> dict:
"""GET /data/v2/accounts/{id}""" return await self._get(
async with httpx.AsyncClient() as client: f"{self.api_base}/data/v2/accounts/{account_id}",
resp = await client.get( headers={"Authorization": f"Bearer {user_token}"},
f"{self.api_base}/data/v2/accounts/{account_id}", )
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Transactions — v2 # Transactions — v2
@@ -195,101 +189,74 @@ class TinkClient:
async def list_transactions(self, user_token: str, page_size: int = 25, async def list_transactions(self, user_token: str, page_size: int = 25,
page_token: Optional[str] = None, page_token: Optional[str] = None,
account_id: Optional[str] = None) -> dict: account_id: Optional[str] = None) -> dict:
"""GET /data/v2/transactions — new v2 endpoint."""
params: dict = {"pageSize": page_size} params: dict = {"pageSize": page_size}
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
if account_id: if account_id:
params["accountIdIn"] = account_id params["accountIdIn"] = account_id
async with httpx.AsyncClient() as client: return await self._get(
resp = await client.get( f"{self.api_base}/data/v2/transactions",
f"{self.api_base}/data/v2/transactions", headers={"Authorization": f"Bearer {user_token}"},
params=params, params=params,
headers={"Authorization": f"Bearer {user_token}"}, )
)
resp.raise_for_status()
return resp.json()
async def get_transaction(self, user_token: str, transaction_id: str) -> dict: async def get_transaction(self, user_token: str, transaction_id: str) -> dict:
"""GET /data/v2/transactions/{id}""" return await self._get(
async with httpx.AsyncClient() as client: f"{self.api_base}/data/v2/transactions/{transaction_id}",
resp = await client.get( headers={"Authorization": f"Bearer {user_token}"},
f"{self.api_base}/data/v2/transactions/{transaction_id}", )
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Events — v2 # Events — v2
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def list_account_transaction_events( async def list_account_transaction_events(
self, user_token: str, self, user_token: str, page_size: int = 25,
page_size: int = 25,
page_token: Optional[str] = None, page_token: Optional[str] = None,
) -> dict: ) -> dict:
"""GET /events/v2/account-transactions"""
params: dict = {"pageSize": page_size} params: dict = {"pageSize": page_size}
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
async with httpx.AsyncClient() as client: return await self._get(
resp = await client.get( f"{self.api_base}/events/v2/account-transactions",
f"{self.api_base}/events/v2/account-transactions", headers={"Authorization": f"Bearer {user_token}"},
params=params, params=params,
headers={"Authorization": f"Bearer {user_token}"}, )
)
resp.raise_for_status()
return resp.json()
async def list_booked_transaction_events( async def list_booked_transaction_events(
self, user_token: str, self, user_token: str, page_size: int = 25,
page_size: int = 25,
page_token: Optional[str] = None, page_token: Optional[str] = None,
) -> dict: ) -> dict:
"""GET /events/v2/account-booked-transactions"""
params: dict = {"pageSize": page_size} params: dict = {"pageSize": page_size}
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
async with httpx.AsyncClient() as client: return await self._get(
resp = await client.get( f"{self.api_base}/events/v2/account-booked-transactions",
f"{self.api_base}/events/v2/account-booked-transactions", headers={"Authorization": f"Bearer {user_token}"},
params=params, params=params,
headers={"Authorization": f"Bearer {user_token}"}, )
)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Webhooks — app-level (uses client credentials token) # Webhooks — app-level
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def list_webhooks(self, app_token: str) -> dict: async def list_webhooks(self, app_token: str) -> dict:
"""GET /api/v1/webhooks""" return await self._get(
async with httpx.AsyncClient() as client: f"{self.api_base}/api/v1/webhooks",
resp = await client.get( headers={"Authorization": f"Bearer {app_token}"},
f"{self.api_base}/api/v1/webhooks", )
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()
async def register_webhook(self, app_token: str, url: str, async def register_webhook(self, app_token: str, url: str,
enabled_events: list[str] | None = None) -> dict: enabled_events: list[str] | None = None) -> dict:
"""POST /api/v1/webhooks""" return await self._post(
payload = { f"{self.api_base}/api/v1/webhooks",
"url": url, headers={"Authorization": f"Bearer {app_token}"},
"enabledEvents": enabled_events or [ json_body={
"account-booked-transaction:created", "url": url,
"account-pending-transaction:created", "enabledEvents": enabled_events or [
"account-pending-transaction:updated", "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()