From a77c709d4d69e1b1c7590bcc6399de90214a97d0 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Fri, 22 May 2026 19:18:38 +0200 Subject: [PATCH] Test test test --- src/routes/demo.py | 103 ++++++++++---- src/templates/base.html | 11 +- src/templates/step2.html | 157 +++++++++++++++++++++ src/tink/client.py | 295 +++++++++++++++++---------------------- 4 files changed, 377 insertions(+), 189 deletions(-) create mode 100644 src/templates/step2.html 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 %} +
+
{{ 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

+
+
+
+ + +

→ bliver til moneycapp-henrik-jess

+
+
+ + +
+ +
+
+ +{% 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
+}
+
+
+ + +
+ + ← Step 1 + +
+{% 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", + ], + }, + )