diff --git a/src/routes/demo.py b/src/routes/demo.py
index 29b0092..2667097 100644
--- a/src/routes/demo.py
+++ b/src/routes/demo.py
@@ -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 {external_user_id} — 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)
diff --git a/src/templates/base.html b/src/templates/base.html
index 7731b23..022228d 100644
--- a/src/templates/base.html
+++ b/src/templates/base.html
@@ -40,7 +40,16 @@
MoneyCapp × Tink
- ← Restart demo
+
+ {% if session_customer %}
+
+
+ Kunde:
+ {{ session_customer }}
+
+ {% endif %}
+
↺ Reset
+
diff --git a/src/templates/step2.html b/src/templates/step2.html
new file mode 100644
index 0000000..dd921ac
--- /dev/null
+++ b/src/templates/step2.html
@@ -0,0 +1,157 @@
+{% extends "base.html" %}
+{% block title %} — Step 2: Opret Bruger{% endblock %}
+
+{% block stepper %}
+
+
+
+ {% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %}
+ {% for i in range(1, 7) %}
+
+ {{ i }}
+ {{ step_names[i-1] }}
+
+ {% if i < 6 %}
+
›
+ {% endif %}
+ {% endfor %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
2
+
+
Opret Bruger
+
Create Test Customer
+
+
v1
+
+
+ Opret en Tink-bruger med dit eget external_user_id —
+ dette er dit interne kunde-ID som du kan søge på i Tink Console.
+ Tink returnerer et user_id som bruges i efterfølgende kald.
+
+
+
+{% if error %}
+
+{% endif %}
+
+{% if existing_user_id %}
+
+
+
+
✓
+
+
Bruger allerede oprettet i denne session
+
Du kan fortsætte til Step 3, eller oprette en ny bruger nedenfor.
+
+
+
+
+
external_user_id
+
{{ existing_external_id }}
+
+
+
Tink user_id
+
{{ existing_user_id }}
+
+
+
+
+
+ Opret en ny bruger i stedet
+
+{% else %}
+
+{% endif %}
+
+
+
+
+
Hvem opretter vi?
+
external_user_id bruges til at identificere kunden i Tink Console
+
+
+
+
+{% if existing_user_id %}
+
+
+{% else %}
+
+{% endif %}
+
+
+
+
API endpoint
+
POST https://api.tink.com/api/v1/user/create
+
+
# Request body
+{
+ "external_user_id": "moneycapp-<kundenavn>", ← dit interne ID
+ "market": "DK",
+ "locale": "da_DK"
+}
+
+# Response
+{
+ "user_id": "abc123..." ← Tinks interne ID, brug dette fremadrettet
+}
+
+
+
+
+
+{% endblock %}
diff --git a/src/tink/client.py b/src/tink/client.py
index 666dfa1..d77dcc7 100644
--- a/src/tink/client.py
+++ b/src/tink/client.py
@@ -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",
+ ],
+ },
+ )