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..95c0c40 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cc8c46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +.pytest_cache/ +.mypy_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f195754 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3d3bdc6 --- /dev/null +++ b/Makefile @@ -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__ 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..460fc31 --- /dev/null +++ b/moneycapp-tink-demo.nomad @@ -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 = < 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..ea09d2f --- /dev/null +++ b/src/routes/demo.py @@ -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"} diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..7731b23 --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,94 @@ + + + + + + Tink API Demo {% block title %}{% endblock %} + + + + + + + + + + {% block stepper %}{% endblock %} + + +
+ {% block content %}{% endblock %} +
+ + + + + + + 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/step.html b/src/templates/step.html new file mode 100644 index 0000000..35a7951 --- /dev/null +++ b/src/templates/step.html @@ -0,0 +1,242 @@ +{% 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 }}

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

+

Åbn Tink Link, forbind Demo Bank, kopier koden og indsæt herunder.

+
+
+ + +
+

+ + Sådan forbinder du Demo Bank +

+
    +
  1. Hent credentials fra Console → Demo Bank → Transactions → DK
  2. +
  3. Klik "Åbn Tink Link" nedenfor (åbner i ny fane)
  4. +
  5. Vælg Tink Demo BankOpen BankingPassword And OTP
  6. +
  7. Indtast username + password → OTP-koden vises på siden
  8. +
  9. Vælg en konto → Continue
  10. +
  11. Du lander på console.tink.com/callback?code=XXXX — kopier koden
  12. +
  13. Indsæt koden i feltet herunder og klik "Brug kode"
  14. +
+
+ + + + +
+ + +
+
+ + + {% if tink_link_url %} +
+ + + Direkte callback (kræver registreret redirect URI i Console) + + +
+

Virker kun når {{ tink_link_url | truncate(60) }} er registreret som redirect URI i Tink Console.

+ + Åbn med direkte callback + + +
+
+ {% endif %} + + {% 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/step6.html b/src/templates/step6.html new file mode 100644 index 0000000..1de8b3f --- /dev/null +++ b/src/templates/step6.html @@ -0,0 +1,160 @@ +{% extends "base.html" %} +{% block title %} — Step 6: Events{% 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 %} +
+
+ 6 +
+

Events v2

+

Real-time Event Feed

+
+ v2 ✦ +
+

+ Tink Events v2 giver real-time notifikationer når transaktioner bogføres. + Kombineret med webhooks kan din applikation reagere på bankbevægelser øjeblikkeligt. +

+
+ +{% if error %} +
+
{{ error }}
+
+{% endif %} + +
+ + +
+
+
+
+
+ Bogførte transaktioner + GET /events/v2/account-booked-transactions +
+ v2 +
+
+ +
+
+ cURL + +
+
{{ curl_booked }}
+
+ +
+ {% if result_booked %} + {% if is_demo %}

⚠ Sample Data

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

Ingen data — tilslut bank i Step 3 først.

+ {% endif %} +
+
+
+ + +
+
+
+
+
+ Alle transaktionshændelser + GET /events/v2/account-transactions +
+ v2 +
+
+ +
+
+ cURL + +
+
{{ curl_all }}
+
+ +
+ {% if result_all %} + {% if is_demo %}

⚠ Sample Data

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

Ingen data — tilslut bank i Step 3 først.

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

+ + Webhooks +

+

+ Konfigurer en webhook i Tink Console til at poste events til /webhooks/tink på dette endpoint. + Events sendes i real-time når transaktioner opdateres. +

+
+ POST {{ app_base_url }}/webhooks/tink +
+
+ + +
+

Det var hele flowet 🎉

+

+ Fra brugeroprettelse til live transaktioner og events — alt via Tink v2 API. + Klar til at integrere i MoneyCapp. +

+ +
+ + +
+ + ← Step 5 + +
+{% 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..dcd0c22 --- /dev/null +++ b/src/tink/client.py @@ -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()