First attempt to tink demo
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

This commit is contained in:
Henrik Jess Nielsen
2026-05-22 18:30:59 +02:00
parent ba6d428a43
commit 26a16e3638
20 changed files with 1683 additions and 0 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Tink API credentials — get these from https://console.tink.com
TINK_CLIENT_ID=your_client_id_here
TINK_CLIENT_SECRET=your_client_secret_here
# Must match what you configure in Tink Console under Redirect URIs
# Local dev: http://localhost:8000/callback
# Production: https://tink-demo.i80.dk/callback
TINK_REDIRECT_URI=http://localhost:8000/callback
# Base URL for this app (used in webhook registration etc.)
APP_BASE_URL=http://localhost:8000
# Secret for signing session cookies (generate with: python -c "import secrets; print(secrets.token_hex(32))")
SESSION_SECRET=change_me_to_a_random_secret
# Tink API base URL (sandbox = production, use test credentials from Tink Console)
TINK_API_BASE=https://api.tink.com
TINK_LINK_BASE=https://link.tink.com

View File

@@ -0,0 +1,34 @@
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to i80 registry
uses: docker/login-action@v3
with:
registry: registry.i80.dk
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: registry.i80.dk/moneycapp-tink-demo:latest
- name: Deploy to Nomad
env:
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
run: |
curl -fsSL https://releases.hashicorp.com/nomad/1.8.0/nomad_1.8.0_linux_amd64.zip -o nomad.zip
unzip -q nomad.zip && chmod +x nomad
./nomad job run moneycapp-tink-demo.nomad

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
__pycache__/
*.pyc
*.pyo
.venv/
venv/
*.egg-info/
dist/
.pytest_cache/
.mypy_cache/

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.12-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

24
Makefile Normal file
View File

@@ -0,0 +1,24 @@
.PHONY: install run dev docker-build docker-up docker-down test clean
install:
python3 -m venv .venv && .venv/bin/pip install -q -r requirements.txt
cp -n .env.example .env || true
@echo "✓ Done — edit .env with your Tink credentials"
run:
.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000
dev:
.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
docker-build:
docker build -t moneycapp-tink-demo .
docker-up:
docker compose up
docker-down:
docker compose down
clean:
rm -rf .venv __pycache__ src/__pycache__ src/**/__pycache__

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
version: "3.9"
services:
demo:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./src:/app/src # hot reload mount
command: uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload

58
moneycapp-tink-demo.nomad Normal file
View File

@@ -0,0 +1,58 @@
job "moneycapp-tink-demo" {
datacenters = ["dc1"]
type = "service"
group "demo" {
count = 1
network {
port "http" { to = 8000 }
}
service {
name = "moneycapp-tink-demo"
port = "http"
tags = ["traefik.enable=true",
"traefik.http.routers.tink-demo.rule=Host(`tink-demo.i80.dk`)"]
check {
type = "http"
path = "/"
interval = "30s"
timeout = "5s"
}
}
task "app" {
driver = "docker"
config {
image = "registry.i80.dk/moneycapp-tink-demo:latest"
ports = ["http"]
}
env {
TINK_REDIRECT_URI = "https://tink-demo.i80.dk/callback"
APP_BASE_URL = "https://tink-demo.i80.dk"
TINK_API_BASE = "https://api.tink.com"
TINK_LINK_BASE = "https://link.tink.com"
}
template {
data = <<EOF
{{ with secret "secret/moneycapp-tink-demo" }}
TINK_CLIENT_ID={{ .Data.data.client_id }}
TINK_CLIENT_SECRET={{ .Data.data.client_secret }}
SESSION_SECRET={{ .Data.data.session_secret }}
{{ end }}
EOF
destination = "secrets/env"
env = true
}
resources {
cpu = 256
memory = 256
}
}
}
}

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
httpx==0.28.0
python-dotenv==1.0.1
jinja2==3.1.4
itsdangerous==2.2.0
python-multipart==0.0.17

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

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

@@ -0,0 +1,415 @@
"""
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() -> 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,
)
def _session(request: Request) -> dict:
return request.session.setdefault("demo", {})
# ---------------------------------------------------------------------------
# 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):
client = _client()
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")
_session(request)["app_token"] = result["access_token"]
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", {
"request": 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 = sess.get("app_token", "")
external_user_id = f"moneycapp-demo-{secrets.token_hex(4)}"
body = json.dumps({"external_user_id": external_user_id, "market": "DK", "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}'"
)
error = None
result = None
if not app_token:
error = "Mangler app token — gå tilbage til Step 1 og kør authentication først."
else:
try:
client = _client()
result = await client.create_user(app_token, external_user_id)
sess["user_id"] = result.get("user_id", "")
sess["external_user_id"] = external_user_id
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", {
"request": request,
"step": 2,
"title": "Opret Bruger",
"subtitle": "Create Test Customer",
"endpoint": "POST /api/v1/user/create",
"api_version": "v1",
"description": "Vi opretter en ny Tink-bruger med et unikt external_user_id — det er dit interne kunde-ID. Tink returnerer et user_id som vi bruger i de næste kald.",
"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()
tink_link_url = client.get_tink_link_url(market="DK")
dev_tink_link_url = client.get_tink_link_url(
market="DK", redirect_uri_override=CONSOLE_CALLBACK
)
error = None
credentials = None
cb_success = request.query_params.get("cb_success")
cb_error = request.query_params.get("cb_error")
# 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:
error = str(e)
curl_example = (
"# Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig\n"
f"# Redirect brugeren direkte til:\n"
f"https://link.tink.com/1.0/transactions/connect-accounts\n"
f" ?client_id=$CLIENT_ID\n"
f" &redirect_uri=$REDIRECT_URI\n"
f" &market=DK\n"
f" &scope=accounts:read,transactions:read,credentials:read\n\n"
"# Tink Link redirecter tilbage med ?code=... som du exchangeer 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=$CODE'"
)
return templates.TemplateResponse("step.html", {
"request": request,
"step": 3,
"title": "Tilslut Bank",
"subtitle": "Tink Link — Bank Connection",
"endpoint": "Tink Link /1.0/transactions/connect-accounts",
"api_version": "Link v1",
"description": "Brugeren åbner Tink Link, vælger Tink Demo Bank og logger ind med test-credentials fra Console. Tink redirecter tilbage med en authorization_code som automatisk exchang'es til et user token.",
"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,
})
@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()
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:
client = _client()
tokens = await client.exchange_code_for_token(code)
sess["user_token"] = tokens.get("access_token", "")
except Exception as e:
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}")
return RedirectResponse("/demo/step/3")
# ---------------------------------------------------------------------------
# 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()
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", {
"request": 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 = 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()
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", {
"request": 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()
user_token = sess.get("user_token", "")
is_demo = sess.get("demo_mode") or s.demo_mode
error = None
result_booked = None
result_all = None
curl_booked = (
"curl 'https://api.tink.com/events/v2/account-booked-transactions?pageSize=10' \\\n"
" -H 'Authorization: Bearer $USER_TOKEN'"
)
curl_all = (
"curl 'https://api.tink.com/events/v2/account-transactions?pageSize=10' \\\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_booked = demo_data.MOCK_EVENTS_BOOKED
result_all = demo_data.MOCK_EVENTS_ALL
else:
try:
client = _client()
result_booked = await client.list_booked_transaction_events(user_token, page_size=10)
except Exception as e:
error = f"Booked events: {e}"
try:
client = _client()
result_all = await client.list_account_transaction_events(user_token, page_size=10)
except Exception as e:
error = (error or "") + f" | All events: {e}"
return templates.TemplateResponse("step6.html", {
"request": request,
"step": 6,
"title": "Events",
"subtitle": "Real-time Event Feed",
"error": error,
"result_booked": result_booked,
"result_all": result_all,
"curl_booked": curl_booked,
"curl_all": curl_all,
"is_demo": is_demo,
"app_base_url": s.app_base_url,
"next_step": None,
"prev_step": 5,
})
# ---------------------------------------------------------------------------
# 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"}

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

@@ -0,0 +1,94 @@
<!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>
<a href="/" class="text-sm text-slate-400 hover:text-white transition">← Restart demo</a>
</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); }
});
}
</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 %}

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

@@ -0,0 +1,242 @@
{% 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 }}</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: console.tink.com/callback (always works, no redirect URI registration needed) -->
<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">Åbn Tink Link, forbind Demo Bank, kopier koden og indsæt herunder.</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>
Sådan forbinder du Demo Bank
</p>
<ol class="text-slate-400 space-y-1.5 list-decimal list-inside leading-relaxed">
<li>Hent credentials fra <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>Klik <strong class="text-white">"Åbn Tink Link"</strong> nedenfor (åbner i ny fane)</li>
<li>Vælg <span class="text-slate-300">Tink Demo Bank</span><span class="text-slate-300">Open Banking</span><span class="text-slate-300">Password And OTP</span></li>
<li>Indtast username + password → OTP-koden vises på siden</li>
<li>Vælg en konto → Continue</li>
<li>Du lander på <span class="font-mono text-amber-300 text-xs">console.tink.com/callback<strong>?code=XXXX</strong></span> — kopier koden</li>
<li>Indsæt koden i feltet herunder og klik "Brug kode"</li>
</ol>
</div>
<div class="flex items-center gap-3">
<a href="{{ dev_tink_link_url }}" target="_blank"
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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
<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>
<!-- Code paste form -->
<form method="POST" action="/demo/step/3" class="flex gap-2 items-stretch pt-1 border-t border-slate-800">
<input type="text" name="code" placeholder="Indsæt code=XXXX her efter Tink Link flow..."
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>
<!-- SECONDARY: direct callback (only works when redirect URI is registered) -->
{% if tink_link_url %}
<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>
Direkte callback (kræver registreret redirect URI i Console)
<span class="ml-auto"></span>
</summary>
<div class="px-5 pb-4 pt-3 border-t border-slate-800">
<p class="text-slate-500 text-xs mb-3">Virker kun når <code class="text-slate-400">{{ tink_link_url | truncate(60) }}</code> er registreret som redirect URI i Tink Console.</p>
<a href="{{ tink_link_url }}"
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 direkte 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>
</div>
</details>
{% endif %}
{% 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 %}

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

@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block title %} — Step 6: 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", "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 == 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">Events v2</h2>
<p class="text-slate-400 text-sm">Real-time Event Feed</p>
</div>
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-violet-900/50 text-violet-300 border border-violet-700/40">v2 ✦</span>
</div>
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
Tink Events v2 giver real-time notifikationer når transaktioner bogføres.
Kombineret med webhooks kan din applikation reagere på bankbevægelser øjeblikkeligt.
</p>
</div>
{% if error %}
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5 mb-6">
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
</div>
{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Booked transactions events -->
<div class="space-y-3">
<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">Bogførte transaktioner</span>
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /events/v2/account-booked-transactions</code>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-900/50 text-violet-300 border border-violet-700/40 font-mono">v2</span>
</div>
</div>
<!-- curl -->
<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-booked')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
</div>
<pre id="curl-booked" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_booked }}</pre>
</div>
<!-- response -->
<div class="p-4 max-h-96 overflow-y-auto">
{% if result_booked %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ result_booked | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Ingen data — tilslut bank i Step 3 først.</p>
{% endif %}
</div>
</div>
</div>
<!-- All transaction events -->
<div class="space-y-3">
<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">Alle transaktionshændelser</span>
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /events/v2/account-transactions</code>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-900/50 text-violet-300 border border-violet-700/40 font-mono">v2</span>
</div>
</div>
<!-- curl -->
<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-all')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
</div>
<pre id="curl-all" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_all }}</pre>
</div>
<!-- response -->
<div class="p-4 max-h-96 overflow-y-auto">
{% if result_all %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ result_all | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Ingen data — tilslut bank i Step 3 først.</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Webhook info box -->
<div class="mt-6 bg-slate-900 border border-slate-800 rounded-xl p-5">
<h3 class="text-white font-semibold mb-2 flex items-center gap-2">
<svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
Webhooks
</h3>
<p class="text-slate-400 text-sm mb-3">
Konfigurer en webhook i Tink Console til at poste events til <code class="text-violet-300">/webhooks/tink</code> på dette endpoint.
Events sendes i real-time når transaktioner opdateres.
</p>
<div class="bg-slate-950 rounded-lg p-3">
<code class="text-xs text-emerald-400 font-mono">POST {{ app_base_url }}/webhooks/tink</code>
</div>
</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">
<h3 class="text-2xl font-bold text-white mb-2">Det var hele flowet 🎉</h3>
<p class="text-slate-400 mb-6 max-w-lg mx-auto">
Fra brugeroprettelse til live transaktioner og events — alt via Tink v2 API.
Klar til at integrere i MoneyCapp.
</p>
<div class="flex gap-3 justify-center">
<a href="/" 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" 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

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

@@ -0,0 +1,261 @@
"""
Tink API client — async httpx wrapper covering auth, users,
accounts (v2), transactions (v2), events (v2), and bank connectivity.
"""
import json
import httpx
from typing import Optional
from dataclasses import dataclass, field
@dataclass
class TinkTokens:
app_token: str = ""
user_token: str = ""
user_id: str = ""
external_user_id: str = ""
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"):
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("/")
# -------------------------------------------------------------------------
# Authentication
# -------------------------------------------------------------------------
async def get_app_token(self, scope: str = "user:create") -> dict:
"""Client credentials flow — returns app-level token."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/oauth/token",
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials",
"scope": scope,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
return resp.json()
async def exchange_code_for_token(self, code: str,
redirect_uri: str | None = None) -> dict:
"""Authorization code → user-level access token.
Pass redirect_uri only when it differs from self.redirect_uri
(e.g. dev flow via console.tink.com/callback).
"""
data: dict = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "authorization_code",
"code": code,
}
if redirect_uri:
data["redirect_uri"] = redirect_uri
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/oauth/token",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
return resp.json()
# -------------------------------------------------------------------------
# Users (v1 — only version available)
# -------------------------------------------------------------------------
async def create_user(self, app_token: str, external_user_id: str,
market: str = "DK", locale: str = "da_DK") -> dict:
"""Create a Tink user. Returns user_id."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/user/create",
json={
"external_user_id": external_user_id,
"market": market,
"locale": locale,
},
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()
async def get_user(self, user_token: str) -> dict:
"""Get current user info."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/api/v1/user",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
async def get_authorization_grant_token(self, app_token: str, user_id: str,
scope: str) -> dict:
"""Get a delegate authorization grant for a specific user (form-encoded)."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
data={
"actor_client_id": self.client_id,
"user_id": user_id,
"scope": scope,
},
headers={
"Authorization": f"Bearer {app_token}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
resp.raise_for_status()
return resp.json()
# -------------------------------------------------------------------------
# Tink Link — bank connection URL
# -------------------------------------------------------------------------
def get_tink_link_url(self, market: str = "DK",
authorization_code: str | None = None,
redirect_uri_override: str | None = None) -> str:
"""Build the Tink Link URL for bank connection.
Uses simplified flow (no authorization_code) by default,
matching the Tink Console demo URL pattern.
Use redirect_uri_override to swap in a different callback URL
(e.g. https://console.tink.com/callback for local dev).
"""
from urllib.parse import urlencode
params: dict = {
"client_id": self.client_id,
"redirect_uri": redirect_uri_override or self.redirect_uri,
"market": market,
"locale": "da_DK",
"scope": "accounts:read,transactions:read,credentials:read",
}
if authorization_code:
params["authorization_code"] = authorization_code
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
# -------------------------------------------------------------------------
# Credentials (bank connections) — v1
# -------------------------------------------------------------------------
async def list_credentials(self, user_token: str) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/api/v1/credentials/list",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
# -------------------------------------------------------------------------
# Accounts — v2
# -------------------------------------------------------------------------
async def list_accounts(self, user_token: str, page_size: int = 50,
page_token: Optional[str] = None) -> dict:
"""GET /data/v2/accounts — new v2 endpoint."""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/accounts",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
async def get_account(self, user_token: str, account_id: str) -> dict:
"""GET /data/v2/accounts/{id}"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/accounts/{account_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
# -------------------------------------------------------------------------
# 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:
"""GET /data/v2/transactions — new v2 endpoint."""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
if account_id:
params["accountIdIn"] = account_id
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/transactions",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
async def get_transaction(self, user_token: str, transaction_id: str) -> dict:
"""GET /data/v2/transactions/{id}"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/data/v2/transactions/{transaction_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
# -------------------------------------------------------------------------
# Events — v2
# -------------------------------------------------------------------------
async def list_account_transaction_events(
self, user_token: str,
page_size: int = 25,
page_token: Optional[str] = None,
) -> dict:
"""GET /events/v2/account-transactions"""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/events/v2/account-transactions",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()
async def list_booked_transaction_events(
self, user_token: str,
page_size: int = 25,
page_token: Optional[str] = None,
) -> dict:
"""GET /events/v2/account-booked-transactions"""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/events/v2/account-booked-transactions",
params=params,
headers={"Authorization": f"Bearer {user_token}"},
)
resp.raise_for_status()
return resp.json()