First attempt to tink demo
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Tink API credentials — get these from https://console.tink.com
|
||||
TINK_CLIENT_ID=your_client_id_here
|
||||
TINK_CLIENT_SECRET=your_client_secret_here
|
||||
|
||||
# Must match what you configure in Tink Console under Redirect URIs
|
||||
# Local dev: http://localhost:8000/callback
|
||||
# Production: https://tink-demo.i80.dk/callback
|
||||
TINK_REDIRECT_URI=http://localhost:8000/callback
|
||||
|
||||
# Base URL for this app (used in webhook registration etc.)
|
||||
APP_BASE_URL=http://localhost:8000
|
||||
|
||||
# Secret for signing session cookies (generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
||||
SESSION_SECRET=change_me_to_a_random_secret
|
||||
|
||||
# Tink API base URL (sandbox = production, use test credentials from Tink Console)
|
||||
TINK_API_BASE=https://api.tink.com
|
||||
TINK_LINK_BASE=https://link.tink.com
|
||||
34
.gitea/workflows/deploy.yml
Normal file
34
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to i80 registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.i80.dk
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: registry.i80.dk/moneycapp-tink-demo:latest
|
||||
|
||||
- name: Deploy to Nomad
|
||||
env:
|
||||
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
|
||||
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
|
||||
run: |
|
||||
curl -fsSL https://releases.hashicorp.com/nomad/1.8.0/nomad_1.8.0_linux_amd64.zip -o nomad.zip
|
||||
unzip -q nomad.zip && chmod +x nomad
|
||||
./nomad job run moneycapp-tink-demo.nomad
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY src/ src/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -0,0 +1,24 @@
|
||||
.PHONY: install run dev docker-build docker-up docker-down test clean
|
||||
|
||||
install:
|
||||
python3 -m venv .venv && .venv/bin/pip install -q -r requirements.txt
|
||||
cp -n .env.example .env || true
|
||||
@echo "✓ Done — edit .env with your Tink credentials"
|
||||
|
||||
run:
|
||||
.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
dev:
|
||||
.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
docker-build:
|
||||
docker build -t moneycapp-tink-demo .
|
||||
|
||||
docker-up:
|
||||
docker compose up
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
clean:
|
||||
rm -rf .venv __pycache__ src/__pycache__ src/**/__pycache__
|
||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
demo:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./src:/app/src # hot reload mount
|
||||
command: uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
58
moneycapp-tink-demo.nomad
Normal file
58
moneycapp-tink-demo.nomad
Normal file
@@ -0,0 +1,58 @@
|
||||
job "moneycapp-tink-demo" {
|
||||
datacenters = ["dc1"]
|
||||
type = "service"
|
||||
|
||||
group "demo" {
|
||||
count = 1
|
||||
|
||||
network {
|
||||
port "http" { to = 8000 }
|
||||
}
|
||||
|
||||
service {
|
||||
name = "moneycapp-tink-demo"
|
||||
port = "http"
|
||||
tags = ["traefik.enable=true",
|
||||
"traefik.http.routers.tink-demo.rule=Host(`tink-demo.i80.dk`)"]
|
||||
check {
|
||||
type = "http"
|
||||
path = "/"
|
||||
interval = "30s"
|
||||
timeout = "5s"
|
||||
}
|
||||
}
|
||||
|
||||
task "app" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "registry.i80.dk/moneycapp-tink-demo:latest"
|
||||
ports = ["http"]
|
||||
}
|
||||
|
||||
env {
|
||||
TINK_REDIRECT_URI = "https://tink-demo.i80.dk/callback"
|
||||
APP_BASE_URL = "https://tink-demo.i80.dk"
|
||||
TINK_API_BASE = "https://api.tink.com"
|
||||
TINK_LINK_BASE = "https://link.tink.com"
|
||||
}
|
||||
|
||||
template {
|
||||
data = <<EOF
|
||||
{{ with secret "secret/moneycapp-tink-demo" }}
|
||||
TINK_CLIENT_ID={{ .Data.data.client_id }}
|
||||
TINK_CLIENT_SECRET={{ .Data.data.client_secret }}
|
||||
SESSION_SECRET={{ .Data.data.session_secret }}
|
||||
{{ end }}
|
||||
EOF
|
||||
destination = "secrets/env"
|
||||
env = true
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 256
|
||||
memory = 256
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
httpx==0.28.0
|
||||
python-dotenv==1.0.1
|
||||
jinja2==3.1.4
|
||||
itsdangerous==2.2.0
|
||||
python-multipart==0.0.17
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
25
src/config.py
Normal file
25
src/config.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
App configuration loaded from environment / .env file.
|
||||
"""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Settings:
|
||||
tink_client_id: str = os.environ["TINK_CLIENT_ID"]
|
||||
tink_client_secret: str = os.environ["TINK_CLIENT_SECRET"]
|
||||
tink_redirect_uri: str = os.getenv("TINK_REDIRECT_URI", "http://localhost:8000/callback")
|
||||
app_base_url: str = os.getenv("APP_BASE_URL", "http://localhost:8000")
|
||||
session_secret: str = os.getenv("SESSION_SECRET", "dev-only-change-in-prod")
|
||||
tink_api_base: str = os.getenv("TINK_API_BASE", "https://api.tink.com")
|
||||
tink_link_base: str = os.getenv("TINK_LINK_BASE", "https://link.tink.com")
|
||||
demo_mode: bool = os.getenv("DEMO_MODE", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
228
src/demo_data.py
Normal file
228
src/demo_data.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Realistic mock data for demo mode.
|
||||
|
||||
Used when DEMO_MODE=true or when no bank is connected.
|
||||
Data mimics real Tink API v2 responses for a Danish user.
|
||||
"""
|
||||
|
||||
MOCK_CREDENTIALS = {
|
||||
"credentials": [
|
||||
{
|
||||
"id": "dk-demobank-cred-001",
|
||||
"providerName": "dk-demobank-open-banking-embedded",
|
||||
"type": "PASSWORD",
|
||||
"status": "UPDATED",
|
||||
"statusUpdated": "2026-05-22T08:00:00Z",
|
||||
"updated": "2026-05-22T08:00:00Z",
|
||||
"fields": {"username": "demo_user_001"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
MOCK_ACCOUNTS = {
|
||||
"accounts": [
|
||||
{
|
||||
"id": "acc-8f3a2e1b-demo",
|
||||
"name": "Løbende konto",
|
||||
"type": "CHECKING",
|
||||
"balances": {
|
||||
"booked": {
|
||||
"amount": {"value": {"unscaledValue": "4275850", "scale": "2"}, "currencyCode": "DKK"},
|
||||
},
|
||||
"available": {
|
||||
"amount": {"value": {"unscaledValue": "4275850", "scale": "2"}, "currencyCode": "DKK"},
|
||||
},
|
||||
},
|
||||
"identifiers": {
|
||||
"iban": {"iban": "DK5000400440116243"},
|
||||
"financialInstitution": {"accountNumber": "0440116243"},
|
||||
},
|
||||
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
|
||||
"financialInstitutionId": "dk-demobank",
|
||||
},
|
||||
{
|
||||
"id": "acc-9c4d3f2a-demo",
|
||||
"name": "Opsparing",
|
||||
"type": "SAVINGS",
|
||||
"balances": {
|
||||
"booked": {
|
||||
"amount": {"value": {"unscaledValue": "18500000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
},
|
||||
},
|
||||
"identifiers": {
|
||||
"iban": {"iban": "DK5000400440116244"},
|
||||
"financialInstitution": {"accountNumber": "0440116244"},
|
||||
},
|
||||
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
|
||||
"financialInstitutionId": "dk-demobank",
|
||||
},
|
||||
],
|
||||
"nextPageToken": "",
|
||||
}
|
||||
|
||||
MOCK_TRANSACTIONS = {
|
||||
"transactions": [
|
||||
{
|
||||
"id": "tx-001-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-22", "value": "2026-05-22"},
|
||||
"description": "Spotify AB",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
||||
"merchantInformation": {"merchantName": "Spotify", "merchantCategoryCode": "7995"},
|
||||
"types": {"financialInstitutionTypeCode": "CSCD", "type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-002-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-21", "value": "2026-05-21"},
|
||||
"description": "Netto Supermarked",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
||||
"merchantInformation": {"merchantName": "Netto", "merchantCategoryCode": "5411"},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-003-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "3500000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-20", "value": "2026-05-20"},
|
||||
"description": "Løn maj 2026",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "income.salary", "name": "Løn"}},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-004-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-120000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-19", "value": "2026-05-19"},
|
||||
"description": "Matas Strøget",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}},
|
||||
"merchantInformation": {"merchantName": "Matas", "merchantCategoryCode": "5912"},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-005-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-8500000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-01", "value": "2026-05-01"},
|
||||
"description": "Husleje maj",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-006-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-35000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-18", "value": "2026-05-18"},
|
||||
"description": "DSB Rejse",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}},
|
||||
"merchantInformation": {"merchantName": "DSB", "merchantCategoryCode": "4112"},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-007-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-19900", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-17", "value": "2026-05-17"},
|
||||
"description": "Netflix International",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
||||
"merchantInformation": {"merchantName": "Netflix", "merchantCategoryCode": "7995"},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-008-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-62500", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-16", "value": "2026-05-16"},
|
||||
"description": "Fakta Falkoner",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
||||
"merchantInformation": {"merchantName": "Fakta", "merchantCategoryCode": "5411"},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
{
|
||||
"id": "tx-009-demo",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"dates": {"booked": "2026-05-23", "value": "2026-05-23"},
|
||||
"description": "7-Eleven Nørreport",
|
||||
"status": "PENDING",
|
||||
"categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}},
|
||||
"merchantInformation": {"merchantName": "7-Eleven", "merchantCategoryCode": "5499"},
|
||||
"types": {"type": "DEFAULT"},
|
||||
},
|
||||
],
|
||||
"nextPageToken": "",
|
||||
}
|
||||
|
||||
MOCK_EVENTS_BOOKED = {
|
||||
"events": [
|
||||
{
|
||||
"id": "evt-booked-001",
|
||||
"type": "account-booked-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-22T08:12:33Z",
|
||||
"transaction": {
|
||||
"id": "tx-001-demo",
|
||||
"description": "Spotify AB",
|
||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"status": "BOOKED",
|
||||
"dates": {"booked": "2026-05-22"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "evt-booked-002",
|
||||
"type": "account-booked-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-21T14:22:10Z",
|
||||
"transaction": {
|
||||
"id": "tx-002-demo",
|
||||
"description": "Netto Supermarked",
|
||||
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"status": "BOOKED",
|
||||
"dates": {"booked": "2026-05-21"},
|
||||
},
|
||||
},
|
||||
],
|
||||
"nextPageToken": "",
|
||||
}
|
||||
|
||||
MOCK_EVENTS_ALL = {
|
||||
"events": [
|
||||
{
|
||||
"id": "evt-pending-001",
|
||||
"type": "account-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-23T11:05:44Z",
|
||||
"transaction": {
|
||||
"id": "tx-009-demo",
|
||||
"description": "7-Eleven Nørreport",
|
||||
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"status": "PENDING",
|
||||
"dates": {"booked": "2026-05-23"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "evt-booked-001",
|
||||
"type": "account-booked-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-22T08:12:33Z",
|
||||
"transaction": {
|
||||
"id": "tx-001-demo",
|
||||
"description": "Spotify AB",
|
||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"status": "BOOKED",
|
||||
"dates": {"booked": "2026-05-22"},
|
||||
},
|
||||
},
|
||||
],
|
||||
"nextPageToken": "",
|
||||
}
|
||||
28
src/main.py
Normal file
28
src/main.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
FastAPI application entry point.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from src.config import get_settings
|
||||
from src.routes.demo import router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Tink API Demo",
|
||||
description="MoneyCapp × Tink — sales demo showing v2 API endpoints",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.session_secret,
|
||||
max_age=3600,
|
||||
)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="src/static"), name="static")
|
||||
0
src/routes/__init__.py
Normal file
0
src/routes/__init__.py
Normal file
415
src/routes/demo.py
Normal file
415
src/routes/demo.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Demo routes — each /demo/step/N page shows one Tink API call
|
||||
with the live JSON response and a curl example.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Form, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from src.tink.client import TinkClient
|
||||
from src.config import get_settings
|
||||
from src import demo_data
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="src/templates")
|
||||
|
||||
|
||||
def _client() -> TinkClient:
|
||||
s = get_settings()
|
||||
return TinkClient(
|
||||
client_id=s.tink_client_id,
|
||||
client_secret=s.tink_client_secret,
|
||||
redirect_uri=s.tink_redirect_uri,
|
||||
api_base=s.tink_api_base,
|
||||
link_base=s.tink_link_base,
|
||||
)
|
||||
|
||||
|
||||
def _session(request: Request) -> dict:
|
||||
return request.session.setdefault("demo", {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Landing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/demo/reset")
|
||||
async def reset_demo(request: Request):
|
||||
"""Clear all demo session state and restart from Step 1."""
|
||||
request.session.pop("demo", None)
|
||||
return RedirectResponse("/demo/step/1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1 — Authenticate (client credentials)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/demo/step/1", response_class=HTMLResponse)
|
||||
async def step1(request: Request):
|
||||
client = _client()
|
||||
s = get_settings()
|
||||
error = None
|
||||
result = None
|
||||
curl_example = (
|
||||
f"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
|
||||
f" -d 'client_id={s.tink_client_id}' \\\n"
|
||||
f" -d 'client_secret=***' \\\n"
|
||||
f" -d 'grant_type=client_credentials' \\\n"
|
||||
f" -d 'scope=user:create'"
|
||||
)
|
||||
try:
|
||||
result = await client.get_app_token(scope="user:create")
|
||||
_session(request)["app_token"] = result["access_token"]
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
return templates.TemplateResponse("step.html", {
|
||||
"request": request,
|
||||
"step": 1,
|
||||
"title": "Authenticate",
|
||||
"subtitle": "Client Credentials Flow",
|
||||
"endpoint": "POST /api/v1/oauth/token",
|
||||
"api_version": "v1",
|
||||
"description": "Vi starter med at hente et app-level access token via Client Credentials flow. Dette token bruges til at oprette brugere.",
|
||||
"curl_example": curl_example,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"next_step": 2,
|
||||
"prev_step": None,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2 — Create User
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/demo/step/2", response_class=HTMLResponse)
|
||||
async def step2_get(request: Request):
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
app_token = sess.get("app_token", "")
|
||||
external_user_id = f"moneycapp-demo-{secrets.token_hex(4)}"
|
||||
|
||||
body = json.dumps({"external_user_id": external_user_id, "market": "DK", "locale": "da_DK"}, indent=2)
|
||||
curl_example = (
|
||||
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
|
||||
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
||||
f" -H 'Content-Type: application/json' \\\n"
|
||||
f" -d '{body}'"
|
||||
)
|
||||
|
||||
error = None
|
||||
result = None
|
||||
if not app_token:
|
||||
error = "Mangler app token — gå tilbage til Step 1 og kør authentication først."
|
||||
else:
|
||||
try:
|
||||
client = _client()
|
||||
result = await client.create_user(app_token, external_user_id)
|
||||
sess["user_id"] = result.get("user_id", "")
|
||||
sess["external_user_id"] = external_user_id
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
return templates.TemplateResponse("step.html", {
|
||||
"request": request,
|
||||
"step": 2,
|
||||
"title": "Opret Bruger",
|
||||
"subtitle": "Create Test Customer",
|
||||
"endpoint": "POST /api/v1/user/create",
|
||||
"api_version": "v1",
|
||||
"description": "Vi opretter en ny Tink-bruger med et unikt external_user_id — det er dit interne kunde-ID. Tink returnerer et user_id som vi bruger i de næste kald.",
|
||||
"curl_example": curl_example,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"next_step": 3,
|
||||
"prev_step": 1,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3 — Connect Bank (Tink Link redirect)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CONSOLE_CALLBACK = "https://console.tink.com/callback"
|
||||
|
||||
|
||||
@router.get("/demo/step/3", response_class=HTMLResponse)
|
||||
async def step3_get(request: Request):
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
client = _client()
|
||||
|
||||
tink_link_url = client.get_tink_link_url(market="DK")
|
||||
dev_tink_link_url = client.get_tink_link_url(
|
||||
market="DK", redirect_uri_override=CONSOLE_CALLBACK
|
||||
)
|
||||
error = None
|
||||
credentials = None
|
||||
cb_success = request.query_params.get("cb_success")
|
||||
cb_error = request.query_params.get("cb_error")
|
||||
|
||||
# Demo mode: auto-mark as connected with mock data
|
||||
if s.demo_mode and not sess.get("user_token"):
|
||||
sess["user_token"] = "demo-mode-token"
|
||||
sess["demo_mode"] = True
|
||||
|
||||
# Check if already connected (returning from callback)
|
||||
user_token = sess.get("user_token", "")
|
||||
if user_token:
|
||||
if sess.get("demo_mode"):
|
||||
credentials = demo_data.MOCK_CREDENTIALS
|
||||
cb_success = cb_success or "demo"
|
||||
else:
|
||||
try:
|
||||
credentials = await client.list_credentials(user_token)
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
curl_example = (
|
||||
"# Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig\n"
|
||||
f"# Redirect brugeren direkte til:\n"
|
||||
f"https://link.tink.com/1.0/transactions/connect-accounts\n"
|
||||
f" ?client_id=$CLIENT_ID\n"
|
||||
f" &redirect_uri=$REDIRECT_URI\n"
|
||||
f" &market=DK\n"
|
||||
f" &scope=accounts:read,transactions:read,credentials:read\n\n"
|
||||
"# Tink Link redirecter tilbage med ?code=... som du exchangeer for user token:\n"
|
||||
"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
|
||||
" -d 'grant_type=authorization_code' \\\n"
|
||||
" -d 'client_id=$CLIENT_ID' \\\n"
|
||||
" -d 'client_secret=$CLIENT_SECRET' \\\n"
|
||||
" -d 'code=$CODE'"
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("step.html", {
|
||||
"request": request,
|
||||
"step": 3,
|
||||
"title": "Tilslut Bank",
|
||||
"subtitle": "Tink Link — Bank Connection",
|
||||
"endpoint": "Tink Link /1.0/transactions/connect-accounts",
|
||||
"api_version": "Link v1",
|
||||
"description": "Brugeren åbner Tink Link, vælger Tink Demo Bank og logger ind med test-credentials fra Console. Tink redirecter tilbage med en authorization_code som automatisk exchang'es til et user token.",
|
||||
"curl_example": curl_example,
|
||||
"result": credentials,
|
||||
"tink_link_url": tink_link_url,
|
||||
"dev_tink_link_url": dev_tink_link_url,
|
||||
"error": error or (f"Callback fejl: {cb_error}" if cb_error else None),
|
||||
"cb_success": cb_success,
|
||||
"next_step": 4,
|
||||
"prev_step": 2,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/demo/step/3", response_class=HTMLResponse)
|
||||
async def step3_post(request: Request, code: str = Form(...)):
|
||||
"""Manual code entry — exchange a code obtained via console.tink.com/callback."""
|
||||
sess = _session(request)
|
||||
try:
|
||||
client = _client()
|
||||
tokens = await client.exchange_code_for_token(
|
||||
code=code.strip(),
|
||||
redirect_uri=CONSOLE_CALLBACK,
|
||||
)
|
||||
sess["user_token"] = tokens.get("access_token", "")
|
||||
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||
except Exception as e:
|
||||
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/callback", response_class=HTMLResponse)
|
||||
async def tink_callback(request: Request, code: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
credentials_id: Optional[str] = None):
|
||||
"""Tink Link OAuth callback — exchange code for user token."""
|
||||
sess = _session(request)
|
||||
if error:
|
||||
return RedirectResponse(f"/demo/step/3?error={error}")
|
||||
if code:
|
||||
try:
|
||||
client = _client()
|
||||
tokens = await client.exchange_code_for_token(code)
|
||||
sess["user_token"] = tokens.get("access_token", "")
|
||||
except Exception as e:
|
||||
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}")
|
||||
return RedirectResponse("/demo/step/3")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4 — Accounts (v2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/demo/step/4", response_class=HTMLResponse)
|
||||
async def step4(request: Request):
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
user_token = sess.get("user_token", "")
|
||||
error = None
|
||||
result = None
|
||||
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||
|
||||
curl_example = (
|
||||
"curl https://api.tink.com/data/v2/accounts \\\n"
|
||||
" -H 'Authorization: Bearer $USER_TOKEN'"
|
||||
)
|
||||
|
||||
if not user_token and not is_demo:
|
||||
error = "Mangler user token — tilslut en bank i Step 3 først."
|
||||
elif is_demo:
|
||||
result = demo_data.MOCK_ACCOUNTS
|
||||
else:
|
||||
try:
|
||||
client = _client()
|
||||
result = await client.list_accounts(user_token)
|
||||
# Fall back to demo data if no accounts connected yet
|
||||
if not result.get("accounts"):
|
||||
result = demo_data.MOCK_ACCOUNTS
|
||||
sess["demo_mode"] = True
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
return templates.TemplateResponse("step.html", {
|
||||
"request": request,
|
||||
"step": 4,
|
||||
"title": "Konti",
|
||||
"subtitle": "Account List with Balances",
|
||||
"endpoint": "GET /data/v2/accounts",
|
||||
"api_version": "v2 ✦",
|
||||
"description": "Henter brugerens konti via det nye v2 data endpoint. Returnerer account type, balance, currency og IBAN.",
|
||||
"curl_example": curl_example,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"is_demo": is_demo or (result == demo_data.MOCK_ACCOUNTS),
|
||||
"next_step": 5,
|
||||
"prev_step": 3,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5 — Transactions (v2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/demo/step/5", response_class=HTMLResponse)
|
||||
async def step5(request: Request, account_id: Optional[str] = None):
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
user_token = sess.get("user_token", "")
|
||||
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||
error = None
|
||||
result = None
|
||||
|
||||
curl_example = (
|
||||
"curl 'https://api.tink.com/data/v2/transactions?pageSize=25' \\\n"
|
||||
" -H 'Authorization: Bearer $USER_TOKEN'"
|
||||
)
|
||||
|
||||
if not user_token and not is_demo:
|
||||
error = "Mangler user token — tilslut en bank i Step 3 først."
|
||||
elif is_demo:
|
||||
result = demo_data.MOCK_TRANSACTIONS
|
||||
else:
|
||||
try:
|
||||
client = _client()
|
||||
result = await client.list_transactions(user_token, account_id=account_id)
|
||||
if not result.get("transactions"):
|
||||
result = demo_data.MOCK_TRANSACTIONS
|
||||
sess["demo_mode"] = True
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
return templates.TemplateResponse("step.html", {
|
||||
"request": request,
|
||||
"step": 5,
|
||||
"title": "Transaktioner",
|
||||
"subtitle": "Transaction History",
|
||||
"endpoint": "GET /data/v2/transactions",
|
||||
"api_version": "v2 ✦",
|
||||
"description": "Henter transaktioner via v2 endpoint med paginering. Returnerer amount, description, status (BOOKED/PENDING), og kategorisering.",
|
||||
"curl_example": curl_example,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"is_demo": is_demo or (result == demo_data.MOCK_TRANSACTIONS),
|
||||
"next_step": 6,
|
||||
"prev_step": 4,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 6 — Events (v2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/demo/step/6", response_class=HTMLResponse)
|
||||
async def step6(request: Request):
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
user_token = sess.get("user_token", "")
|
||||
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||
error = None
|
||||
result_booked = None
|
||||
result_all = None
|
||||
|
||||
curl_booked = (
|
||||
"curl 'https://api.tink.com/events/v2/account-booked-transactions?pageSize=10' \\\n"
|
||||
" -H 'Authorization: Bearer $USER_TOKEN'"
|
||||
)
|
||||
curl_all = (
|
||||
"curl 'https://api.tink.com/events/v2/account-transactions?pageSize=10' \\\n"
|
||||
" -H 'Authorization: Bearer $USER_TOKEN'"
|
||||
)
|
||||
|
||||
if not user_token and not is_demo:
|
||||
error = "Mangler user token — tilslut en bank i Step 3 først."
|
||||
elif is_demo:
|
||||
result_booked = demo_data.MOCK_EVENTS_BOOKED
|
||||
result_all = demo_data.MOCK_EVENTS_ALL
|
||||
else:
|
||||
try:
|
||||
client = _client()
|
||||
result_booked = await client.list_booked_transaction_events(user_token, page_size=10)
|
||||
except Exception as e:
|
||||
error = f"Booked events: {e}"
|
||||
try:
|
||||
client = _client()
|
||||
result_all = await client.list_account_transaction_events(user_token, page_size=10)
|
||||
except Exception as e:
|
||||
error = (error or "") + f" | All events: {e}"
|
||||
|
||||
return templates.TemplateResponse("step6.html", {
|
||||
"request": request,
|
||||
"step": 6,
|
||||
"title": "Events",
|
||||
"subtitle": "Real-time Event Feed",
|
||||
"error": error,
|
||||
"result_booked": result_booked,
|
||||
"result_all": result_all,
|
||||
"curl_booked": curl_booked,
|
||||
"curl_all": curl_all,
|
||||
"is_demo": is_demo,
|
||||
"app_base_url": s.app_base_url,
|
||||
"next_step": None,
|
||||
"prev_step": 5,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Webhook receiver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/webhooks/tink")
|
||||
async def webhook_receiver(request: Request):
|
||||
"""Receive Tink webhook events (configure URL in Tink Console)."""
|
||||
body = await request.json()
|
||||
# In production you'd verify the signature and store events
|
||||
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
|
||||
return {"status": "received"}
|
||||
94
src/templates/base.html
Normal file
94
src/templates/base.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="da" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Tink API Demo {% block title %}{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.json-key { color: #7dd3fc; }
|
||||
.json-str { color: #86efac; }
|
||||
.json-num { color: #fcd34d; }
|
||||
.json-bool { color: #f9a8d4; }
|
||||
.json-null { color: #94a3b8; }
|
||||
pre.json-block {
|
||||
background: #0f172a;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
font-family: 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
.step-badge-v2 {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
}
|
||||
.step-badge-v1 {
|
||||
background: #334155;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-full flex flex-col">
|
||||
|
||||
<!-- Top nav -->
|
||||
<nav class="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-10">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3 group">
|
||||
<div class="w-8 h-8 rounded-lg bg-violet-600 flex items-center justify-center text-white font-bold text-sm">T</div>
|
||||
<div>
|
||||
<span class="font-semibold text-white">Tink API Demo</span>
|
||||
<span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/" class="text-sm text-slate-400 hover:text-white transition">← Restart demo</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Step progress bar (only if step is defined in template) -->
|
||||
{% block stepper %}{% endblock %}
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-slate-800 text-center text-slate-500 text-xs py-4">
|
||||
Tink API Demo — MoneyCapp sales prototype — i80.dk
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function syntaxHighlight(json) {
|
||||
if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
|
||||
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function(match) {
|
||||
let cls = 'json-num';
|
||||
if (/^"/.test(match)) {
|
||||
cls = /:$/.test(match) ? 'json-key' : 'json-str';
|
||||
} else if (/true|false/.test(match)) {
|
||||
cls = 'json-bool';
|
||||
} else if (/null/.test(match)) {
|
||||
cls = 'json-null';
|
||||
}
|
||||
return `<span class="${cls}">${match}</span>`;
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.raw-json').forEach(el => {
|
||||
try {
|
||||
const data = JSON.parse(el.textContent);
|
||||
el.innerHTML = syntaxHighlight(data);
|
||||
} catch(e) {
|
||||
el.innerHTML = syntaxHighlight(el.textContent);
|
||||
}
|
||||
el.classList.add('json-block');
|
||||
el.classList.remove('raw-json');
|
||||
});
|
||||
function copyToClipboard(id) {
|
||||
const el = document.getElementById(id);
|
||||
navigator.clipboard.writeText(el.innerText).then(() => {
|
||||
const btn = el.nextElementSibling;
|
||||
if (btn) { btn.textContent = '✓ Kopieret'; setTimeout(() => btn.textContent = 'Kopier', 2000); }
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
57
src/templates/index.html
Normal file
57
src/templates/index.html
Normal file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %} — Start{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center gap-8">
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="max-w-2xl">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-violet-900/40 border border-violet-700/50 text-violet-300 text-sm mb-6">
|
||||
<span class="w-2 h-2 rounded-full bg-violet-400 animate-pulse"></span>
|
||||
Live demo · Tink Sandbox
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
Tink Open Banking<br/>
|
||||
<span class="text-violet-400">API Demo</span>
|
||||
</h1>
|
||||
<p class="text-slate-400 text-lg leading-relaxed mb-2">
|
||||
Step-for-step gennemgang af hele Tink integrationsflowet —
|
||||
fra brugeroprettelse til live transaktioner og events.
|
||||
</p>
|
||||
<p class="text-slate-500 text-sm">
|
||||
Bruger Tink <strong class="text-violet-400">v2 endpoints</strong> for accounts, transactions og events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Flow overview -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 w-full max-w-4xl">
|
||||
{% set steps = [
|
||||
("1", "Auth", "Client Credentials", "v1"),
|
||||
("2", "Opret Bruger", "POST /user/create", "v1"),
|
||||
("3", "Tilslut Bank", "Tink Link", "Link"),
|
||||
("4", "Konti", "GET /data/v2/accounts", "v2"),
|
||||
("5", "Transaktioner", "GET /data/v2/transactions", "v2"),
|
||||
("6", "Events", "GET /events/v2/…", "v2"),
|
||||
] %}
|
||||
{% for num, name, endpoint, version in steps %}
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="w-7 h-7 rounded-full bg-slate-800 text-slate-300 text-xs font-bold flex items-center justify-center">{{ num }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-mono {% if version == 'v2' %}bg-violet-900/50 text-violet-300 border border-violet-700/40{% else %}bg-slate-800 text-slate-400{% endif %}">{{ version }}</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-white">{{ name }}</div>
|
||||
<div class="text-xs text-slate-500 font-mono leading-tight">{{ endpoint }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a href="/demo/step/1"
|
||||
class="inline-flex items-center gap-3 px-8 py-4 bg-violet-600 hover:bg-violet-500 text-white font-semibold rounded-xl text-lg transition-all hover:scale-105 shadow-lg shadow-violet-900/50">
|
||||
Start Demo
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/></svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
242
src/templates/step.html
Normal file
242
src/templates/step.html
Normal file
@@ -0,0 +1,242 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Step {{ step }}{% endblock %}
|
||||
|
||||
{% block stepper %}
|
||||
<div class="bg-slate-900/60 border-b border-slate-800">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||
<div class="flex items-center gap-1 overflow-x-auto">
|
||||
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Events"] %}
|
||||
{% for i in range(1, 7) %}
|
||||
<a href="/demo/step/{{ i }}"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
|
||||
{% if i == step %}bg-violet-600 text-white font-semibold
|
||||
{% elif i < step %}text-slate-300 hover:text-white
|
||||
{% else %}text-slate-600 hover:text-slate-400{% endif %}">
|
||||
<span class="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center
|
||||
{% if i < step %}bg-slate-700 text-slate-300
|
||||
{% elif i == step %}bg-violet-500 text-white
|
||||
{% else %}bg-slate-800 text-slate-600{% endif %}">{{ i }}</span>
|
||||
{{ step_names[i-1] }}
|
||||
</a>
|
||||
{% if i < 6 %}
|
||||
<span class="text-slate-700">›</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
|
||||
<!-- Left: info panel -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
|
||||
<!-- Step header -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">{{ step }}</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">{{ title }}</h2>
|
||||
<p class="text-slate-400 text-sm">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endpoint badge -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wider">Endpoint</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-mono font-semibold
|
||||
{% if 'v2' in api_version %}bg-violet-900/50 text-violet-300 border border-violet-700/40
|
||||
{% else %}bg-slate-800 text-slate-400{% endif %}">
|
||||
{{ api_version }}
|
||||
</span>
|
||||
</div>
|
||||
<code class="text-sm text-emerald-400 font-mono break-all">{{ endpoint }}</code>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
||||
<p class="text-slate-300 text-sm leading-relaxed">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- curl example -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL eksempel</span>
|
||||
<button onclick="copyToClipboard('curl-{{ step }}')"
|
||||
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">
|
||||
Kopier
|
||||
</button>
|
||||
</div>
|
||||
<pre id="curl-{{ step }}" class="text-xs text-amber-300 font-mono whitespace-pre-wrap leading-relaxed">{{ curl_example }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex gap-3">
|
||||
{% if prev_step %}
|
||||
<a href="/demo/step/{{ prev_step }}"
|
||||
class="flex-1 text-center px-4 py-2.5 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 rounded-xl text-sm transition">
|
||||
← Step {{ prev_step }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if next_step %}
|
||||
<a href="/demo/step/{{ next_step }}"
|
||||
class="flex-1 text-center px-4 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
|
||||
Step {{ next_step }} →
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: response panel -->
|
||||
<div class="lg:col-span-3 space-y-4">
|
||||
|
||||
<!-- Tink Link special button (step 3) -->
|
||||
{% if tink_link_url %}
|
||||
|
||||
{% if cb_success %}
|
||||
<!-- Already connected — show success, hide connection UI -->
|
||||
<div class="bg-emerald-950/60 border border-emerald-700/50 rounded-xl p-5 flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-emerald-900/60 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-emerald-300 font-semibold text-base">Bank forbundet!</p>
|
||||
<p class="text-emerald-400/70 text-sm mt-0.5">User token gemt i session. Trin 4–6 er klar.</p>
|
||||
<a href="/demo/reset"
|
||||
class="inline-flex items-center gap-1.5 mt-3 text-xs text-slate-500 hover:text-slate-300 transition">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Start forfra
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Not yet connected — show connection UI -->
|
||||
|
||||
<!-- PRIMARY: console.tink.com/callback (always works, no redirect URI registration needed) -->
|
||||
<div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-900/50 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-white font-semibold mb-1">Tilslut testbank</h3>
|
||||
<p class="text-slate-400 text-sm">Åbn Tink Link, forbind Demo Bank, kopier koden og indsæt herunder.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="bg-slate-800/60 border border-slate-700/50 rounded-lg p-4 text-sm space-y-2">
|
||||
<p class="text-slate-300 font-semibold flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Sådan forbinder du Demo Bank
|
||||
</p>
|
||||
<ol class="text-slate-400 space-y-1.5 list-decimal list-inside leading-relaxed">
|
||||
<li>Hent credentials fra <span class="font-mono text-amber-300 text-xs bg-slate-900 px-1.5 py-0.5 rounded">Console → Demo Bank → Transactions → DK</span></li>
|
||||
<li>Klik <strong class="text-white">"Åbn Tink Link"</strong> nedenfor (åbner i ny fane)</li>
|
||||
<li>Vælg <span class="text-slate-300">Tink Demo Bank</span> → <span class="text-slate-300">Open Banking</span> → <span class="text-slate-300">Password And OTP</span></li>
|
||||
<li>Indtast username + password → OTP-koden vises på siden</li>
|
||||
<li>Vælg en konto → Continue</li>
|
||||
<li>Du lander på <span class="font-mono text-amber-300 text-xs">console.tink.com/callback<strong>?code=XXXX</strong></span> — kopier koden</li>
|
||||
<li>Indsæt koden i feltet herunder og klik "Brug kode"</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ dev_tink_link_url }}" target="_blank"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition">
|
||||
Åbn Tink Link
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
<a href="/demo/reset"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 rounded-lg text-sm transition">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Start forfra
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Code paste form -->
|
||||
<form method="POST" action="/demo/step/3" class="flex gap-2 items-stretch pt-1 border-t border-slate-800">
|
||||
<input type="text" name="code" placeholder="Indsæt code=XXXX her efter Tink Link flow..."
|
||||
class="flex-1 bg-slate-800 border border-slate-700 text-slate-200 rounded-lg px-3 py-2 text-sm font-mono placeholder-slate-600 focus:outline-none focus:border-emerald-600" required>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg transition whitespace-nowrap">
|
||||
Brug kode
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- SECONDARY: direct callback (only works when redirect URI is registered) -->
|
||||
{% if tink_link_url %}
|
||||
<details class="bg-slate-900 border border-slate-700/30 rounded-xl overflow-hidden">
|
||||
<summary class="px-5 py-3 cursor-pointer text-slate-500 hover:text-slate-400 text-xs flex items-center gap-2 select-none">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Direkte callback (kræver registreret redirect URI i Console)
|
||||
<span class="ml-auto">▾</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-4 pt-3 border-t border-slate-800">
|
||||
<p class="text-slate-500 text-xs mb-3">Virker kun når <code class="text-slate-400">{{ tink_link_url | truncate(60) }}</code> er registreret som redirect URI i Tink Console.</p>
|
||||
<a href="{{ tink_link_url }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm rounded-lg transition">
|
||||
Åbn med direkte callback
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}{# end not cb_success #}
|
||||
|
||||
{% endif %}{# end tink_link_url #}
|
||||
|
||||
<!-- Error state -->
|
||||
{% if error %}
|
||||
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-red-400 text-xl flex-shrink-0">✕</span>
|
||||
<div>
|
||||
<h3 class="text-red-300 font-semibold mb-1">Fejl</h3>
|
||||
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- JSON response -->
|
||||
{% if result %}
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||
<span class="text-sm text-slate-300 font-semibold">Response</span>
|
||||
<span class="text-xs text-emerald-400 font-mono">200 OK</span>
|
||||
{% if is_demo %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-semibold bg-amber-900/60 text-amber-300 border border-amber-700/40">⚠ Sample Data</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button onclick="copyToClipboard('json-{{ step }}')"
|
||||
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">
|
||||
Kopier JSON
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 max-h-[520px] overflow-y-auto">
|
||||
<pre id="json-{{ step }}" class="raw-json">{{ result | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% elif not error %}
|
||||
<!-- Waiting state -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl p-10 flex flex-col items-center justify-center gap-3 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<p class="text-slate-400 text-sm">Klik på knappen ovenfor for at køre dette API-kald og se svaret her.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
160
src/templates/step6.html
Normal file
160
src/templates/step6.html
Normal file
@@ -0,0 +1,160 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %} — Step 6: Events{% endblock %}
|
||||
|
||||
{% block stepper %}
|
||||
<div class="bg-slate-900/60 border-b border-slate-800">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||
<div class="flex items-center gap-1 overflow-x-auto">
|
||||
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Events"] %}
|
||||
{% for i in range(1, 7) %}
|
||||
<a href="/demo/step/{{ i }}"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
|
||||
{% if i == 6 %}bg-violet-600 text-white font-semibold
|
||||
{% elif i < 6 %}text-slate-300 hover:text-white
|
||||
{% else %}text-slate-600 hover:text-slate-400{% endif %}">
|
||||
<span class="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center
|
||||
{% if i < 6 %}bg-slate-700 text-slate-300
|
||||
{% elif i == 6 %}bg-violet-500 text-white
|
||||
{% else %}bg-slate-800 text-slate-600{% endif %}">{{ i }}</span>
|
||||
{{ step_names[i-1] }}
|
||||
</a>
|
||||
{% if i < 6 %}
|
||||
<span class="text-slate-700">›</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">6</span>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">Events v2</h2>
|
||||
<p class="text-slate-400 text-sm">Real-time Event Feed</p>
|
||||
</div>
|
||||
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-violet-900/50 text-violet-300 border border-violet-700/40">v2 ✦</span>
|
||||
</div>
|
||||
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
|
||||
Tink Events v2 giver real-time notifikationer når transaktioner bogføres.
|
||||
Kombineret med webhooks kan din applikation reagere på bankbevægelser øjeblikkeligt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5 mb-6">
|
||||
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Booked transactions events -->
|
||||
<div class="space-y-3">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-white">Bogførte transaktioner</span>
|
||||
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /events/v2/account-booked-transactions</code>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-900/50 text-violet-300 border border-violet-700/40 font-mono">v2</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- curl -->
|
||||
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
|
||||
<button onclick="copyToClipboard('curl-booked')"
|
||||
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
|
||||
</div>
|
||||
<pre id="curl-booked" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_booked }}</pre>
|
||||
</div>
|
||||
<!-- response -->
|
||||
<div class="p-4 max-h-96 overflow-y-auto">
|
||||
{% if result_booked %}
|
||||
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
|
||||
<pre class="raw-json">{{ result_booked | tojson(indent=2) }}</pre>
|
||||
{% else %}
|
||||
<p class="text-slate-500 text-sm text-center py-6">Ingen data — tilslut bank i Step 3 først.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All transaction events -->
|
||||
<div class="space-y-3">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-white">Alle transaktionshændelser</span>
|
||||
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /events/v2/account-transactions</code>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-900/50 text-violet-300 border border-violet-700/40 font-mono">v2</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- curl -->
|
||||
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
|
||||
<button onclick="copyToClipboard('curl-all')"
|
||||
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
|
||||
</div>
|
||||
<pre id="curl-all" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_all }}</pre>
|
||||
</div>
|
||||
<!-- response -->
|
||||
<div class="p-4 max-h-96 overflow-y-auto">
|
||||
{% if result_all %}
|
||||
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
|
||||
<pre class="raw-json">{{ result_all | tojson(indent=2) }}</pre>
|
||||
{% else %}
|
||||
<p class="text-slate-500 text-sm text-center py-6">Ingen data — tilslut bank i Step 3 først.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Webhook info box -->
|
||||
<div class="mt-6 bg-slate-900 border border-slate-800 rounded-xl p-5">
|
||||
<h3 class="text-white font-semibold mb-2 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
|
||||
Webhooks
|
||||
</h3>
|
||||
<p class="text-slate-400 text-sm mb-3">
|
||||
Konfigurer en webhook i Tink Console til at poste events til <code class="text-violet-300">/webhooks/tink</code> på dette endpoint.
|
||||
Events sendes i real-time når transaktioner opdateres.
|
||||
</p>
|
||||
<div class="bg-slate-950 rounded-lg p-3">
|
||||
<code class="text-xs text-emerald-400 font-mono">POST {{ app_base_url }}/webhooks/tink</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Final CTA -->
|
||||
<div class="mt-8 bg-gradient-to-br from-violet-900/30 to-indigo-900/20 border border-violet-700/30 rounded-2xl p-8 text-center">
|
||||
<h3 class="text-2xl font-bold text-white mb-2">Det var hele flowet 🎉</h3>
|
||||
<p class="text-slate-400 mb-6 max-w-lg mx-auto">
|
||||
Fra brugeroprettelse til live transaktioner og events — alt via Tink v2 API.
|
||||
Klar til at integrere i MoneyCapp.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-center">
|
||||
<a href="/" class="px-5 py-2.5 border border-slate-600 text-slate-300 hover:text-white hover:border-slate-400 rounded-xl text-sm transition">Kør demo igen</a>
|
||||
<a href="https://docs.tink.com" target="_blank"
|
||||
class="px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
|
||||
Tink Docs →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="mt-4 flex justify-start">
|
||||
<a href="/demo/step/5"
|
||||
class="px-4 py-2.5 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 rounded-xl text-sm transition">
|
||||
← Step 5
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
src/tink/__init__.py
Normal file
0
src/tink/__init__.py
Normal file
261
src/tink/client.py
Normal file
261
src/tink/client.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Tink API client — async httpx wrapper covering auth, users,
|
||||
accounts (v2), transactions (v2), events (v2), and bank connectivity.
|
||||
"""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class TinkTokens:
|
||||
app_token: str = ""
|
||||
user_token: str = ""
|
||||
user_id: str = ""
|
||||
external_user_id: str = ""
|
||||
|
||||
|
||||
class TinkClient:
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str,
|
||||
api_base: str = "https://api.tink.com",
|
||||
link_base: str = "https://link.tink.com"):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.link_base = link_base.rstrip("/")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Authentication
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def get_app_token(self, scope: str = "user:create") -> dict:
|
||||
"""Client credentials flow — returns app-level token."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/api/v1/oauth/token",
|
||||
data={
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": scope,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def exchange_code_for_token(self, code: str,
|
||||
redirect_uri: str | None = None) -> dict:
|
||||
"""Authorization code → user-level access token.
|
||||
|
||||
Pass redirect_uri only when it differs from self.redirect_uri
|
||||
(e.g. dev flow via console.tink.com/callback).
|
||||
"""
|
||||
data: dict = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
}
|
||||
if redirect_uri:
|
||||
data["redirect_uri"] = redirect_uri
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/api/v1/oauth/token",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Users (v1 — only version available)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def create_user(self, app_token: str, external_user_id: str,
|
||||
market: str = "DK", locale: str = "da_DK") -> dict:
|
||||
"""Create a Tink user. Returns user_id."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/api/v1/user/create",
|
||||
json={
|
||||
"external_user_id": external_user_id,
|
||||
"market": market,
|
||||
"locale": locale,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {app_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_user(self, user_token: str) -> dict:
|
||||
"""Get current user info."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/api/v1/user",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_authorization_grant_token(self, app_token: str, user_id: str,
|
||||
scope: str) -> dict:
|
||||
"""Get a delegate authorization grant for a specific user (form-encoded)."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
|
||||
data={
|
||||
"actor_client_id": self.client_id,
|
||||
"user_id": user_id,
|
||||
"scope": scope,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {app_token}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tink Link — bank connection URL
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_tink_link_url(self, market: str = "DK",
|
||||
authorization_code: str | None = None,
|
||||
redirect_uri_override: str | None = None) -> str:
|
||||
"""Build the Tink Link URL for bank connection.
|
||||
|
||||
Uses simplified flow (no authorization_code) by default,
|
||||
matching the Tink Console demo URL pattern.
|
||||
Use redirect_uri_override to swap in a different callback URL
|
||||
(e.g. https://console.tink.com/callback for local dev).
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
params: dict = {
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": redirect_uri_override or self.redirect_uri,
|
||||
"market": market,
|
||||
"locale": "da_DK",
|
||||
"scope": "accounts:read,transactions:read,credentials:read",
|
||||
}
|
||||
if authorization_code:
|
||||
params["authorization_code"] = authorization_code
|
||||
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Credentials (bank connections) — v1
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_credentials(self, user_token: str) -> dict:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/api/v1/credentials/list",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Accounts — v2
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_accounts(self, user_token: str, page_size: int = 50,
|
||||
page_token: Optional[str] = None) -> dict:
|
||||
"""GET /data/v2/accounts — new v2 endpoint."""
|
||||
params: dict = {"pageSize": page_size}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/data/v2/accounts",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_account(self, user_token: str, account_id: str) -> dict:
|
||||
"""GET /data/v2/accounts/{id}"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/data/v2/accounts/{account_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Transactions — v2
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_transactions(self, user_token: str, page_size: int = 25,
|
||||
page_token: Optional[str] = None,
|
||||
account_id: Optional[str] = None) -> dict:
|
||||
"""GET /data/v2/transactions — new v2 endpoint."""
|
||||
params: dict = {"pageSize": page_size}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
if account_id:
|
||||
params["accountIdIn"] = account_id
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/data/v2/transactions",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def get_transaction(self, user_token: str, transaction_id: str) -> dict:
|
||||
"""GET /data/v2/transactions/{id}"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/data/v2/transactions/{transaction_id}",
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Events — v2
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def list_account_transaction_events(
|
||||
self, user_token: str,
|
||||
page_size: int = 25,
|
||||
page_token: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""GET /events/v2/account-transactions"""
|
||||
params: dict = {"pageSize": page_size}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/events/v2/account-transactions",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_booked_transaction_events(
|
||||
self, user_token: str,
|
||||
page_size: int = 25,
|
||||
page_token: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""GET /events/v2/account-booked-transactions"""
|
||||
params: dict = {"pageSize": page_size}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/events/v2/account-booked-transactions",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {user_token}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
Reference in New Issue
Block a user