feat: Tink open banking demo — 6-step API walkthrough

Demonstrates the full Tink integration flow for open banking:
  Step 1 — Client credentials auth (app token)
  Step 2 — Create Tink user with external_user_id
  Step 3 — Connect bank via Tink Link OAuth redirect
  Step 4 — List accounts (v2 endpoint)
  Step 5 — List transactions (v2 endpoint, cursor pagination)
  Step 6 — Webhooks (register endpoint, receive events)

Built with Python / FastAPI + Jinja2 templates.
Each step shows live JSON responses, cURL examples and API version badges.
Includes server-side token store (prevents session cookie overflow),
asyncio lock on OAuth callback, and demo mode with realistic mock data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Henrik Jess Nielsen
2026-05-23 02:08:27 +02:00
commit ab591be464
23 changed files with 2477 additions and 0 deletions

0
src/__init__.py Normal file
View File

25
src/config.py Normal file
View File

@@ -0,0 +1,25 @@
"""
App configuration loaded from environment / .env file.
"""
import os
from functools import lru_cache
from dotenv import load_dotenv
load_dotenv()
class Settings:
tink_client_id: str = os.environ["TINK_CLIENT_ID"]
tink_client_secret: str = os.environ["TINK_CLIENT_SECRET"]
tink_redirect_uri: str = os.getenv("TINK_REDIRECT_URI", "http://localhost:8000/callback")
app_base_url: str = os.getenv("APP_BASE_URL", "http://localhost:8000")
session_secret: str = os.getenv("SESSION_SECRET", "dev-only-change-in-prod")
tink_api_base: str = os.getenv("TINK_API_BASE", "https://api.tink.com")
tink_link_base: str = os.getenv("TINK_LINK_BASE", "https://link.tink.com")
demo_mode: bool = os.getenv("DEMO_MODE", "false").lower() in ("true", "1", "yes")
@lru_cache
def get_settings() -> Settings:
return Settings()

228
src/demo_data.py Normal file
View File

@@ -0,0 +1,228 @@
"""
Realistic mock data for demo mode.
Used when DEMO_MODE=true or when no bank is connected.
Data mimics real Tink API v2 responses for a Danish user.
"""
MOCK_CREDENTIALS = {
"credentials": [
{
"id": "dk-demobank-cred-001",
"providerName": "dk-demobank-open-banking-embedded",
"type": "PASSWORD",
"status": "UPDATED",
"statusUpdated": "2026-05-22T08:00:00Z",
"updated": "2026-05-22T08:00:00Z",
"fields": {"username": "demo_user_001"},
}
]
}
MOCK_ACCOUNTS = {
"accounts": [
{
"id": "acc-8f3a2e1b-demo",
"name": "Løbende konto",
"type": "CHECKING",
"balances": {
"booked": {
"amount": {"value": {"unscaledValue": "4275850", "scale": "2"}, "currencyCode": "DKK"},
},
"available": {
"amount": {"value": {"unscaledValue": "4275850", "scale": "2"}, "currencyCode": "DKK"},
},
},
"identifiers": {
"iban": {"iban": "DK5000400440116243"},
"financialInstitution": {"accountNumber": "0440116243"},
},
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
"financialInstitutionId": "dk-demobank",
},
{
"id": "acc-9c4d3f2a-demo",
"name": "Opsparing",
"type": "SAVINGS",
"balances": {
"booked": {
"amount": {"value": {"unscaledValue": "18500000", "scale": "2"}, "currencyCode": "DKK"},
},
},
"identifiers": {
"iban": {"iban": "DK5000400440116244"},
"financialInstitution": {"accountNumber": "0440116244"},
},
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
"financialInstitutionId": "dk-demobank",
},
],
"nextPageToken": "",
}
MOCK_TRANSACTIONS = {
"transactions": [
{
"id": "tx-001-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-22", "value": "2026-05-22"},
"description": "Spotify AB",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
"merchantInformation": {"merchantName": "Spotify", "merchantCategoryCode": "7995"},
"types": {"financialInstitutionTypeCode": "CSCD", "type": "DEFAULT"},
},
{
"id": "tx-002-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-21", "value": "2026-05-21"},
"description": "Netto Supermarked",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
"merchantInformation": {"merchantName": "Netto", "merchantCategoryCode": "5411"},
"types": {"type": "DEFAULT"},
},
{
"id": "tx-003-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "3500000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-20", "value": "2026-05-20"},
"description": "Løn maj 2026",
"status": "BOOKED",
"categories": {"pfm": {"id": "income.salary", "name": "Løn"}},
"types": {"type": "DEFAULT"},
},
{
"id": "tx-004-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-120000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-19", "value": "2026-05-19"},
"description": "Matas Strøget",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}},
"merchantInformation": {"merchantName": "Matas", "merchantCategoryCode": "5912"},
"types": {"type": "DEFAULT"},
},
{
"id": "tx-005-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-8500000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-01", "value": "2026-05-01"},
"description": "Husleje maj",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}},
"types": {"type": "DEFAULT"},
},
{
"id": "tx-006-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-35000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-18", "value": "2026-05-18"},
"description": "DSB Rejse",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}},
"merchantInformation": {"merchantName": "DSB", "merchantCategoryCode": "4112"},
"types": {"type": "DEFAULT"},
},
{
"id": "tx-007-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-19900", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-17", "value": "2026-05-17"},
"description": "Netflix International",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
"merchantInformation": {"merchantName": "Netflix", "merchantCategoryCode": "7995"},
"types": {"type": "DEFAULT"},
},
{
"id": "tx-008-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-62500", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-16", "value": "2026-05-16"},
"description": "Fakta Falkoner",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
"merchantInformation": {"merchantName": "Fakta", "merchantCategoryCode": "5411"},
"types": {"type": "DEFAULT"},
},
{
"id": "tx-009-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-23", "value": "2026-05-23"},
"description": "7-Eleven Nørreport",
"status": "PENDING",
"categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}},
"merchantInformation": {"merchantName": "7-Eleven", "merchantCategoryCode": "5499"},
"types": {"type": "DEFAULT"},
},
],
"nextPageToken": "",
}
MOCK_EVENTS_BOOKED = {
"events": [
{
"id": "evt-booked-001",
"type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-22T08:12:33Z",
"transaction": {
"id": "tx-001-demo",
"description": "Spotify AB",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED",
"dates": {"booked": "2026-05-22"},
},
},
{
"id": "evt-booked-002",
"type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-21T14:22:10Z",
"transaction": {
"id": "tx-002-demo",
"description": "Netto Supermarked",
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED",
"dates": {"booked": "2026-05-21"},
},
},
],
"nextPageToken": "",
}
MOCK_EVENTS_ALL = {
"events": [
{
"id": "evt-pending-001",
"type": "account-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-23T11:05:44Z",
"transaction": {
"id": "tx-009-demo",
"description": "7-Eleven Nørreport",
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
"status": "PENDING",
"dates": {"booked": "2026-05-23"},
},
},
{
"id": "evt-booked-001",
"type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-22T08:12:33Z",
"transaction": {
"id": "tx-001-demo",
"description": "Spotify AB",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED",
"dates": {"booked": "2026-05-22"},
},
},
],
"nextPageToken": "",
}

28
src/main.py Normal file
View File

@@ -0,0 +1,28 @@
"""
FastAPI application entry point.
"""
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from src.config import get_settings
from src.routes.demo import router
settings = get_settings()
app = FastAPI(
title="Tink API Demo",
description="MoneyCapp × Tink — sales demo showing v2 API endpoints",
version="1.0.0",
)
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret,
max_age=3600,
)
app.include_router(router)
app.mount("/static", StaticFiles(directory="src/static"), name="static")

0
src/routes/__init__.py Normal file
View File

644
src/routes/demo.py Normal file
View File

@@ -0,0 +1,644 @@
"""
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")
def _client(log_cb=None) -> TinkClient:
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,
on_request=log_cb,
)
def _session(request: Request) -> dict:
return request.session.setdefault("demo", {})
# ---------------------------------------------------------------------------
# Server-side token store — keeps JWTs OUT of the session cookie
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead)
# ---------------------------------------------------------------------------
_token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
def _get_sid(sess: dict) -> str:
"""Return (creating if needed) a stable session ID stored in the cookie."""
if "sid" not in sess:
sess["sid"] = str(uuid.uuid4())
return sess["sid"]
def _store_token(sess: dict, key: str, value: str) -> None:
"""Save a JWT in the server-side store instead of the cookie."""
sid = _get_sid(sess)
_token_store.setdefault(sid, {})[key] = value
def _load_token(sess: dict, key: str, default: str = "") -> str:
"""Read a JWT from the server-side store."""
sid = sess.get("sid", "")
return _token_store.get(sid, {}).get(key, default)
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
# ---------------------------------------------------------------------------
@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."""
sess = _session(request)
sid = sess.get("sid", "")
if sid:
_token_store.pop(sid, None)
request.session.pop("demo", None)
return RedirectResponse("/demo/step/1")
@router.get("/demo/debug-session")
async def debug_session(request: Request):
"""Show current session keys (debug only)."""
sess = _session(request)
safe = {
k: (v[:20] + "" if isinstance(v, str) and len(v) > 20 else v)
for k, v in sess.items()
if k != "api_log"
}
safe["api_log_count"] = len(sess.get("api_log", []))
safe["cookie_size_bytes"] = len(str(request.session))
return safe
# ---------------------------------------------------------------------------
# Step 1 — Authenticate (client credentials)
# ---------------------------------------------------------------------------
@router.get("/demo/step/1", response_class=HTMLResponse)
async def step1(request: Request):
sess = _session(request)
client = _client(log_cb=_logger(sess))
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,authorization:grant'"
)
try:
result = await client.get_app_token(scope="user:create,authorization:grant")
_store_token(sess, "app_token", result["access_token"])
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", _ctx(request, {
"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,
}))
# ---------------------------------------------------------------------------
# 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 = _load_token(sess, "app_token")
error = None
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 = _load_token(sess, "app_token")
error = None
result = None
# Build external_user_id — always unique per run (simulates real customer UUID)
short_id = secrets.token_hex(3) # 6-char hex, e.g. "a3f9c1"
if customer_name.strip():
import re
slug = customer_name.strip().lower().replace(" ", "-")
slug = re.sub(r"[^a-z0-9\-]", "", slug)
external_user_id = f"moneycapp-{slug}-{short_id}"
else:
external_user_id = f"moneycapp-{short_id}"
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"
f" -H 'Content-Type: application/json' \\\n"
f" -d '{body}'"
)
if not app_token:
error = "Mangler app token — gå tilbage til Step 1."
else:
try:
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
sess["user_market"] = market
# New user — clear any stale tokens from a previous user
sess.pop("user_token", None)
sess.pop("demo_mode", None)
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", _ctx(request, {
"step": 2,
"title": "Opret Bruger",
"subtitle": f"Kunde: {external_user_id}",
"endpoint": "POST /api/v1/user/create",
"api_version": "v1",
"description": f"Oprettet Tink-bruger med <code class='text-violet-300 font-mono'>tink_external_ref</code> = <code class='text-violet-300 font-mono'>{external_user_id}</code> — MoneyCapp's reference til kunden i Tink. Tink returnerer et <code class='text-violet-300 font-mono'>user_id</code> som bruges i næste kald.",
"curl_example": curl_example,
"result": result,
"error": error,
"next_step": 3,
"prev_step": 1,
}))
# ---------------------------------------------------------------------------
# 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))
error = None
credentials = None
cb_success = request.query_params.get("cb_success")
cb_error = request.query_params.get("cb_error")
user_id = sess.get("user_id", "")
app_token = _load_token(sess, "app_token")
grant_result = None
# Call authorization-grant/delegate to demonstrate the API and get a code.
# We show this in the response viewer so the audience sees it works.
# The actual Tink Link flow uses anonymous mode (sandbox limitation),
# but in production you'd pass authorization_code in the URL.
if user_id and app_token and not _load_token(sess, "user_token"):
try:
grant_result = await client.get_authorization_grant_token(
app_token=app_token,
user_id=user_id,
scope="accounts:read,transactions:read,credentials:read",
)
except Exception as e:
error = str(e)
# Always use anonymous flow for the actual Tink Link URL — works in sandbox.
# Production apps would add authorization_code=<code> instead of scope.
tink_link_url = client.get_tink_link_url(market=sess.get("user_market", "DK"))
dev_tink_link_url = client.get_tink_link_url(
market=sess.get("user_market", "DK"),
redirect_uri_override=CONSOLE_CALLBACK,
)
# Demo mode: auto-mark as connected with mock data
if s.demo_mode and not _load_token(sess, "user_token"):
_store_token(sess, "user_token", "demo-mode-token")
sess["demo_mode"] = True
# Check if already connected (returning from callback)
user_token = _load_token(sess, "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:
if "403" in str(e):
credentials = {"note": "credentials:read scope ikke tildelt — brug accounts:read i stedet"}
else:
error = str(e)
external_user_id = sess.get("external_user_id", "")
uid_display = user_id or "$USER_ID"
grant_code = (grant_result or {}).get("code", "$AUTHORIZATION_CODE")
curl_example = (
"# Step 1: Generer authorization_code for din 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"
"# → { \"code\": \"AUTHORIZATION_CODE\" }\n\n"
"# Step 2: Byg Tink Link URL med authorization_code (binder bank til din bruger)\n"
"https://link.tink.com/1.0/transactions/connect-accounts\n"
" ?client_id=$CLIENT_ID\n"
" &redirect_uri=$REDIRECT_URI\n"
f" &authorization_code={grant_code}\n\n"
"# Step 3: Callback → exchange code for user token\n"
"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'"
)
if grant_result:
description = (
f"authorization-grant/delegate kaldt for bruger <code class='text-violet-300 font-mono'>{external_user_id}</code> — "
f"returnerede <code class='text-violet-300 font-mono'>code</code> som vist nedenfor. "
f"I produktion sendes denne code med i Tink Link URL som <code class='text-violet-300 font-mono'>authorization_code</code>."
)
elif user_id:
description = (
"Åbn Tink Link → vælg <b>Tink Demo Bank</b> → log ind med testbruger. "
"Klik <b>Vis testbrugere</b> for at se login til DK, SE, NO, FI, DE m.fl."
)
else:
description = "Gå til Step 2 for at oprette en bruger, klik derefter her for at tilslutte banken."
# Tink Demo Bank test users per market
# Source: https://docs.tink.com/resources/tutorials/test-your-integration-with-demo-bank
demo_bank_users = [
{"market": "🇩🇰 DK", "username": "u04877810", "password": "vxw774", "otp": "1234", "scenario": "Succes"},
{"market": "🇩🇰 DK", "username": "u92721594", "password": "nbs589", "otp": "", "scenario": "Auth-fejl"},
{"market": "🇸🇪 SE", "username": "u59803783", "password": "hwj858", "otp": "1234", "scenario": "Succes"},
{"market": "🇸🇪 SE", "username": "u91817276", "password": "cft248", "otp": "", "scenario": "Auth-fejl"},
{"market": "🇳🇴 NO", "username": "u24765398", "password": "xjf459", "otp": "1234", "scenario": "Succes"},
{"market": "🇫🇮 FI", "username": "u19283746", "password": "zkm291", "otp": "1234", "scenario": "Succes"},
{"market": "🇩🇪 DE", "username": "u38471920", "password": "bvp103", "otp": "1234", "scenario": "Succes"},
{"market": "🇬🇧 GB", "username": "u72910483", "password": "qrt567", "otp": "1234", "scenario": "Succes"},
{"market": "🇳🇱 NL", "username": "u56473829", "password": "lmn482", "otp": "1234", "scenario": "Succes"},
{"market": "🇫🇷 FR", "username": "u84920173", "password": "pqs736", "otp": "1234", "scenario": "Succes"},
]
return templates.TemplateResponse("step.html", _ctx(request, {
"step": 3,
"title": "Tilslut Bank",
"subtitle": "Tink Link — Bank Connection",
"endpoint": "POST /api/v1/oauth/authorization-grant/delegate",
"api_version": "v1",
"description": description,
"curl_example": curl_example,
"result": grant_result or credentials,
"tink_link_url": tink_link_url,
"dev_tink_link_url": dev_tink_link_url,
"demo_bank_users": demo_bank_users,
"error": error or (f"Callback fejl: {cb_error}" if cb_error else None),
"cb_success": cb_success,
"next_step": 4,
"prev_step": 2,
}))
@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:
client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(
code=code.strip(),
redirect_uri=CONSOLE_CALLBACK,
)
_store_token(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)
print(f"[CALLBACK] code={code!r} error={error!r} session_keys={list(sess.keys())}")
if error:
print(f"[CALLBACK] Tink returned error: {error}")
return RedirectResponse(f"/demo/step/3?error={error}")
if code:
try:
s = get_settings()
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(
code, redirect_uri=s.tink_redirect_uri
)
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
user_token = tokens.get("access_token", "")
if not user_token:
print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}")
return RedirectResponse(
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
status_code=303,
)
_store_token(sess, "user_token", user_token)
print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e:
import traceback
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}")
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
print(f"[CALLBACK] No code — bare redirect to step 3")
return RedirectResponse("/demo/step/3", status_code=303)
# ---------------------------------------------------------------------------
# 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 = _load_token(sess, "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))
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, {
"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,
}))
# ---------------------------------------------------------------------------
# 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 = _load_token(sess, "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))
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, {
"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,
}))
# ---------------------------------------------------------------------------
# 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
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'"
)
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"
" }'"
)
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",
}
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]
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
return templates.TemplateResponse("step6.html", _ctx(request, {
"step": 6,
"title": "Webhooks & Events",
"subtitle": "Real-time Event Notifications",
"error": error,
"result_webhooks": result_webhooks,
"webhook_registered": webhook_registered,
"webhook_url": webhook_url,
"curl_list": curl_list,
"curl_register": curl_register,
"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)
# ---------------------------------------------------------------------------
# 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"}

119
src/templates/base.html Normal file
View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="da" class="h-full">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Tink API Demo {% block title %}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.json-key { color: #7dd3fc; }
.json-str { color: #86efac; }
.json-num { color: #fcd34d; }
.json-bool { color: #f9a8d4; }
.json-null { color: #94a3b8; }
pre.json-block {
background: #0f172a;
border-radius: 0.5rem;
padding: 1.25rem;
overflow-x: auto;
font-size: 0.8rem;
line-height: 1.5;
font-family: 'Fira Code', 'Cascadia Code', monospace;
}
.step-badge-v2 {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.step-badge-v1 {
background: #334155;
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-full flex flex-col">
<!-- Top nav -->
<nav class="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-10">
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/" class="flex items-center gap-3 group">
<div class="w-8 h-8 rounded-lg bg-violet-600 flex items-center justify-center text-white font-bold text-sm">T</div>
<div>
<span class="font-semibold text-white">Tink API Demo</span>
<span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
</div>
</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/log"
class="text-sm text-slate-400 hover:text-violet-300 transition flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
API Log
</a>
<a href="/demo/reset" class="text-sm text-slate-400 hover:text-white transition">↺ Reset</a>
</div>
</div>
</nav>
<!-- Step progress bar (only if step is defined in template) -->
{% block stepper %}{% endblock %}
<!-- Main content -->
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-8">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="border-t border-slate-800 text-center text-slate-500 text-xs py-4">
Tink API Demo &mdash; MoneyCapp sales prototype &mdash; i80.dk
</footer>
<script>
function syntaxHighlight(json) {
if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function(match) {
let cls = 'json-num';
if (/^"/.test(match)) {
cls = /:$/.test(match) ? 'json-key' : 'json-str';
} else if (/true|false/.test(match)) {
cls = 'json-bool';
} else if (/null/.test(match)) {
cls = 'json-null';
}
return `<span class="${cls}">${match}</span>`;
});
}
document.querySelectorAll('.raw-json').forEach(el => {
try {
const data = JSON.parse(el.textContent);
el.innerHTML = syntaxHighlight(data);
} catch(e) {
el.innerHTML = syntaxHighlight(el.textContent);
}
el.classList.add('json-block');
el.classList.remove('raw-json');
});
function copyToClipboard(id) {
const el = document.getElementById(id);
navigator.clipboard.writeText(el.innerText).then(() => {
const btn = el.nextElementSibling;
if (btn) { btn.textContent = '✓ Kopieret'; setTimeout(() => btn.textContent = 'Kopier', 2000); }
});
}
function copyText(id, btn) {
const el = document.getElementById(id);
navigator.clipboard.writeText(el.innerText).then(() => {
const orig = btn.textContent;
btn.textContent = '✓';
setTimeout(() => btn.textContent = orig, 2000);
});
}
</script>
</body>
</html>

57
src/templates/index.html Normal file
View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %} — Start{% endblock %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center gap-8">
<!-- Hero -->
<div class="max-w-2xl">
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-violet-900/40 border border-violet-700/50 text-violet-300 text-sm mb-6">
<span class="w-2 h-2 rounded-full bg-violet-400 animate-pulse"></span>
Live demo · Tink Sandbox
</div>
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
Tink Open Banking<br/>
<span class="text-violet-400">API Demo</span>
</h1>
<p class="text-slate-400 text-lg leading-relaxed mb-2">
Step-for-step gennemgang af hele Tink integrationsflowet —
fra brugeroprettelse til live transaktioner og events.
</p>
<p class="text-slate-500 text-sm">
Bruger Tink <strong class="text-violet-400">v2 endpoints</strong> for accounts, transactions og events.
</p>
</div>
<!-- Flow overview -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 w-full max-w-4xl">
{% set steps = [
("1", "Auth", "Client Credentials", "v1"),
("2", "Opret Bruger", "POST /user/create", "v1"),
("3", "Tilslut Bank", "Tink Link", "Link"),
("4", "Konti", "GET /data/v2/accounts", "v2"),
("5", "Transaktioner", "GET /data/v2/transactions", "v2"),
("6", "Events", "GET /events/v2/…", "v2"),
] %}
{% for num, name, endpoint, version in steps %}
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="w-7 h-7 rounded-full bg-slate-800 text-slate-300 text-xs font-bold flex items-center justify-center">{{ num }}</span>
<span class="text-xs px-2 py-0.5 rounded-full font-mono {% if version == 'v2' %}bg-violet-900/50 text-violet-300 border border-violet-700/40{% else %}bg-slate-800 text-slate-400{% endif %}">{{ version }}</span>
</div>
<div class="text-sm font-semibold text-white">{{ name }}</div>
<div class="text-xs text-slate-500 font-mono leading-tight">{{ endpoint }}</div>
</div>
{% endfor %}
</div>
<!-- CTA -->
<a href="/demo/step/1"
class="inline-flex items-center gap-3 px-8 py-4 bg-violet-600 hover:bg-violet-500 text-white font-semibold rounded-xl text-lg transition-all hover:scale-105 shadow-lg shadow-violet-900/50">
Start Demo
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
</a>
</div>
{% endblock %}

99
src/templates/log.html Normal file
View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}API Log — Tink Demo{% endblock %}
{% block content %}
<div class="max-w-5xl mx-auto px-4 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">📋 API Request Log</h1>
<p class="text-slate-400 text-sm mt-1">Alle Tink API-kald i denne session</p>
</div>
<div class="flex items-center gap-3">
<span class="text-slate-400 text-sm">{{ log_count }} kald registreret</span>
{% if log_count > 0 %}
<form method="post" action="/demo/log/clear">
<button type="submit"
class="text-xs px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-300 transition">
Ryd log
</button>
</form>
{% endif %}
<a href="/demo/step/1"
class="text-xs px-3 py-1.5 rounded bg-violet-600 hover:bg-violet-500 text-white transition">
← Tilbage til demo
</a>
</div>
</div>
{% if log_count == 0 %}
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-12 text-center">
<div class="text-4xl mb-4">📭</div>
<p class="text-slate-400">Ingen API-kald endnu.</p>
<p class="text-slate-500 text-sm mt-1">Gå igennem demo-steppene for at se kaldene her.</p>
<a href="/demo/step/1" class="inline-block mt-4 px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-white text-sm transition">
Start fra Step 1
</a>
</div>
{% else %}
<div class="space-y-3">
{% for entry in log %}
<div class="rounded-xl border {% if entry.ok %}border-emerald-700/40 bg-emerald-900/10{% else %}border-red-700/40 bg-red-900/10{% endif %} overflow-hidden">
{# Header row #}
<button onclick="this.nextElementSibling.classList.toggle('hidden')"
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/5 transition">
{# Method badge #}
<span class="text-xs font-mono font-bold px-2 py-0.5 rounded
{% if entry.method == 'GET' %}bg-blue-500/20 text-blue-300 border border-blue-500/30
{% else %}bg-violet-500/20 text-violet-300 border border-violet-500/30{% endif %}">
{{ entry.method }}
</span>
{# Status badge #}
<span class="text-xs font-mono px-2 py-0.5 rounded
{% if entry.ok %}bg-emerald-500/20 text-emerald-300 border border-emerald-500/30
{% else %}bg-red-500/20 text-red-300 border border-red-500/30{% endif %}">
{{ entry.status }}
</span>
{# URL — strip domain for cleanliness #}
<span class="font-mono text-sm text-slate-200 flex-1 truncate">
{{ entry.url | replace("https://api.tink.com", "") }}
</span>
{# Time + duration #}
<span class="text-xs text-slate-500 font-mono">{{ entry.ts }}</span>
<span class="text-xs text-slate-500 font-mono">{{ entry.duration_ms }}ms</span>
<svg class="w-4 h-4 text-slate-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
{# Collapsible body — hidden by default #}
<div class="hidden border-t {% if entry.ok %}border-emerald-700/20{% else %}border-red-700/20{% endif %} px-4 py-3 grid grid-cols-1 md:grid-cols-2 gap-4">
{% if entry.req_body %}
<div>
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Request</div>
<pre class="text-xs font-mono text-slate-300 bg-slate-900/60 rounded p-3 overflow-auto max-h-48 whitespace-pre-wrap">{{ entry.req_body | tojson(indent=2) }}</pre>
</div>
{% endif %}
<div {% if not entry.req_body %}class="md:col-span-2"{% endif %}>
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Response</div>
<pre class="text-xs font-mono text-slate-300 bg-slate-900/60 rounded p-3 overflow-auto max-h-48 whitespace-pre-wrap">{{ entry.resp_body | tojson(indent=2) }}</pre>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

335
src/templates/step.html Normal file
View File

@@ -0,0 +1,335 @@
{% extends "base.html" %}
{% block title %} — Step {{ step }}{% 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", "Events"] %}
{% 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 == step %}bg-violet-600 text-white font-semibold
{% elif i < step %}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 < step %}bg-slate-700 text-slate-300
{% elif i == step %}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="grid grid-cols-1 lg:grid-cols-5 gap-6">
<!-- Left: info panel -->
<div class="lg:col-span-2 space-y-4">
<!-- Step header -->
<div>
<div class="flex items-center gap-2 mb-2">
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">{{ step }}</span>
<div>
<h2 class="text-xl font-bold text-white">{{ title }}</h2>
<p class="text-slate-400 text-sm">{{ subtitle }}</p>
</div>
</div>
</div>
<!-- Endpoint badge -->
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-slate-500 uppercase tracking-wider">Endpoint</span>
<span class="text-xs px-2 py-0.5 rounded-full font-mono font-semibold
{% if 'v2' in api_version %}bg-violet-900/50 text-violet-300 border border-violet-700/40
{% else %}bg-slate-800 text-slate-400{% endif %}">
{{ api_version }}
</span>
</div>
<code class="text-sm text-emerald-400 font-mono break-all">{{ endpoint }}</code>
</div>
<!-- Description -->
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
<p class="text-slate-300 text-sm leading-relaxed">{{ description | safe }}</p>
</div>
<!-- curl example -->
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL eksempel</span>
<button onclick="copyToClipboard('curl-{{ step }}')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">
Kopier
</button>
</div>
<pre id="curl-{{ step }}" class="text-xs text-amber-300 font-mono whitespace-pre-wrap leading-relaxed">{{ curl_example }}</pre>
</div>
<!-- Navigation -->
<div class="flex gap-3">
{% if prev_step %}
<a href="/demo/step/{{ prev_step }}"
class="flex-1 text-center 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 {{ prev_step }}
</a>
{% endif %}
{% if next_step %}
<a href="/demo/step/{{ next_step }}"
class="flex-1 text-center px-4 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
Step {{ next_step }} →
</a>
{% endif %}
</div>
</div>
<!-- Right: response panel -->
<div class="lg:col-span-3 space-y-4">
<!-- Tink Link special button (step 3) -->
{% if tink_link_url %}
{% if cb_success %}
<!-- Already connected — show success, hide connection UI -->
<div class="bg-emerald-950/60 border border-emerald-700/50 rounded-xl p-5 flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-emerald-900/60 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</div>
<div class="flex-1">
<p class="text-emerald-300 font-semibold text-base">Bank forbundet!</p>
<p class="text-emerald-400/70 text-sm mt-0.5">User token gemt i session. Trin 46 er klar.</p>
<a href="/demo/reset"
class="inline-flex items-center gap-1.5 mt-3 text-xs text-slate-500 hover:text-slate-300 transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Start forfra
</a>
</div>
</div>
{% else %}
<!-- Not yet connected — show connection UI -->
<!-- PRIMARY: direct callback flow -->
<div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full bg-emerald-900/50 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
</div>
<div>
<h3 class="text-white font-semibold mb-1">Tilslut testbank</h3>
<p class="text-slate-400 text-sm">Klik knappen, vælg Demo Bank og log ind — du redirectes automatisk tilbage.</p>
</div>
</div>
<!-- Instructions -->
<div class="bg-slate-800/60 border border-slate-700/50 rounded-lg p-4 text-sm space-y-2">
<p class="text-slate-300 font-semibold flex items-center gap-1.5">
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Trin i Tink Link
</p>
<ol class="text-slate-400 space-y-1.5 list-decimal list-inside leading-relaxed">
<li>Vælg <span class="text-slate-300 font-medium">Tink Demo Bank</span></li>
<li>Vælg <span class="text-slate-300">Open Banking</span><span class="text-slate-300">Password And OTP</span></li>
<li>Hent credentials: <span class="font-mono text-amber-300 text-xs bg-slate-900 px-1.5 py-0.5 rounded">Console → Demo Bank → Transactions → DK</span></li>
<li>Indtast username + password → OTP vises på siden → Continue</li>
<li>Vælg en konto → du redirectes automatisk tilbage her ✓</li>
</ol>
</div>
<!-- Demo Bank credentials hint -->
<div class="bg-slate-800/70 border border-amber-700/30 rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
<span class="text-amber-300 text-sm font-semibold">Demo Bank Credentials</span>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<div class="text-xs text-slate-500 mb-1">Username</div>
<div class="flex items-center gap-2 bg-slate-900 border border-slate-700 rounded px-3 py-2">
<code id="demo-user" class="text-sm font-mono text-emerald-300 flex-1">u04877810</code>
<button onclick="copyText('demo-user', this)"
class="text-xs text-slate-500 hover:text-slate-300 transition flex-shrink-0">Kopier</button>
</div>
</div>
<div>
<div class="text-xs text-slate-500 mb-1">Password</div>
<div class="flex items-center gap-2 bg-slate-900 border border-slate-700 rounded px-3 py-2">
<code id="demo-pass" class="text-sm font-mono text-emerald-300 flex-1">vxw774</code>
<button onclick="copyText('demo-pass', this)"
class="text-xs text-slate-500 hover:text-slate-300 transition flex-shrink-0">Kopier</button>
</div>
</div>
</div>
<p class="text-xs text-slate-500 mt-2">Vælg <span class="text-slate-400">Tink Demo Bank → Open Banking → Password And OTP</span></p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<a href="{{ tink_link_url }}"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition">
Åbn Tink Link
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
</a>
{% if demo_bank_users %}
<button onclick="document.getElementById('demo-users-modal').classList.remove('hidden')"
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-violet-700 text-violet-400 hover:text-violet-200 hover:border-violet-500 rounded-lg text-sm transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Vis testbrugere
</button>
{% endif %}
<a href="/demo/reset"
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 rounded-lg text-sm transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Start forfra
</a>
</div>
{% if demo_bank_users %}
<!-- Demo Bank users modal -->
<div id="demo-users-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" onclick="document.getElementById('demo-users-modal').classList.add('hidden')"></div>
<div class="relative bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800">
<div>
<h3 class="text-white font-semibold text-base">Demo Bank — Testbrugere</h3>
<p class="text-slate-500 text-xs mt-0.5">Brug disse kredentialer når du logger ind i Tink Demo Bank. OTP er altid <code class="text-violet-300">1234</code> hvor det er påkrævet.</p>
</div>
<button onclick="document.getElementById('demo-users-modal').classList.add('hidden')"
class="text-slate-500 hover:text-white transition p-1.5 rounded-lg hover:bg-slate-800">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="overflow-auto max-h-96">
<table class="w-full text-sm">
<thead class="bg-slate-800/50 sticky top-0">
<tr class="text-slate-400 text-xs uppercase tracking-wider">
<th class="px-4 py-2.5 text-left">Market</th>
<th class="px-4 py-2.5 text-left">Brugernavn</th>
<th class="px-4 py-2.5 text-left">Password</th>
<th class="px-4 py-2.5 text-left">OTP</th>
<th class="px-4 py-2.5 text-left">Scenarie</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for u in demo_bank_users %}
<tr class="hover:bg-slate-800/40 transition">
<td class="px-4 py-2.5 text-slate-300 font-medium">{{ u.market }}</td>
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
<code class="font-mono text-emerald-300 text-xs">{{ u.username }}</code>
<button onclick="navigator.clipboard.writeText('{{ u.username }}')" title="Kopier"
class="text-slate-600 hover:text-slate-300 transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</td>
<td class="px-4 py-2.5">
<div class="flex items-center gap-2">
<code class="font-mono text-violet-300 text-xs">{{ u.password }}</code>
<button onclick="navigator.clipboard.writeText('{{ u.password }}')" title="Kopier"
class="text-slate-600 hover:text-slate-300 transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-slate-400">{{ u.otp or "—" }}</td>
<td class="px-4 py-2.5">
<span class="px-2 py-0.5 rounded-full text-xs font-medium {% if 'fejl' in u.scenario.lower() %}bg-red-900/40 text-red-400{% else %}bg-emerald-900/40 text-emerald-400{% endif %}">
{{ u.scenario }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="px-6 py-3 border-t border-slate-800 text-xs text-slate-500">
Kilde: <a href="https://docs.tink.com/resources/tutorials/test-your-integration-with-demo-bank" target="_blank" class="text-violet-400 hover:text-violet-300 underline">Tink Demo Bank dokumentation</a>
</div>
</div>
</div>
{% endif %}
</div>
<!-- FALLBACK: console.tink.com/callback + manual code paste -->
<details class="bg-slate-900 border border-slate-700/30 rounded-xl overflow-hidden">
<summary class="px-5 py-3 cursor-pointer text-slate-500 hover:text-slate-400 text-xs flex items-center gap-2 select-none">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Alternativ: manuel kode-indsætning via console.tink.com
<span class="ml-auto"></span>
</summary>
<div class="px-5 pb-4 pt-3 border-t border-slate-800 space-y-3">
<a href="{{ dev_tink_link_url }}" target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm rounded-lg transition">
Åbn med console callback
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
<form method="POST" action="/demo/step/3" class="flex gap-2 items-stretch">
<input type="text" name="code" placeholder="Indsæt code=XXXX fra console.tink.com/callback..."
class="flex-1 bg-slate-800 border border-slate-700 text-slate-200 rounded-lg px-3 py-2 text-sm font-mono placeholder-slate-600 focus:outline-none focus:border-emerald-600" required>
<button type="submit"
class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg transition whitespace-nowrap">
Brug kode
</button>
</form>
</div>
</details>
{% endif %}{# end not cb_success #}
{% endif %}{# end tink_link_url #}
<!-- Error state -->
{% if error %}
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5">
<div class="flex items-start gap-3">
<span class="text-red-400 text-xl flex-shrink-0"></span>
<div>
<h3 class="text-red-300 font-semibold mb-1">Fejl</h3>
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
</div>
</div>
</div>
{% endif %}
<!-- JSON response -->
{% if result %}
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-800">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
<span class="text-sm text-slate-300 font-semibold">Response</span>
<span class="text-xs text-emerald-400 font-mono">200 OK</span>
{% if is_demo %}
<span class="text-xs px-2 py-0.5 rounded-full font-semibold bg-amber-900/60 text-amber-300 border border-amber-700/40">⚠ Sample Data</span>
{% endif %}
</div>
<button onclick="copyToClipboard('json-{{ step }}')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">
Kopier JSON
</button>
</div>
<div class="p-4 max-h-[520px] overflow-y-auto">
<pre id="json-{{ step }}" class="raw-json">{{ result | tojson(indent=2) }}</pre>
</div>
</div>
{% elif not error %}
<!-- Waiting state -->
<div class="bg-slate-900 border border-slate-800 rounded-xl p-10 flex flex-col items-center justify-center gap-3 text-center">
<div class="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center">
<svg class="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<p class="text-slate-400 text-sm">Klik på knappen ovenfor for at køre dette API-kald og se svaret her.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

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

@@ -0,0 +1,164 @@
{% 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 en <code class="text-violet-300">tink_external_ref</code>
MoneyCapp's interne reference til kunden i Tink. Gemmes i jeres kundedatabase som <code class="text-violet-300">tink_external_ref</code> (ikke jeres interne <code class="text-slate-400">customer_id</code>).
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">
<code class="text-violet-300">tink_external_ref</code> = jeres reference til kunden i Tink —
adskilt fra jeres interne <code class="text-slate-400">customer_id</code>
</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"><code class="text-violet-300/70">tink_external_ref</code> = <code class="text-violet-300/70">moneycapp-henrik-jess-a3f9c1</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"># MoneyCapp DB:
# customer_id = 42 ← jeres interne ID (Tink ser det aldrig)
# tink_external_ref = "moneycapp-42-a3f9c1" ← Tink-reference
# Request body
{
"external_user_id": "moneycapp-&lt;ref&gt;", ← tink_external_ref
"market": "DK",
"locale": "da_DK"
}
# Response
{
"user_id": "abc123..." ← Tinks interne ID, gem og brug 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 %}

245
src/templates/step6.html Normal file
View File

@@ -0,0 +1,245 @@
{% extends "base.html" %}
{% block title %} — Step 6: Webhooks & Events{% 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 == 6 %}bg-violet-600 text-white font-semibold
{% elif i < 6 %}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 < 6 %}bg-slate-700 text-slate-300
{% elif i == 6 %}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">6</span>
<div>
<h2 class="text-xl font-bold text-white">Webhooks & Real-time Events</h2>
<p class="text-slate-400 text-sm">Push-notifikationer til din backend</p>
</div>
</div>
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
Registrér dit endpoint hos Tink — så sender de automatisk en HTTP POST til dig
hver gang en transaktion bogføres, opdateres eller en konto ændres. Zero polling.
</p>
</div>
{% if error %}
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5 mb-6">
<p class="text-red-300 text-sm font-semibold mb-1">Webhook API fejl</p>
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
</div>
{% endif %}
<!-- How it works banner -->
<div class="bg-slate-900/50 border border-violet-800/30 rounded-xl p-5 mb-6">
<h3 class="text-white font-semibold mb-3 text-sm">Hvordan webhooks virker</h3>
<div class="flex flex-wrap items-center gap-2 text-sm">
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
<span class="text-violet-400 font-mono text-xs block mb-0.5">Din app</span>
Brugeren kobler bank
</div>
<span class="text-slate-600 text-lg"></span>
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
<span class="text-emerald-400 font-mono text-xs block mb-0.5">Tink</span>
Henter transaktioner
</div>
<span class="text-slate-600 text-lg"></span>
<div class="bg-violet-900/50 border border-violet-700/50 rounded-lg px-3 py-2 text-slate-200 font-semibold">
<span class="text-violet-300 font-mono text-xs block mb-0.5">POST /webhooks/tink</span>
Dit endpoint modtager event
</div>
<span class="text-slate-600 text-lg"></span>
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
<span class="text-amber-400 font-mono text-xs block mb-0.5">Din app</span>
Opdaterer UI / notifikation
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- List webhooks -->
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-slate-800">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-semibold text-white">Registrerede webhooks</span>
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /api/v1/webhooks</code>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700 font-mono">app token</span>
</div>
</div>
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
<button onclick="copyToClipboard('curl-list')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
</div>
<pre id="curl-list" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_list }}</pre>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
{% if result_webhooks %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ result_webhooks | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Ingen webhooks registreret endnu.</p>
{% endif %}
</div>
</div>
<!-- Register webhook -->
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-slate-800">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-semibold text-white">Registrér webhook endpoint</span>
<code class="block text-xs text-emerald-400 font-mono mt-0.5">POST /api/v1/webhooks</code>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800/50 font-mono">registreret ✓</span>
</div>
</div>
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
<button onclick="copyToClipboard('curl-register')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
</div>
<pre id="curl-register" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_register }}</pre>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
{% if webhook_registered %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ webhook_registered | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Webhook ikke registreret.</p>
{% endif %}
</div>
</div>
</div>
<!-- Sample event payload -->
<div class="mt-6 bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<div class="px-4 py-3 border-b border-slate-800">
<div class="flex items-center justify-between">
<div>
<span class="text-sm font-semibold text-white">Sample Webhook Payload</span>
<p class="text-xs text-slate-400 mt-0.5">Sådan ser en event ud når Tink poster til dit endpoint</p>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900/50 text-amber-300 border border-amber-800/50 font-mono">incoming POST</span>
</div>
</div>
<div class="p-4 overflow-x-auto">
<pre class="text-xs text-emerald-300 font-mono whitespace-pre">{
"event": "account-booked-transaction:created",
"context": {
"userId": "a8b3c2d1-...",
"externalUserId": "moneycapp-user-42"
},
"content": {
"id": "tx_9f3a2b1c...",
"accountId": "acc_7e1d4f2a...",
"amount": {
"currencyCode": "DKK",
"value": { "scale": 2, "unscaledValue": "-24900" }
},
"dates": {
"booked": "2025-05-22",
"value": "2025-05-22"
},
"descriptions": {
"display": "Netto Albertslund",
"original": "NETTO ALBERTSLUND"
},
"merchantInformation": {
"merchantCategoryCode": "5411",
"merchantName": "Netto"
},
"status": "BOOKED",
"types": { "type": "DEFAULT" }
}
}</pre>
</div>
</div>
<!-- Your receiver endpoint -->
<div class="mt-4 bg-slate-900/60 border border-slate-700/50 rounded-xl p-4">
<div class="flex items-center gap-3 mb-3">
<div class="w-8 h-8 rounded-full bg-emerald-600/20 border border-emerald-600/40 flex items-center justify-center">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</div>
<div>
<p class="text-white text-sm font-semibold">Dit webhook modtager endpoint er live</p>
<code class="text-emerald-400 text-xs font-mono">POST {{ app_base_url }}/webhooks/tink</code>
</div>
</div>
<details class="text-sm text-slate-400">
<summary class="cursor-pointer hover:text-white transition text-xs font-semibold uppercase tracking-wider text-slate-500">Se receiver kode</summary>
<div class="mt-3 bg-slate-950 rounded-lg p-4 overflow-x-auto">
<pre class="text-xs text-emerald-300 font-mono whitespace-pre">@router.post("/webhooks/tink")
async def tink_webhook(request: Request):
payload = await request.json()
event_type = payload.get("event", "unknown")
content = payload.get("content", {})
# Her ville MoneyCapp opdatere sin database,
# sende push-notifikation, opdatere UI via SSE etc.
print(f"Tink event: {event_type}")
print(f"Transaction ID: {content.get('id')}")
print(f"Amount: {content.get('amount')}")
return {"status": "received"}</pre>
</div>
</details>
</div>
<!-- Final CTA -->
<div class="mt-8 bg-gradient-to-br from-violet-900/30 to-indigo-900/20 border border-violet-700/30 rounded-2xl p-8 text-center">
<div class="text-4xl mb-3">🎉</div>
<h3 class="text-2xl font-bold text-white mb-2">Det var hele flowet</h3>
<p class="text-slate-400 mb-2 max-w-lg mx-auto">
Fra brugeroprettelse over live bankdata til real-time webhooks — alt via Tink API.
</p>
<p class="text-slate-500 text-sm mb-6 max-w-xl mx-auto">
Vi har vist: auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
Det er præcis hvad MoneyCapp mangler for at gøre deres integration robust.
</p>
<div class="flex gap-3 justify-center flex-wrap">
<a href="/demo/reset" class="px-5 py-2.5 border border-slate-600 text-slate-300 hover:text-white hover:border-slate-400 rounded-xl text-sm transition">↺ Kør demo igen</a>
<a href="https://docs.tink.com/api-introduction" target="_blank"
class="px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
Tink Docs →
</a>
</div>
</div>
<!-- Navigation -->
<div class="mt-4 flex justify-start">
<a href="/demo/step/5"
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 5
</a>
</div>
{% endblock %}

0
src/tink/__init__.py Normal file
View File

279
src/tink/client.py Normal file
View File

@@ -0,0 +1,279 @@
"""
Tink API client — async httpx wrapper covering auth, users,
accounts (v2), transactions (v2), events (v2), and bank connectivity.
"""
import json
import time
import httpx
from typing import Optional, Callable
from dataclasses import dataclass, field
@dataclass
class TinkTokens:
app_token: str = ""
user_token: str = ""
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:
def _redact(d: dict) -> dict:
"""Replace long string values (JWT tokens etc.) with truncated versions."""
if not isinstance(d, dict):
return d
out = {}
for k, v in d.items():
if isinstance(v, str) and len(v) > 60:
out[k] = v[:12] + "…[redacted]"
elif isinstance(v, dict):
out[k] = _redact(v)
else:
out[k] = v
return out
return {
"method": method,
"url": url,
"req_body": _redact(req_body) if req_body else req_body,
"status": status,
"resp_body": _redact(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",
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
# -------------------------------------------------------------------------
async def get_app_token(self, scope: str = "user:create") -> dict:
"""Client credentials flow — returns app-level token."""
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:
data: dict = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "authorization_code",
"code": code,
}
if redirect_uri:
data["redirect_uri"] = redirect_uri
return await self._post(
f"{self.api_base}/api/v1/oauth/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=data,
)
# -------------------------------------------------------------------------
# Users (v1)
# -------------------------------------------------------------------------
async def create_user(self, app_token: str, external_user_id: str,
market: str = "DK", locale: str = "da_DK") -> dict:
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:
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:
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 (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:
from urllib.parse import urlencode
params: dict = {
"client_id": self.client_id,
"redirect_uri": redirect_uri_override or self.redirect_uri,
"market": market,
"locale": "da_DK",
}
if authorization_code:
# scope is already embedded in the authorization_code grant — do NOT add it again
params["authorization_code"] = authorization_code
else:
# anonymous flow — scope must be explicit
params["scope"] = "accounts:read,transactions:read,credentials:read"
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
# -------------------------------------------------------------------------
# Credentials (v1)
# -------------------------------------------------------------------------
async def list_credentials(self, user_token: str) -> dict:
return await self._get(
f"{self.api_base}/api/v1/credentials/list",
headers={"Authorization": f"Bearer {user_token}"},
)
# -------------------------------------------------------------------------
# Accounts — v2
# -------------------------------------------------------------------------
async def list_accounts(self, user_token: str, page_size: int = 50,
page_token: Optional[str] = None) -> dict:
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
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:
return await self._get(
f"{self.api_base}/data/v2/accounts/{account_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# -------------------------------------------------------------------------
# Transactions — v2
# -------------------------------------------------------------------------
async def list_transactions(self, user_token: str, page_size: int = 25,
page_token: Optional[str] = None,
account_id: Optional[str] = None) -> dict:
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
if account_id:
params["accountIdIn"] = account_id
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:
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,
page_token: Optional[str] = None,
) -> dict:
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
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,
page_token: Optional[str] = None,
) -> dict:
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
return await self._get(
f"{self.api_base}/events/v2/account-booked-transactions",
headers={"Authorization": f"Bearer {user_token}"},
params=params,
)
# -------------------------------------------------------------------------
# Webhooks — app-level
# -------------------------------------------------------------------------
async def list_webhooks(self, app_token: str) -> dict:
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:
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",
],
},
)