Files
tink-demo/src/routes/demo.py

555 lines
20 KiB
Python
Raw Normal View History

2026-05-22 18:30:59 +02:00
"""
Demo routes each /demo/step/N page shows one Tink API call
with the live JSON response and a curl example.
"""
import json
import uuid
import secrets
from typing import Optional
from fastapi import APIRouter, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from src.tink.client import TinkClient
from src.config import get_settings
from src import demo_data
router = APIRouter()
templates = Jinja2Templates(directory="src/templates")
2026-05-22 19:18:38 +02:00
def _client(log_cb=None) -> TinkClient:
2026-05-22 18:30:59 +02:00
s = get_settings()
return TinkClient(
client_id=s.tink_client_id,
client_secret=s.tink_client_secret,
redirect_uri=s.tink_redirect_uri,
api_base=s.tink_api_base,
link_base=s.tink_link_base,
2026-05-22 19:18:38 +02:00
on_request=log_cb,
2026-05-22 18:30:59 +02:00
)
def _session(request: Request) -> dict:
return request.session.setdefault("demo", {})
2026-05-22 19:18:38 +02:00
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
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Landing
# ---------------------------------------------------------------------------
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@router.get("/demo/reset")
async def reset_demo(request: Request):
"""Clear all demo session state and restart from Step 1."""
request.session.pop("demo", None)
return RedirectResponse("/demo/step/1")
# ---------------------------------------------------------------------------
# Step 1 — Authenticate (client credentials)
# ---------------------------------------------------------------------------
@router.get("/demo/step/1", response_class=HTMLResponse)
async def step1(request: Request):
2026-05-22 19:18:38 +02:00
sess = _session(request)
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
s = get_settings()
error = None
result = None
curl_example = (
f"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
f" -d 'client_id={s.tink_client_id}' \\\n"
f" -d 'client_secret=***' \\\n"
f" -d 'grant_type=client_credentials' \\\n"
f" -d 'scope=user:create'"
)
try:
result = await client.get_app_token(scope="user:create")
2026-05-22 19:18:38 +02:00
sess["app_token"] = result["access_token"]
2026-05-22 18:30:59 +02:00
except Exception as e:
error = str(e)
2026-05-22 19:18:38 +02:00
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 1,
"title": "Authenticate",
"subtitle": "Client Credentials Flow",
"endpoint": "POST /api/v1/oauth/token",
"api_version": "v1",
"description": "Vi starter med at hente et app-level access token via Client Credentials flow. Dette token bruges til at oprette brugere.",
"curl_example": curl_example,
"result": result,
"error": error,
"next_step": 2,
"prev_step": None,
2026-05-22 19:18:38 +02:00
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 2 — Create User
# ---------------------------------------------------------------------------
@router.get("/demo/step/2", response_class=HTMLResponse)
async def step2_get(request: Request):
sess = _session(request)
s = get_settings()
app_token = sess.get("app_token", "")
2026-05-22 19:18:38 +02:00
error = None
2026-05-22 18:30:59 +02:00
2026-05-22 19:18:38 +02:00
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)
2026-05-22 18:30:59 +02:00
curl_example = (
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
f" -H 'Content-Type: application/json' \\\n"
f" -d '{body}'"
)
if not app_token:
2026-05-22 19:18:38 +02:00
error = "Mangler app token — gå tilbage til Step 1."
2026-05-22 18:30:59 +02:00
else:
try:
2026-05-22 19:18:38 +02:00
client = _client(log_cb=_logger(sess))
result = await client.create_user(app_token, external_user_id, market=market)
2026-05-22 18:30:59 +02:00
sess["user_id"] = result.get("user_id", "")
sess["external_user_id"] = external_user_id
sess["user_market"] = market
2026-05-22 18:30:59 +02:00
except Exception as e:
error = str(e)
2026-05-22 19:18:38 +02:00
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 2,
"title": "Opret Bruger",
2026-05-22 19:18:38 +02:00
"subtitle": f"Kunde: {external_user_id}",
2026-05-22 18:30:59 +02:00
"endpoint": "POST /api/v1/user/create",
"api_version": "v1",
2026-05-22 19:18:38 +02:00
"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.",
2026-05-22 18:30:59 +02:00
"curl_example": curl_example,
"result": result,
"error": error,
"next_step": 3,
"prev_step": 1,
2026-05-22 19:18:38 +02:00
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 3 — Connect Bank (Tink Link redirect)
# ---------------------------------------------------------------------------
CONSOLE_CALLBACK = "https://console.tink.com/callback"
@router.get("/demo/step/3", response_class=HTMLResponse)
async def step3_get(request: Request):
sess = _session(request)
s = get_settings()
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
error = None
credentials = None
auth_code_error = None
2026-05-22 18:30:59 +02:00
cb_success = request.query_params.get("cb_success")
cb_error = request.query_params.get("cb_error")
# If we have a user from Step 2, generate an authorization_code for them
# so Tink Link connects the bank to THAT specific user (not a new anonymous one)
user_id = sess.get("user_id", "")
app_token = sess.get("app_token", "")
authorization_code = None
if user_id and app_token and not sess.get("user_token"):
try:
grant = await client.get_authorization_grant_token(
app_token=app_token,
user_id=user_id,
scope="accounts:read,transactions:read,credentials:read",
)
authorization_code = grant.get("code", "")
except Exception as e:
auth_code_error = str(e)
tink_link_url = client.get_tink_link_url(
market=sess.get("user_market", "DK"),
authorization_code=authorization_code or None,
)
dev_tink_link_url = client.get_tink_link_url(
market=sess.get("user_market", "DK"),
authorization_code=authorization_code or None,
redirect_uri_override=CONSOLE_CALLBACK,
)
2026-05-22 18:30:59 +02:00
# Demo mode: auto-mark as connected with mock data
if s.demo_mode and not sess.get("user_token"):
sess["user_token"] = "demo-mode-token"
sess["demo_mode"] = True
# Check if already connected (returning from callback)
user_token = sess.get("user_token", "")
if user_token:
if sess.get("demo_mode"):
credentials = demo_data.MOCK_CREDENTIALS
cb_success = cb_success or "demo"
else:
try:
credentials = await client.list_credentials(user_token)
except Exception as e:
2026-05-22 19:18:38 +02:00
if "403" in str(e):
credentials = {"note": "credentials:read kræver authorization-grant flow"}
else:
error = str(e)
2026-05-22 18:30:59 +02:00
external_user_id = sess.get("external_user_id", "")
uid_display = user_id or "$USER_ID"
2026-05-22 18:30:59 +02:00
curl_example = (
"# Step 1: Hent authorization_code for den specifikke bruger\n"
"curl -X POST https://api.tink.com/api/v1/oauth/authorization-grant/delegate \\\n"
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
" -d 'actor_client_id=$CLIENT_ID' \\\n"
f" -d 'user_id={uid_display}' \\\n"
" -d 'scope=accounts:read,transactions:read'\n\n"
"# Step 2: Send authorization_code med i Tink Link URL\n"
"https://link.tink.com/1.0/transactions/connect-accounts\n"
" ?client_id=$CLIENT_ID\n"
" &redirect_uri=$REDIRECT_URI\n"
" &authorization_code=$CODE ← binder til din bruger!\n\n"
"# Step 3: Exchange callback code for user token\n"
2026-05-22 18:30:59 +02:00
"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
" -d 'grant_type=authorization_code' \\\n"
" -d 'client_id=$CLIENT_ID' \\\n"
" -d 'client_secret=$CLIENT_SECRET' \\\n"
" -d 'code=$CALLBACK_CODE'"
2026-05-22 18:30:59 +02:00
)
linked_user_note = None
if authorization_code:
linked_user_note = f"Tink Link er bundet til bruger <code class='text-violet-300 font-mono'>{external_user_id}</code> via authorization_code"
elif user_id:
linked_user_note = f"⚠️ Kunne ikke hente authorization_code: {auth_code_error or 'mangler app token'} — Tink Link opretter en ny anonym bruger"
2026-05-22 19:18:38 +02:00
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 3,
"title": "Tilslut Bank",
"subtitle": "Tink Link — Bank Connection",
"endpoint": "GET /api/v1/oauth/authorization-grant/delegate",
"api_version": "v1",
"description": linked_user_note or "Åbn Tink Link, vælg Tink Demo Bank og log ind med test-credentials.",
2026-05-22 18:30:59 +02:00
"curl_example": curl_example,
"result": credentials,
"tink_link_url": tink_link_url,
"dev_tink_link_url": dev_tink_link_url,
"error": error or (f"Callback fejl: {cb_error}" if cb_error else None),
"cb_success": cb_success,
"next_step": 4,
"prev_step": 2,
2026-05-22 19:18:38 +02:00
}))
2026-05-22 18:30:59 +02:00
@router.post("/demo/step/3", response_class=HTMLResponse)
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:
2026-05-22 19:18:38 +02:00
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
tokens = await client.exchange_code_for_token(
code=code.strip(),
redirect_uri=CONSOLE_CALLBACK,
)
sess["user_token"] = tokens.get("access_token", "")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e:
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
@router.get("/callback", response_class=HTMLResponse)
async def tink_callback(request: Request, code: Optional[str] = None,
error: Optional[str] = None,
credentials_id: Optional[str] = None):
"""Tink Link OAuth callback — exchange code for user token."""
sess = _session(request)
if error:
return RedirectResponse(f"/demo/step/3?error={error}")
if code:
try:
2026-05-22 19:18:38 +02:00
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
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)
2026-05-22 18:30:59 +02:00
except Exception as e:
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
return RedirectResponse("/demo/step/3", status_code=303)
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 4 — Accounts (v2)
# ---------------------------------------------------------------------------
@router.get("/demo/step/4", response_class=HTMLResponse)
async def step4(request: Request):
sess = _session(request)
s = get_settings()
user_token = sess.get("user_token", "")
error = None
result = None
is_demo = sess.get("demo_mode") or s.demo_mode
curl_example = (
"curl https://api.tink.com/data/v2/accounts \\\n"
" -H 'Authorization: Bearer $USER_TOKEN'"
)
if not user_token and not is_demo:
error = "Mangler user token — tilslut en bank i Step 3 først."
elif is_demo:
result = demo_data.MOCK_ACCOUNTS
else:
try:
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
result = await client.list_accounts(user_token)
# Fall back to demo data if no accounts connected yet
if not result.get("accounts"):
result = demo_data.MOCK_ACCOUNTS
sess["demo_mode"] = True
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 4,
"title": "Konti",
"subtitle": "Account List with Balances",
"endpoint": "GET /data/v2/accounts",
"api_version": "v2 ✦",
"description": "Henter brugerens konti via det nye v2 data endpoint. Returnerer account type, balance, currency og IBAN.",
"curl_example": curl_example,
"result": result,
"error": error,
"is_demo": is_demo or (result == demo_data.MOCK_ACCOUNTS),
"next_step": 5,
"prev_step": 3,
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 5 — Transactions (v2)
# ---------------------------------------------------------------------------
@router.get("/demo/step/5", response_class=HTMLResponse)
async def step5(request: Request, account_id: Optional[str] = None):
sess = _session(request)
s = get_settings()
user_token = sess.get("user_token", "")
is_demo = sess.get("demo_mode") or s.demo_mode
error = None
result = None
curl_example = (
"curl 'https://api.tink.com/data/v2/transactions?pageSize=25' \\\n"
" -H 'Authorization: Bearer $USER_TOKEN'"
)
if not user_token and not is_demo:
error = "Mangler user token — tilslut en bank i Step 3 først."
elif is_demo:
result = demo_data.MOCK_TRANSACTIONS
else:
try:
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
result = await client.list_transactions(user_token, account_id=account_id)
if not result.get("transactions"):
result = demo_data.MOCK_TRANSACTIONS
sess["demo_mode"] = True
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 5,
"title": "Transaktioner",
"subtitle": "Transaction History",
"endpoint": "GET /data/v2/transactions",
"api_version": "v2 ✦",
"description": "Henter transaktioner via v2 endpoint med paginering. Returnerer amount, description, status (BOOKED/PENDING), og kategorisering.",
"curl_example": curl_example,
"result": result,
"error": error,
"is_demo": is_demo or (result == demo_data.MOCK_TRANSACTIONS),
"next_step": 6,
"prev_step": 4,
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 6 — Events (v2)
# ---------------------------------------------------------------------------
@router.get("/demo/step/6", response_class=HTMLResponse)
async def step6(request: Request):
sess = _session(request)
s = get_settings()
is_demo = sess.get("demo_mode") or s.demo_mode
error = None
result_webhooks = None
webhook_registered = None
2026-05-22 18:30:59 +02:00
webhook_url = f"{s.app_base_url}/webhooks/tink"
curl_list = (
"# List registered webhooks (app-level token)\n"
"curl 'https://api.tink.com/api/v1/webhooks' \\\n"
" -H 'Authorization: Bearer $APP_TOKEN'"
2026-05-22 18:30:59 +02:00
)
curl_register = (
"# Register webhook endpoint\n"
"curl -X POST 'https://api.tink.com/api/v1/webhooks' \\\n"
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
" -H 'Content-Type: application/json' \\\n"
" -d '{\n"
' "url": "' + webhook_url + '",\n'
' "enabledEvents": [\n'
' "account-booked-transaction:created",\n'
' "account-pending-transaction:created"\n'
" ]\n"
" }'"
2026-05-22 18:30:59 +02:00
)
if is_demo:
result_webhooks = demo_data.MOCK_EVENTS_BOOKED
webhook_registered = {
"id": "wh-demo-001",
"url": webhook_url,
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
"status": "ENABLED",
}
2026-05-22 18:30:59 +02:00
else:
try:
client = _client(log_cb=_logger(sess))
app_token_resp = await client.get_app_token(scope="user:create")
app_token = app_token_resp.get("access_token", "")
result_webhooks = await client.list_webhooks(app_token)
# Register our webhook if not already there
existing = [w for w in result_webhooks.get("webhooks", []) if w.get("url") == webhook_url]
if not existing:
webhook_registered = await client.register_webhook(app_token, webhook_url)
else:
webhook_registered = existing[0]
2026-05-22 18:30:59 +02:00
except Exception as e:
err_str = str(e)
if "404" in err_str:
result_webhooks = {"note": "Webhook API ikke tilgængeligt i sandbox — kun i produktion"}
webhook_registered = {
"url": webhook_url,
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
"status": "ENABLED (eksempel)",
}
else:
error = err_str
2026-05-22 18:30:59 +02:00
return templates.TemplateResponse("step6.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 6,
"title": "Webhooks & Events",
"subtitle": "Real-time Event Notifications",
2026-05-22 18:30:59 +02:00
"error": error,
"result_webhooks": result_webhooks,
"webhook_registered": webhook_registered,
"webhook_url": webhook_url,
"curl_list": curl_list,
"curl_register": curl_register,
2026-05-22 18:30:59 +02:00
"is_demo": is_demo,
"app_base_url": s.app_base_url,
"next_step": None,
"prev_step": 5,
}))
# ---------------------------------------------------------------------------
# API Request Log
# ---------------------------------------------------------------------------
@router.get("/demo/log", response_class=HTMLResponse)
async def api_log(request: Request):
sess = _session(request)
log = sess.get("api_log", [])
return templates.TemplateResponse("log.html", _ctx(request, {
"log": list(reversed(log)), # newest first
"log_count": len(log),
}))
@router.post("/demo/log/clear")
async def clear_log(request: Request):
sess = _session(request)
sess["api_log"] = []
return RedirectResponse("/demo/log", status_code=303)
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Webhook receiver
# ---------------------------------------------------------------------------
@router.post("/webhooks/tink")
async def webhook_receiver(request: Request):
"""Receive Tink webhook events (configure URL in Tink Console)."""
body = await request.json()
# In production you'd verify the signature and store events
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
return {"status": "received"}