From ab591be46499cb4bdd83119ce184c92db6589f06 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sat, 23 May 2026 02:08:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Tink=20open=20banking=20demo=20?= =?UTF-8?q?=E2=80=94=206-step=20API=20walkthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .env.example | 18 + .gitea/workflows/deploy.yml | 55 +++ .gitignore | 11 + Dockerfile | 13 + Makefile | 50 +++ README.md | 49 +++ docker-compose.yml | 10 + moneycapp-tink-demo.nomad | 41 +++ requirements.txt | 7 + src/__init__.py | 0 src/config.py | 25 ++ src/demo_data.py | 228 +++++++++++++ src/main.py | 28 ++ src/routes/__init__.py | 0 src/routes/demo.py | 644 ++++++++++++++++++++++++++++++++++++ src/templates/base.html | 119 +++++++ src/templates/index.html | 57 ++++ src/templates/log.html | 99 ++++++ src/templates/step.html | 335 +++++++++++++++++++ src/templates/step2.html | 164 +++++++++ src/templates/step6.html | 245 ++++++++++++++ src/tink/__init__.py | 0 src/tink/client.py | 279 ++++++++++++++++ 23 files changed, 2477 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 moneycapp-tink-demo.nomad create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/config.py create mode 100644 src/demo_data.py create mode 100644 src/main.py create mode 100644 src/routes/__init__.py create mode 100644 src/routes/demo.py create mode 100644 src/templates/base.html create mode 100644 src/templates/index.html create mode 100644 src/templates/log.html create mode 100644 src/templates/step.html create mode 100644 src/templates/step2.html create mode 100644 src/templates/step6.html create mode 100644 src/tink/__init__.py create mode 100644 src/tink/client.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c3c51b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..18b5c66 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,55 @@ +name: Build and Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +env: + SERVICE_NAME: moneycapp-tink-demo + IMAGE: registry.i80.dk/gitea/moneycapp-tink-demo + +jobs: + deploy: + runs-on: debian-host + + env: + PATH: /usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/bin:/snap/bin + NOMAD_ADDR: "https://nomad.i80.dk:4646" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Registry + run: | + echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | docker login registry.i80.dk -u "robot\$gitserver" --password-stdin + + - name: Write production env + run: | + cat > .env.production << 'ENVEOF' + TINK_CLIENT_ID=${{ secrets.TINK_CLIENT_ID }} + TINK_CLIENT_SECRET=${{ secrets.TINK_CLIENT_SECRET }} + TINK_REDIRECT_URI=https://tink-demo.i80.dk/callback + APP_BASE_URL=https://tink-demo.i80.dk + DEMO_MODE=false + ENVEOF + # Strip leading spaces + sed -i 's/^[[:space:]]*//' .env.production + + - name: Build and push image + run: | + docker build -t ${IMAGE}:latest . + docker push ${IMAGE}:latest + + - name: Validate Nomad job + run: nomad job validate ${SERVICE_NAME}.nomad + + - name: Deploy to Nomad + run: nomad job run ${SERVICE_NAME}.nomad + + - name: Health check + run: | + sleep 15 + curl -sf https://tink-demo.i80.dk/ || echo "Not yet reachable via Traefik" + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89718bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +.pytest_cache/ +.mypy_cache/ +.env.production diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a36183 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim AS base + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ src/ +COPY .env.production .env + +EXPOSE 8000 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fcd0ff2 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +.PHONY: install run dev docker-build docker-up docker-down push deploy logs vault-setup test clean + +REGISTRY = registry.i80.dk +IMAGE = $(REGISTRY)/moneycapp-tink-demo + +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 + +# --- Deploy to i80.dk --- + +push: + docker build --platform linux/amd64 -t $(IMAGE):latest . + docker push $(IMAGE):latest + @echo "✓ Image pushed to $(IMAGE):latest" + +vault-setup: + @echo "Storing Tink credentials in Vault..." + vault kv put secret/moneycapp-tink-demo \ + client_id=$(TINK_CLIENT_ID) \ + client_secret=$(TINK_CLIENT_SECRET) \ + session_secret=$$(openssl rand -hex 32) + @echo "✓ Vault secret stored at secret/moneycapp-tink-demo" + +deploy: push + scp moneycapp-tink-demo.nomad autobox.i80.dk:/tmp/ + ssh autobox.i80.dk 'export NOMAD_ADDR=https://nomad.i80.dk:4646 && nomad job run /tmp/moneycapp-tink-demo.nomad' + @echo "✓ Deployed — https://tink-demo.i80.dk" + +logs: + ssh autobox.i80.dk 'export NOMAD_ADDR=https://nomad.i80.dk:4646 && nomad alloc logs -job moneycapp-tink-demo' + +clean: + rm -rf .venv __pycache__ src/__pycache__ src/**/__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a430f60 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# MoneyCapp × Tink API Demo + +"Sales-y Swagger" — step-for-step gennemgang af Tink integrationsflowet med live JSON responses. + +## Hvad det er + +En hosted demo-app der viser hele Tink onboarding-flowet: + +| Step | Endpoint | Version | +|------|----------|---------| +| 1 | POST `/api/v1/oauth/token` — Client Credentials | v1 | +| 2 | POST `/api/v1/user/create` — Opret bruger | v1 | +| 3 | Tink Link redirect — Tilslut bank | Link v1 | +| 4 | GET `/data/v2/accounts` — Konti med balances | **v2** | +| 5 | GET `/data/v2/transactions` — Transaktioner | **v2** | +| 6 | GET `/events/v2/account-transactions` + webhooks | **v2** | + +## Quick start (lokal dev) + +```bash +cp .env.example .env +# Udfyld TINK_CLIENT_ID og TINK_CLIENT_SECRET fra Tink Console +# Tilføj http://localhost:8000/callback som Redirect URI i Tink Console + +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +uvicorn src.main:app --reload +# Åbn http://localhost:8000 +``` + +## Docker + +```bash +docker compose up +``` + +## Deploy til i80/Nomad + +1. Konfigurer Gitea secrets: `REGISTRY_USER`, `REGISTRY_TOKEN`, `NOMAD_ADDR`, `NOMAD_TOKEN` +2. Læg Tink credentials i Nomad/Vault: `secret/moneycapp-tink-demo` +3. Tilføj `https://tink-demo.i80.dk/callback` som Redirect URI i Tink Console +4. Push til `main` → Gitea Actions bygger og deployer + +## Tink Console setup + +1. Gå til [console.tink.com](https://console.tink.com) +2. Opret en app → kopiér Client ID + Secret til `.env` +3. Under **Redirect URIs**: tilføj din callback URL +4. Under **Scopes**: aktiver `accounts:read`, `transactions:read`, `credentials:read/write`, `user:create` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8ff86db --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/moneycapp-tink-demo.nomad b/moneycapp-tink-demo.nomad new file mode 100644 index 0000000..7168b10 --- /dev/null +++ b/moneycapp-tink-demo.nomad @@ -0,0 +1,41 @@ +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`)", + "traefik.http.routers.tink-demo.tls=true", + "traefik.http.routers.tink-demo.tls.certresolver=le"] + check { + type = "http" + path = "/" + interval = "30s" + timeout = "5s" + } + } + + task "app" { + driver = "docker" + + config { + image = "registry.i80.dk/gitea/moneycapp-tink-demo:latest" + ports = ["http"] + } + + resources { + cpu = 256 + memory = 256 + } + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ca30779 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..590b06c --- /dev/null +++ b/src/config.py @@ -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() diff --git a/src/demo_data.py b/src/demo_data.py new file mode 100644 index 0000000..8a87146 --- /dev/null +++ b/src/demo_data.py @@ -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": "", +} diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..d641bb4 --- /dev/null +++ b/src/main.py @@ -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") diff --git a/src/routes/__init__.py b/src/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/demo.py b/src/routes/demo.py new file mode 100644 index 0000000..1f999ff --- /dev/null +++ b/src/routes/demo.py @@ -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 tink_external_ref = {external_user_id} — MoneyCapp's reference til kunden i Tink. Tink returnerer et user_id som bruges i næste kald.", + "curl_example": curl_example, + "result": result, + "error": error, + "next_step": 3, + "prev_step": 1, + })) + + +# --------------------------------------------------------------------------- +# 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= 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 {external_user_id} — " + f"returnerede code som vist nedenfor. " + f"I produktion sendes denne code med i Tink Link URL som authorization_code." + ) + elif user_id: + description = ( + "Åbn Tink Link → vælg Tink Demo Bank → log ind med testbruger. " + "Klik Vis testbrugere 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"} diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..7359b07 --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,119 @@ + + + + + + Tink API Demo {% block title %}{% endblock %} + + + + + + + + + + {% block stepper %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ + +
+ Tink API Demo — MoneyCapp sales prototype — i80.dk +
+ + + + diff --git a/src/templates/index.html b/src/templates/index.html new file mode 100644 index 0000000..f42b98d --- /dev/null +++ b/src/templates/index.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} + +{% block title %} — Start{% endblock %} + +{% block content %} +
+ + +
+
+ + Live demo · Tink Sandbox +
+

+ Tink Open Banking
+ API Demo +

+

+ Step-for-step gennemgang af hele Tink integrationsflowet — + fra brugeroprettelse til live transaktioner og events. +

+

+ Bruger Tink v2 endpoints for accounts, transactions og events. +

+
+ + +
+ {% 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 %} +
+
+ {{ num }} + {{ version }} +
+
{{ name }}
+
{{ endpoint }}
+
+ {% endfor %} +
+ + + + Start Demo + + + +
+{% endblock %} diff --git a/src/templates/log.html b/src/templates/log.html new file mode 100644 index 0000000..1eee0ef --- /dev/null +++ b/src/templates/log.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}API Log — Tink Demo{% endblock %} + +{% block content %} +
+ +
+
+

📋 API Request Log

+

Alle Tink API-kald i denne session

+
+
+ {{ log_count }} kald registreret + {% if log_count > 0 %} +
+ +
+ {% endif %} + + ← Tilbage til demo + +
+
+ + {% if log_count == 0 %} +
+
📭
+

Ingen API-kald endnu.

+

Gå igennem demo-steppene for at se kaldene her.

+ + Start fra Step 1 + +
+ {% else %} + +
+ {% for entry in log %} +
+ + {# Header row #} + + + {# Collapsible body — hidden by default #} + +
+ {% endfor %} +
+ + {% endif %} +
+{% endblock %} diff --git a/src/templates/step.html b/src/templates/step.html new file mode 100644 index 0000000..a08ef94 --- /dev/null +++ b/src/templates/step.html @@ -0,0 +1,335 @@ +{% extends "base.html" %} +{% block title %} — Step {{ step }}{% endblock %} + +{% block stepper %} +
+
+
+ {% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Events"] %} + {% for i in range(1, 7) %} + + {{ i }} + {{ step_names[i-1] }} + + {% if i < 6 %} + + {% endif %} + {% endfor %} +
+
+
+{% endblock %} + +{% block content %} +
+ + +
+ + +
+
+ {{ step }} +
+

{{ title }}

+

{{ subtitle }}

+
+
+
+ + +
+
+ Endpoint + + {{ api_version }} + +
+ {{ endpoint }} +
+ + +
+

{{ description | safe }}

+
+ + +
+
+ cURL eksempel + +
+
{{ curl_example }}
+
+ + +
+ {% if prev_step %} + + ← Step {{ prev_step }} + + {% endif %} + {% if next_step %} + + Step {{ next_step }} → + + {% endif %} +
+
+ + +
+ + + {% if tink_link_url %} + + {% if cb_success %} + +
+
+ +
+
+

Bank forbundet!

+

User token gemt i session. Trin 4–6 er klar.

+ + + Start forfra + +
+
+ + {% else %} + + + +
+
+
+ +
+
+

Tilslut testbank

+

Klik knappen, vælg Demo Bank og log ind — du redirectes automatisk tilbage.

+
+
+ + +
+

+ + Trin i Tink Link +

+
    +
  1. Vælg Tink Demo Bank
  2. +
  3. Vælg Open BankingPassword And OTP
  4. +
  5. Hent credentials: Console → Demo Bank → Transactions → DK
  6. +
  7. Indtast username + password → OTP vises på siden → Continue
  8. +
  9. Vælg en konto → du redirectes automatisk tilbage her ✓
  10. +
+
+ + +
+
+ + Demo Bank Credentials +
+
+
+
Username
+
+ u04877810 + +
+
+
+
Password
+
+ vxw774 + +
+
+
+

Vælg Tink Demo Bank → Open Banking → Password And OTP

+
+ +
+ + Åbn Tink Link + + + {% if demo_bank_users %} + + {% endif %} + + + Start forfra + +
+ + {% if demo_bank_users %} + + + {% endif %} +
+ + +
+ + + Alternativ: manuel kode-indsætning via console.tink.com + + +
+ + Åbn med console callback + + +
+ + +
+
+
+ + {% endif %}{# end not cb_success #} + + {% endif %}{# end tink_link_url #} + + + {% if error %} +
+
+ +
+

Fejl

+
{{ error }}
+
+
+
+ {% endif %} + + + {% if result %} +
+
+
+ + Response + 200 OK + {% if is_demo %} + ⚠ Sample Data + {% endif %} +
+ +
+
+
{{ result | tojson(indent=2) }}
+
+
+ {% elif not error %} + +
+
+ +
+

Klik på knappen ovenfor for at køre dette API-kald og se svaret her.

+
+ {% endif %} + +
+
+{% endblock %} diff --git a/src/templates/step2.html b/src/templates/step2.html new file mode 100644 index 0000000..a22d23a --- /dev/null +++ b/src/templates/step2.html @@ -0,0 +1,164 @@ +{% extends "base.html" %} +{% block title %} — Step 2: Opret Bruger{% endblock %} + +{% block stepper %} +
+
+
+ {% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %} + {% for i in range(1, 7) %} + + {{ i }} + {{ step_names[i-1] }} + + {% if i < 6 %} + + {% endif %} + {% endfor %} +
+
+
+{% endblock %} + +{% block content %} +
+
+ 2 +
+

Opret Bruger

+

Create Test Customer

+
+ v1 +
+

+ Opret en Tink-bruger med en tink_external_ref — + MoneyCapp's interne reference til kunden i Tink. Gemmes i jeres kundedatabase som tink_external_ref (ikke jeres interne customer_id). + Tink returnerer et user_id som bruges i efterfølgende kald. +

+
+ +{% if error %} +
+
{{ error }}
+
+{% endif %} + +{% if existing_user_id %} + +
+
+ +
+

Bruger allerede oprettet i denne session

+

Du kan fortsætte til Step 3, eller oprette en ny bruger nedenfor.

+
+
+
+
+

external_user_id

+ {{ existing_external_id }} +
+
+

Tink user_id

+ {{ existing_user_id }} +
+
+ +
+
+ Opret en ny bruger i stedet +
+{% else %} +
+{% endif %} + + +
+
+

Hvem opretter vi?

+

+ tink_external_ref = jeres reference til kunden i Tink — + adskilt fra jeres interne customer_id +

+
+
+
+ + +

tink_external_ref = moneycapp-henrik-jess-a3f9c1

+
+
+ + +
+ +
+
+ +{% if existing_user_id %} +
+
+{% else %} + +{% endif %} + + +
+

API endpoint

+ POST https://api.tink.com/api/v1/user/create +
+
# 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-<ref>",  ← tink_external_ref
+  "market": "DK",
+  "locale": "da_DK"
+}
+
+# Response
+{
+  "user_id": "abc123..."  ← Tinks interne ID, gem og brug fremadrettet
+}
+
+
+ + + +{% endblock %} diff --git a/src/templates/step6.html b/src/templates/step6.html new file mode 100644 index 0000000..78f7abd --- /dev/null +++ b/src/templates/step6.html @@ -0,0 +1,245 @@ +{% extends "base.html" %} +{% block title %} — Step 6: Webhooks & Events{% endblock %} + +{% block stepper %} +
+
+
+ {% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %} + {% for i in range(1, 7) %} + + {{ i }} + {{ step_names[i-1] }} + + {% if i < 6 %} + + {% endif %} + {% endfor %} +
+
+
+{% endblock %} + +{% block content %} +
+
+ 6 +
+

Webhooks & Real-time Events

+

Push-notifikationer til din backend

+
+
+

+ 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. +

+
+ +{% if error %} +
+

Webhook API fejl

+
{{ error }}
+
+{% endif %} + + +
+

Hvordan webhooks virker

+
+
+ Din app + Brugeren kobler bank +
+ +
+ Tink + Henter transaktioner +
+ +
+ POST /webhooks/tink + Dit endpoint modtager event +
+ +
+ Din app + Opdaterer UI / notifikation +
+
+
+ +
+ + +
+
+
+
+ Registrerede webhooks + GET /api/v1/webhooks +
+ app token +
+
+
+
+ cURL + +
+
{{ curl_list }}
+
+
+ {% if result_webhooks %} + {% if is_demo %}

⚠ Sample Data

{% endif %} +
{{ result_webhooks | tojson(indent=2) }}
+ {% else %} +

Ingen webhooks registreret endnu.

+ {% endif %} +
+
+ + +
+
+
+
+ Registrér webhook endpoint + POST /api/v1/webhooks +
+ registreret ✓ +
+
+
+
+ cURL + +
+
{{ curl_register }}
+
+
+ {% if webhook_registered %} + {% if is_demo %}

⚠ Sample Data

{% endif %} +
{{ webhook_registered | tojson(indent=2) }}
+ {% else %} +

Webhook ikke registreret.

+ {% endif %} +
+
+ +
+ + +
+
+
+
+ Sample Webhook Payload +

Sådan ser en event ud når Tink poster til dit endpoint

+
+ incoming POST +
+
+
+
{
+  "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" }
+  }
+}
+
+
+ + +
+
+
+ + + +
+
+

Dit webhook modtager endpoint er live

+ POST {{ app_base_url }}/webhooks/tink +
+
+
+ Se receiver kode +
+
@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"}
+
+
+
+ + +
+
🎉
+

Det var hele flowet

+

+ Fra brugeroprettelse over live bankdata til real-time webhooks — alt via Tink API. +

+

+ 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. +

+ +
+ + + +{% endblock %} diff --git a/src/tink/__init__.py b/src/tink/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tink/client.py b/src/tink/client.py new file mode 100644 index 0000000..17a71c4 --- /dev/null +++ b/src/tink/client.py @@ -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", + ], + }, + )