feat: Tink open banking demo — 6-step API walkthrough
Demonstrates the full Tink integration flow for open banking: Step 1 — Client credentials auth (app token) Step 2 — Create Tink user with external_user_id Step 3 — Connect bank via Tink Link OAuth redirect Step 4 — List accounts (v2 endpoint) Step 5 — List transactions (v2 endpoint, cursor pagination) Step 6 — Webhooks (register endpoint, receive events) Built with Python / FastAPI + Jinja2 templates. Each step shows live JSON responses, cURL examples and API version badges. Includes server-side token store (prevents session cookie overflow), asyncio lock on OAuth callback, and demo mode with realistic mock data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
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
|
||||||
55
.gitea/workflows/deploy.yml
Normal file
55
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
SERVICE_NAME: moneycapp-tink-demo
|
||||||
|
IMAGE: registry.i80.dk/gitea/moneycapp-tink-demo
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: debian-host
|
||||||
|
|
||||||
|
env:
|
||||||
|
PATH: /usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/bin:/snap/bin
|
||||||
|
NOMAD_ADDR: "https://nomad.i80.dk:4646"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Docker Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | docker login registry.i80.dk -u "robot\$gitserver" --password-stdin
|
||||||
|
|
||||||
|
- name: Write production env
|
||||||
|
run: |
|
||||||
|
cat > .env.production << 'ENVEOF'
|
||||||
|
TINK_CLIENT_ID=${{ secrets.TINK_CLIENT_ID }}
|
||||||
|
TINK_CLIENT_SECRET=${{ secrets.TINK_CLIENT_SECRET }}
|
||||||
|
TINK_REDIRECT_URI=https://tink-demo.i80.dk/callback
|
||||||
|
APP_BASE_URL=https://tink-demo.i80.dk
|
||||||
|
DEMO_MODE=false
|
||||||
|
ENVEOF
|
||||||
|
# Strip leading spaces
|
||||||
|
sed -i 's/^[[:space:]]*//' .env.production
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
docker build -t ${IMAGE}:latest .
|
||||||
|
docker push ${IMAGE}:latest
|
||||||
|
|
||||||
|
- name: Validate Nomad job
|
||||||
|
run: nomad job validate ${SERVICE_NAME}.nomad
|
||||||
|
|
||||||
|
- name: Deploy to Nomad
|
||||||
|
run: nomad job run ${SERVICE_NAME}.nomad
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
sleep 15
|
||||||
|
curl -sf https://tink-demo.i80.dk/ || echo "Not yet reachable via Traefik"
|
||||||
|
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.env.production
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY src/ src/
|
||||||
|
COPY .env.production .env
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
50
Makefile
Normal file
50
Makefile
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
.PHONY: install run dev docker-build docker-up docker-down push deploy logs vault-setup test clean
|
||||||
|
|
||||||
|
REGISTRY = registry.i80.dk
|
||||||
|
IMAGE = $(REGISTRY)/moneycapp-tink-demo
|
||||||
|
|
||||||
|
install:
|
||||||
|
python3 -m venv .venv && .venv/bin/pip install -q -r requirements.txt
|
||||||
|
cp -n .env.example .env || true
|
||||||
|
@echo "✓ Done — edit .env with your Tink credentials"
|
||||||
|
|
||||||
|
run:
|
||||||
|
.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
dev:
|
||||||
|
.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker build -t moneycapp-tink-demo .
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
docker compose up
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# --- Deploy to i80.dk ---
|
||||||
|
|
||||||
|
push:
|
||||||
|
docker build --platform linux/amd64 -t $(IMAGE):latest .
|
||||||
|
docker push $(IMAGE):latest
|
||||||
|
@echo "✓ Image pushed to $(IMAGE):latest"
|
||||||
|
|
||||||
|
vault-setup:
|
||||||
|
@echo "Storing Tink credentials in Vault..."
|
||||||
|
vault kv put secret/moneycapp-tink-demo \
|
||||||
|
client_id=$(TINK_CLIENT_ID) \
|
||||||
|
client_secret=$(TINK_CLIENT_SECRET) \
|
||||||
|
session_secret=$$(openssl rand -hex 32)
|
||||||
|
@echo "✓ Vault secret stored at secret/moneycapp-tink-demo"
|
||||||
|
|
||||||
|
deploy: push
|
||||||
|
scp moneycapp-tink-demo.nomad autobox.i80.dk:/tmp/
|
||||||
|
ssh autobox.i80.dk 'export NOMAD_ADDR=https://nomad.i80.dk:4646 && nomad job run /tmp/moneycapp-tink-demo.nomad'
|
||||||
|
@echo "✓ Deployed — https://tink-demo.i80.dk"
|
||||||
|
|
||||||
|
logs:
|
||||||
|
ssh autobox.i80.dk 'export NOMAD_ADDR=https://nomad.i80.dk:4646 && nomad alloc logs -job moneycapp-tink-demo'
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf .venv __pycache__ src/__pycache__ src/**/__pycache__
|
||||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# MoneyCapp × Tink API Demo
|
||||||
|
|
||||||
|
"Sales-y Swagger" — step-for-step gennemgang af Tink integrationsflowet med live JSON responses.
|
||||||
|
|
||||||
|
## Hvad det er
|
||||||
|
|
||||||
|
En hosted demo-app der viser hele Tink onboarding-flowet:
|
||||||
|
|
||||||
|
| Step | Endpoint | Version |
|
||||||
|
|------|----------|---------|
|
||||||
|
| 1 | POST `/api/v1/oauth/token` — Client Credentials | v1 |
|
||||||
|
| 2 | POST `/api/v1/user/create` — Opret bruger | v1 |
|
||||||
|
| 3 | Tink Link redirect — Tilslut bank | Link v1 |
|
||||||
|
| 4 | GET `/data/v2/accounts` — Konti med balances | **v2** |
|
||||||
|
| 5 | GET `/data/v2/transactions` — Transaktioner | **v2** |
|
||||||
|
| 6 | GET `/events/v2/account-transactions` + webhooks | **v2** |
|
||||||
|
|
||||||
|
## Quick start (lokal dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Udfyld TINK_CLIENT_ID og TINK_CLIENT_SECRET fra Tink Console
|
||||||
|
# Tilføj http://localhost:8000/callback som Redirect URI i Tink Console
|
||||||
|
|
||||||
|
python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn src.main:app --reload
|
||||||
|
# Åbn http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy til i80/Nomad
|
||||||
|
|
||||||
|
1. Konfigurer Gitea secrets: `REGISTRY_USER`, `REGISTRY_TOKEN`, `NOMAD_ADDR`, `NOMAD_TOKEN`
|
||||||
|
2. Læg Tink credentials i Nomad/Vault: `secret/moneycapp-tink-demo`
|
||||||
|
3. Tilføj `https://tink-demo.i80.dk/callback` som Redirect URI i Tink Console
|
||||||
|
4. Push til `main` → Gitea Actions bygger og deployer
|
||||||
|
|
||||||
|
## Tink Console setup
|
||||||
|
|
||||||
|
1. Gå til [console.tink.com](https://console.tink.com)
|
||||||
|
2. Opret en app → kopiér Client ID + Secret til `.env`
|
||||||
|
3. Under **Redirect URIs**: tilføj din callback URL
|
||||||
|
4. Under **Scopes**: aktiver `accounts:read`, `transactions:read`, `credentials:read/write`, `user:create`
|
||||||
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
|
||||||
41
moneycapp-tink-demo.nomad
Normal file
41
moneycapp-tink-demo.nomad
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
job "moneycapp-tink-demo" {
|
||||||
|
datacenters = ["dc1"]
|
||||||
|
type = "service"
|
||||||
|
|
||||||
|
group "demo" {
|
||||||
|
count = 1
|
||||||
|
|
||||||
|
network {
|
||||||
|
port "http" { to = 8000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
service {
|
||||||
|
name = "moneycapp-tink-demo"
|
||||||
|
port = "http"
|
||||||
|
tags = ["traefik.enable=true",
|
||||||
|
"traefik.http.routers.tink-demo.rule=Host(`tink-demo.i80.dk`)",
|
||||||
|
"traefik.http.routers.tink-demo.tls=true",
|
||||||
|
"traefik.http.routers.tink-demo.tls.certresolver=le"]
|
||||||
|
check {
|
||||||
|
type = "http"
|
||||||
|
path = "/"
|
||||||
|
interval = "30s"
|
||||||
|
timeout = "5s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task "app" {
|
||||||
|
driver = "docker"
|
||||||
|
|
||||||
|
config {
|
||||||
|
image = "registry.i80.dk/gitea/moneycapp-tink-demo:latest"
|
||||||
|
ports = ["http"]
|
||||||
|
}
|
||||||
|
|
||||||
|
resources {
|
||||||
|
cpu = 256
|
||||||
|
memory = 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
644
src/routes/demo.py
Normal file
644
src/routes/demo.py
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
"""
|
||||||
|
Demo routes — each /demo/step/N page shows one Tink API call
|
||||||
|
with the live JSON response and a curl example.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from src.tink.client import TinkClient
|
||||||
|
from src.config import get_settings
|
||||||
|
from src import demo_data
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="src/templates")
|
||||||
|
|
||||||
|
|
||||||
|
def _client(log_cb=None) -> TinkClient:
|
||||||
|
s = get_settings()
|
||||||
|
return TinkClient(
|
||||||
|
client_id=s.tink_client_id,
|
||||||
|
client_secret=s.tink_client_secret,
|
||||||
|
redirect_uri=s.tink_redirect_uri,
|
||||||
|
api_base=s.tink_api_base,
|
||||||
|
link_base=s.tink_link_base,
|
||||||
|
on_request=log_cb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(request: Request) -> dict:
|
||||||
|
return request.session.setdefault("demo", {})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server-side token store — keeps JWTs OUT of the session cookie
|
||||||
|
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sid(sess: dict) -> str:
|
||||||
|
"""Return (creating if needed) a stable session ID stored in the cookie."""
|
||||||
|
if "sid" not in sess:
|
||||||
|
sess["sid"] = str(uuid.uuid4())
|
||||||
|
return sess["sid"]
|
||||||
|
|
||||||
|
|
||||||
|
def _store_token(sess: dict, key: str, value: str) -> None:
|
||||||
|
"""Save a JWT in the server-side store instead of the cookie."""
|
||||||
|
sid = _get_sid(sess)
|
||||||
|
_token_store.setdefault(sid, {})[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_token(sess: dict, key: str, default: str = "") -> str:
|
||||||
|
"""Read a JWT from the server-side store."""
|
||||||
|
sid = sess.get("sid", "")
|
||||||
|
return _token_store.get(sid, {}).get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(request: Request, extra: dict) -> dict:
|
||||||
|
"""Base template context — always includes session_customer."""
|
||||||
|
sess = _session(request)
|
||||||
|
return {"request": request, "session_customer": sess.get("external_user_id", ""), **extra}
|
||||||
|
|
||||||
|
|
||||||
|
def _logger(sess: dict):
|
||||||
|
"""Returns a callback that appends log entries to sess['api_log']."""
|
||||||
|
def cb(entry: dict):
|
||||||
|
log = sess.setdefault("api_log", [])
|
||||||
|
log.append(entry)
|
||||||
|
# keep last 50 entries
|
||||||
|
if len(log) > 50:
|
||||||
|
sess["api_log"] = log[-50:]
|
||||||
|
return cb
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Landing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/reset")
|
||||||
|
async def reset_demo(request: Request):
|
||||||
|
"""Clear all demo session state and restart from Step 1."""
|
||||||
|
sess = _session(request)
|
||||||
|
sid = sess.get("sid", "")
|
||||||
|
if sid:
|
||||||
|
_token_store.pop(sid, None)
|
||||||
|
request.session.pop("demo", None)
|
||||||
|
return RedirectResponse("/demo/step/1")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/debug-session")
|
||||||
|
async def debug_session(request: Request):
|
||||||
|
"""Show current session keys (debug only)."""
|
||||||
|
sess = _session(request)
|
||||||
|
safe = {
|
||||||
|
k: (v[:20] + "…" if isinstance(v, str) and len(v) > 20 else v)
|
||||||
|
for k, v in sess.items()
|
||||||
|
if k != "api_log"
|
||||||
|
}
|
||||||
|
safe["api_log_count"] = len(sess.get("api_log", []))
|
||||||
|
safe["cookie_size_bytes"] = len(str(request.session))
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 1 — Authenticate (client credentials)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/demo/step/1", response_class=HTMLResponse)
|
||||||
|
async def step1(request: Request):
|
||||||
|
sess = _session(request)
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
s = get_settings()
|
||||||
|
error = None
|
||||||
|
result = None
|
||||||
|
curl_example = (
|
||||||
|
f"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
|
||||||
|
f" -d 'client_id={s.tink_client_id}' \\\n"
|
||||||
|
f" -d 'client_secret=***' \\\n"
|
||||||
|
f" -d 'grant_type=client_credentials' \\\n"
|
||||||
|
f" -d 'scope=user:create,authorization:grant'"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await client.get_app_token(scope="user:create,authorization:grant")
|
||||||
|
_store_token(sess, "app_token", result["access_token"])
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
|
"step": 1,
|
||||||
|
"title": "Authenticate",
|
||||||
|
"subtitle": "Client Credentials Flow",
|
||||||
|
"endpoint": "POST /api/v1/oauth/token",
|
||||||
|
"api_version": "v1",
|
||||||
|
"description": "Vi starter med at hente et app-level access token via Client Credentials flow. Dette token bruges til at oprette brugere.",
|
||||||
|
"curl_example": curl_example,
|
||||||
|
"result": result,
|
||||||
|
"error": error,
|
||||||
|
"next_step": 2,
|
||||||
|
"prev_step": None,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 2 — Create User
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/demo/step/2", response_class=HTMLResponse)
|
||||||
|
async def step2_get(request: Request):
|
||||||
|
sess = _session(request)
|
||||||
|
s = get_settings()
|
||||||
|
app_token = _load_token(sess, "app_token")
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if not app_token:
|
||||||
|
error = "Mangler app token — gå tilbage til Step 1 og kør authentication først."
|
||||||
|
|
||||||
|
# If user already created this session, skip the form
|
||||||
|
existing_user_id = sess.get("user_id", "")
|
||||||
|
existing_external_id = sess.get("external_user_id", "")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("step2.html", _ctx(request, {
|
||||||
|
"step": 2,
|
||||||
|
"error": error,
|
||||||
|
"existing_user_id": existing_user_id,
|
||||||
|
"existing_external_id": existing_external_id,
|
||||||
|
"app_token_ok": bool(app_token),
|
||||||
|
"next_step": 3,
|
||||||
|
"prev_step": 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/demo/step/2", response_class=HTMLResponse)
|
||||||
|
async def step2_post(request: Request,
|
||||||
|
customer_name: str = Form(default=""),
|
||||||
|
market: str = Form(default="DK")):
|
||||||
|
sess = _session(request)
|
||||||
|
s = get_settings()
|
||||||
|
app_token = _load_token(sess, "app_token")
|
||||||
|
error = None
|
||||||
|
result = None
|
||||||
|
|
||||||
|
# Build external_user_id — always unique per run (simulates real customer UUID)
|
||||||
|
short_id = secrets.token_hex(3) # 6-char hex, e.g. "a3f9c1"
|
||||||
|
if customer_name.strip():
|
||||||
|
import re
|
||||||
|
slug = customer_name.strip().lower().replace(" ", "-")
|
||||||
|
slug = re.sub(r"[^a-z0-9\-]", "", slug)
|
||||||
|
external_user_id = f"moneycapp-{slug}-{short_id}"
|
||||||
|
else:
|
||||||
|
external_user_id = f"moneycapp-{short_id}"
|
||||||
|
|
||||||
|
body = json.dumps({"external_user_id": external_user_id, "market": market, "locale": "da_DK"}, indent=2)
|
||||||
|
curl_example = (
|
||||||
|
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
|
||||||
|
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
||||||
|
f" -H 'Content-Type: application/json' \\\n"
|
||||||
|
f" -d '{body}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not app_token:
|
||||||
|
error = "Mangler app token — gå tilbage til Step 1."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
result = await client.create_user(app_token, external_user_id, market=market)
|
||||||
|
sess["user_id"] = result.get("user_id", "")
|
||||||
|
sess["external_user_id"] = external_user_id
|
||||||
|
sess["user_market"] = market
|
||||||
|
# New user — clear any stale tokens from a previous user
|
||||||
|
sess.pop("user_token", None)
|
||||||
|
sess.pop("demo_mode", None)
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
|
"step": 2,
|
||||||
|
"title": "Opret Bruger",
|
||||||
|
"subtitle": f"Kunde: {external_user_id}",
|
||||||
|
"endpoint": "POST /api/v1/user/create",
|
||||||
|
"api_version": "v1",
|
||||||
|
"description": f"Oprettet Tink-bruger med <code class='text-violet-300 font-mono'>tink_external_ref</code> = <code class='text-violet-300 font-mono'>{external_user_id}</code> — MoneyCapp's reference til kunden i Tink. Tink returnerer et <code class='text-violet-300 font-mono'>user_id</code> som bruges i næste kald.",
|
||||||
|
"curl_example": curl_example,
|
||||||
|
"result": result,
|
||||||
|
"error": error,
|
||||||
|
"next_step": 3,
|
||||||
|
"prev_step": 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 3 — Connect Bank (Tink Link redirect)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CONSOLE_CALLBACK = "https://console.tink.com/callback"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/step/3", response_class=HTMLResponse)
|
||||||
|
async def step3_get(request: Request):
|
||||||
|
sess = _session(request)
|
||||||
|
s = get_settings()
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
credentials = None
|
||||||
|
cb_success = request.query_params.get("cb_success")
|
||||||
|
cb_error = request.query_params.get("cb_error")
|
||||||
|
|
||||||
|
user_id = sess.get("user_id", "")
|
||||||
|
app_token = _load_token(sess, "app_token")
|
||||||
|
grant_result = None
|
||||||
|
|
||||||
|
# Call authorization-grant/delegate to demonstrate the API and get a code.
|
||||||
|
# We show this in the response viewer so the audience sees it works.
|
||||||
|
# The actual Tink Link flow uses anonymous mode (sandbox limitation),
|
||||||
|
# but in production you'd pass authorization_code in the URL.
|
||||||
|
if user_id and app_token and not _load_token(sess, "user_token"):
|
||||||
|
try:
|
||||||
|
grant_result = await client.get_authorization_grant_token(
|
||||||
|
app_token=app_token,
|
||||||
|
user_id=user_id,
|
||||||
|
scope="accounts:read,transactions:read,credentials:read",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
# Always use anonymous flow for the actual Tink Link URL — works in sandbox.
|
||||||
|
# Production apps would add authorization_code=<code> instead of scope.
|
||||||
|
tink_link_url = client.get_tink_link_url(market=sess.get("user_market", "DK"))
|
||||||
|
dev_tink_link_url = client.get_tink_link_url(
|
||||||
|
market=sess.get("user_market", "DK"),
|
||||||
|
redirect_uri_override=CONSOLE_CALLBACK,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Demo mode: auto-mark as connected with mock data
|
||||||
|
if s.demo_mode and not _load_token(sess, "user_token"):
|
||||||
|
_store_token(sess, "user_token", "demo-mode-token")
|
||||||
|
sess["demo_mode"] = True
|
||||||
|
|
||||||
|
# Check if already connected (returning from callback)
|
||||||
|
user_token = _load_token(sess, "user_token")
|
||||||
|
if user_token:
|
||||||
|
if sess.get("demo_mode"):
|
||||||
|
credentials = demo_data.MOCK_CREDENTIALS
|
||||||
|
cb_success = cb_success or "demo"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
credentials = await client.list_credentials(user_token)
|
||||||
|
except Exception as e:
|
||||||
|
if "403" in str(e):
|
||||||
|
credentials = {"note": "credentials:read scope ikke tildelt — brug accounts:read i stedet"}
|
||||||
|
else:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
external_user_id = sess.get("external_user_id", "")
|
||||||
|
uid_display = user_id or "$USER_ID"
|
||||||
|
grant_code = (grant_result or {}).get("code", "$AUTHORIZATION_CODE")
|
||||||
|
curl_example = (
|
||||||
|
"# Step 1: Generer authorization_code for din specifikke bruger\n"
|
||||||
|
"curl -X POST https://api.tink.com/api/v1/oauth/authorization-grant/delegate \\\n"
|
||||||
|
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
||||||
|
" -d 'actor_client_id=$CLIENT_ID' \\\n"
|
||||||
|
f" -d 'user_id={uid_display}' \\\n"
|
||||||
|
" -d 'scope=accounts:read,transactions:read'\n"
|
||||||
|
"# → { \"code\": \"AUTHORIZATION_CODE\" }\n\n"
|
||||||
|
"# Step 2: Byg Tink Link URL med authorization_code (binder bank til din bruger)\n"
|
||||||
|
"https://link.tink.com/1.0/transactions/connect-accounts\n"
|
||||||
|
" ?client_id=$CLIENT_ID\n"
|
||||||
|
" &redirect_uri=$REDIRECT_URI\n"
|
||||||
|
f" &authorization_code={grant_code}\n\n"
|
||||||
|
"# Step 3: Callback → exchange code for user token\n"
|
||||||
|
"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
|
||||||
|
" -d 'grant_type=authorization_code' \\\n"
|
||||||
|
" -d 'client_id=$CLIENT_ID' \\\n"
|
||||||
|
" -d 'client_secret=$CLIENT_SECRET' \\\n"
|
||||||
|
" -d 'code=$CALLBACK_CODE'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if grant_result:
|
||||||
|
description = (
|
||||||
|
f"authorization-grant/delegate kaldt for bruger <code class='text-violet-300 font-mono'>{external_user_id}</code> — "
|
||||||
|
f"returnerede <code class='text-violet-300 font-mono'>code</code> som vist nedenfor. "
|
||||||
|
f"I produktion sendes denne code med i Tink Link URL som <code class='text-violet-300 font-mono'>authorization_code</code>."
|
||||||
|
)
|
||||||
|
elif user_id:
|
||||||
|
description = (
|
||||||
|
"Åbn Tink Link → vælg <b>Tink Demo Bank</b> → log ind med testbruger. "
|
||||||
|
"Klik <b>Vis testbrugere</b> for at se login til DK, SE, NO, FI, DE m.fl."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
description = "Gå til Step 2 for at oprette en bruger, klik derefter her for at tilslutte banken."
|
||||||
|
|
||||||
|
# Tink Demo Bank test users per market
|
||||||
|
# Source: https://docs.tink.com/resources/tutorials/test-your-integration-with-demo-bank
|
||||||
|
demo_bank_users = [
|
||||||
|
{"market": "🇩🇰 DK", "username": "u04877810", "password": "vxw774", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇩🇰 DK", "username": "u92721594", "password": "nbs589", "otp": "", "scenario": "Auth-fejl"},
|
||||||
|
{"market": "🇸🇪 SE", "username": "u59803783", "password": "hwj858", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇸🇪 SE", "username": "u91817276", "password": "cft248", "otp": "", "scenario": "Auth-fejl"},
|
||||||
|
{"market": "🇳🇴 NO", "username": "u24765398", "password": "xjf459", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇫🇮 FI", "username": "u19283746", "password": "zkm291", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇩🇪 DE", "username": "u38471920", "password": "bvp103", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇬🇧 GB", "username": "u72910483", "password": "qrt567", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇳🇱 NL", "username": "u56473829", "password": "lmn482", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇫🇷 FR", "username": "u84920173", "password": "pqs736", "otp": "1234", "scenario": "Succes"},
|
||||||
|
]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
|
"step": 3,
|
||||||
|
"title": "Tilslut Bank",
|
||||||
|
"subtitle": "Tink Link — Bank Connection",
|
||||||
|
"endpoint": "POST /api/v1/oauth/authorization-grant/delegate",
|
||||||
|
"api_version": "v1",
|
||||||
|
"description": description,
|
||||||
|
"curl_example": curl_example,
|
||||||
|
"result": grant_result or credentials,
|
||||||
|
"tink_link_url": tink_link_url,
|
||||||
|
"dev_tink_link_url": dev_tink_link_url,
|
||||||
|
"demo_bank_users": demo_bank_users,
|
||||||
|
"error": error or (f"Callback fejl: {cb_error}" if cb_error else None),
|
||||||
|
"cb_success": cb_success,
|
||||||
|
"next_step": 4,
|
||||||
|
"prev_step": 2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/demo/step/3", response_class=HTMLResponse)
|
||||||
|
async def step3_post(request: Request, code: str = Form(...)):
|
||||||
|
"""Manual code entry — exchange a code obtained via console.tink.com/callback."""
|
||||||
|
sess = _session(request)
|
||||||
|
try:
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
tokens = await client.exchange_code_for_token(
|
||||||
|
code=code.strip(),
|
||||||
|
redirect_uri=CONSOLE_CALLBACK,
|
||||||
|
)
|
||||||
|
_store_token(sess, "user_token", tokens.get("access_token", ""))
|
||||||
|
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||||
|
except Exception as e:
|
||||||
|
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback", response_class=HTMLResponse)
|
||||||
|
async def tink_callback(request: Request, code: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
credentials_id: Optional[str] = None):
|
||||||
|
"""Tink Link OAuth callback — exchange code for user token."""
|
||||||
|
sess = _session(request)
|
||||||
|
print(f"[CALLBACK] code={code!r} error={error!r} session_keys={list(sess.keys())}")
|
||||||
|
if error:
|
||||||
|
print(f"[CALLBACK] Tink returned error: {error}")
|
||||||
|
return RedirectResponse(f"/demo/step/3?error={error}")
|
||||||
|
if code:
|
||||||
|
try:
|
||||||
|
s = get_settings()
|
||||||
|
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
tokens = await client.exchange_code_for_token(
|
||||||
|
code, redirect_uri=s.tink_redirect_uri
|
||||||
|
)
|
||||||
|
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
|
||||||
|
user_token = tokens.get("access_token", "")
|
||||||
|
if not user_token:
|
||||||
|
print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}")
|
||||||
|
return RedirectResponse(
|
||||||
|
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
|
_store_token(sess, "user_token", user_token)
|
||||||
|
print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)")
|
||||||
|
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}")
|
||||||
|
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
|
||||||
|
print(f"[CALLBACK] No code — bare redirect to step 3")
|
||||||
|
return RedirectResponse("/demo/step/3", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 4 — Accounts (v2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/demo/step/4", response_class=HTMLResponse)
|
||||||
|
async def step4(request: Request):
|
||||||
|
sess = _session(request)
|
||||||
|
s = get_settings()
|
||||||
|
user_token = _load_token(sess, "user_token")
|
||||||
|
error = None
|
||||||
|
result = None
|
||||||
|
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||||
|
|
||||||
|
curl_example = (
|
||||||
|
"curl https://api.tink.com/data/v2/accounts \\\n"
|
||||||
|
" -H 'Authorization: Bearer $USER_TOKEN'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_token and not is_demo:
|
||||||
|
error = "Mangler user token — tilslut en bank i Step 3 først."
|
||||||
|
elif is_demo:
|
||||||
|
result = demo_data.MOCK_ACCOUNTS
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
result = await client.list_accounts(user_token)
|
||||||
|
# Fall back to demo data if no accounts connected yet
|
||||||
|
if not result.get("accounts"):
|
||||||
|
result = demo_data.MOCK_ACCOUNTS
|
||||||
|
sess["demo_mode"] = True
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
|
"step": 4,
|
||||||
|
"title": "Konti",
|
||||||
|
"subtitle": "Account List with Balances",
|
||||||
|
"endpoint": "GET /data/v2/accounts",
|
||||||
|
"api_version": "v2 ✦",
|
||||||
|
"description": "Henter brugerens konti via det nye v2 data endpoint. Returnerer account type, balance, currency og IBAN.",
|
||||||
|
"curl_example": curl_example,
|
||||||
|
"result": result,
|
||||||
|
"error": error,
|
||||||
|
"is_demo": is_demo or (result == demo_data.MOCK_ACCOUNTS),
|
||||||
|
"next_step": 5,
|
||||||
|
"prev_step": 3,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 5 — Transactions (v2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/demo/step/5", response_class=HTMLResponse)
|
||||||
|
async def step5(request: Request, account_id: Optional[str] = None):
|
||||||
|
sess = _session(request)
|
||||||
|
s = get_settings()
|
||||||
|
user_token = _load_token(sess, "user_token")
|
||||||
|
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||||
|
error = None
|
||||||
|
result = None
|
||||||
|
|
||||||
|
curl_example = (
|
||||||
|
"curl 'https://api.tink.com/data/v2/transactions?pageSize=25' \\\n"
|
||||||
|
" -H 'Authorization: Bearer $USER_TOKEN'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_token and not is_demo:
|
||||||
|
error = "Mangler user token — tilslut en bank i Step 3 først."
|
||||||
|
elif is_demo:
|
||||||
|
result = demo_data.MOCK_TRANSACTIONS
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
result = await client.list_transactions(user_token, account_id=account_id)
|
||||||
|
if not result.get("transactions"):
|
||||||
|
result = demo_data.MOCK_TRANSACTIONS
|
||||||
|
sess["demo_mode"] = True
|
||||||
|
except Exception as e:
|
||||||
|
error = str(e)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
|
"step": 5,
|
||||||
|
"title": "Transaktioner",
|
||||||
|
"subtitle": "Transaction History",
|
||||||
|
"endpoint": "GET /data/v2/transactions",
|
||||||
|
"api_version": "v2 ✦",
|
||||||
|
"description": "Henter transaktioner via v2 endpoint med paginering. Returnerer amount, description, status (BOOKED/PENDING), og kategorisering.",
|
||||||
|
"curl_example": curl_example,
|
||||||
|
"result": result,
|
||||||
|
"error": error,
|
||||||
|
"is_demo": is_demo or (result == demo_data.MOCK_TRANSACTIONS),
|
||||||
|
"next_step": 6,
|
||||||
|
"prev_step": 4,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Step 6 — Events (v2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/demo/step/6", response_class=HTMLResponse)
|
||||||
|
async def step6(request: Request):
|
||||||
|
sess = _session(request)
|
||||||
|
s = get_settings()
|
||||||
|
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||||
|
error = None
|
||||||
|
result_webhooks = None
|
||||||
|
webhook_registered = None
|
||||||
|
|
||||||
|
webhook_url = f"{s.app_base_url}/webhooks/tink"
|
||||||
|
|
||||||
|
curl_list = (
|
||||||
|
"# List registered webhooks (app-level token)\n"
|
||||||
|
"curl 'https://api.tink.com/api/v1/webhooks' \\\n"
|
||||||
|
" -H 'Authorization: Bearer $APP_TOKEN'"
|
||||||
|
)
|
||||||
|
curl_register = (
|
||||||
|
"# Register webhook endpoint\n"
|
||||||
|
"curl -X POST 'https://api.tink.com/api/v1/webhooks' \\\n"
|
||||||
|
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
||||||
|
" -H 'Content-Type: application/json' \\\n"
|
||||||
|
" -d '{\n"
|
||||||
|
' "url": "' + webhook_url + '",\n'
|
||||||
|
' "enabledEvents": [\n'
|
||||||
|
' "account-booked-transaction:created",\n'
|
||||||
|
' "account-pending-transaction:created"\n'
|
||||||
|
" ]\n"
|
||||||
|
" }'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_demo:
|
||||||
|
result_webhooks = demo_data.MOCK_EVENTS_BOOKED
|
||||||
|
webhook_registered = {
|
||||||
|
"id": "wh-demo-001",
|
||||||
|
"url": webhook_url,
|
||||||
|
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
|
||||||
|
"status": "ENABLED",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
|
app_token_resp = await client.get_app_token(scope="user:create")
|
||||||
|
app_token = app_token_resp.get("access_token", "")
|
||||||
|
|
||||||
|
result_webhooks = await client.list_webhooks(app_token)
|
||||||
|
|
||||||
|
# Register our webhook if not already there
|
||||||
|
existing = [w for w in result_webhooks.get("webhooks", []) if w.get("url") == webhook_url]
|
||||||
|
if not existing:
|
||||||
|
webhook_registered = await client.register_webhook(app_token, webhook_url)
|
||||||
|
else:
|
||||||
|
webhook_registered = existing[0]
|
||||||
|
except Exception as e:
|
||||||
|
err_str = str(e)
|
||||||
|
if "404" in err_str:
|
||||||
|
result_webhooks = {"note": "Webhook API ikke tilgængeligt i sandbox — kun i produktion"}
|
||||||
|
webhook_registered = {
|
||||||
|
"url": webhook_url,
|
||||||
|
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
|
||||||
|
"status": "ENABLED (eksempel)",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error = err_str
|
||||||
|
|
||||||
|
return templates.TemplateResponse("step6.html", _ctx(request, {
|
||||||
|
"step": 6,
|
||||||
|
"title": "Webhooks & Events",
|
||||||
|
"subtitle": "Real-time Event Notifications",
|
||||||
|
"error": error,
|
||||||
|
"result_webhooks": result_webhooks,
|
||||||
|
"webhook_registered": webhook_registered,
|
||||||
|
"webhook_url": webhook_url,
|
||||||
|
"curl_list": curl_list,
|
||||||
|
"curl_register": curl_register,
|
||||||
|
"is_demo": is_demo,
|
||||||
|
"app_base_url": s.app_base_url,
|
||||||
|
"next_step": None,
|
||||||
|
"prev_step": 5,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API Request Log
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/demo/log", response_class=HTMLResponse)
|
||||||
|
async def api_log(request: Request):
|
||||||
|
sess = _session(request)
|
||||||
|
log = sess.get("api_log", [])
|
||||||
|
return templates.TemplateResponse("log.html", _ctx(request, {
|
||||||
|
"log": list(reversed(log)), # newest first
|
||||||
|
"log_count": len(log),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/demo/log/clear")
|
||||||
|
async def clear_log(request: Request):
|
||||||
|
sess = _session(request)
|
||||||
|
sess["api_log"] = []
|
||||||
|
return RedirectResponse("/demo/log", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webhook receiver
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/webhooks/tink")
|
||||||
|
async def webhook_receiver(request: Request):
|
||||||
|
"""Receive Tink webhook events (configure URL in Tink Console)."""
|
||||||
|
body = await request.json()
|
||||||
|
# In production you'd verify the signature and store events
|
||||||
|
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
|
||||||
|
return {"status": "received"}
|
||||||
119
src/templates/base.html
Normal file
119
src/templates/base.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Tink API Demo {% block title %}{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<style>
|
||||||
|
.json-key { color: #7dd3fc; }
|
||||||
|
.json-str { color: #86efac; }
|
||||||
|
.json-num { color: #fcd34d; }
|
||||||
|
.json-bool { color: #f9a8d4; }
|
||||||
|
.json-null { color: #94a3b8; }
|
||||||
|
pre.json-block {
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
.step-badge-v2 {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
}
|
||||||
|
.step-badge-v1 {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-slate-950 text-slate-100 min-h-full flex flex-col">
|
||||||
|
|
||||||
|
<!-- Top nav -->
|
||||||
|
<nav class="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-10">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<a href="/" class="flex items-center gap-3 group">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-violet-600 flex items-center justify-center text-white font-bold text-sm">T</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-white">Tink API Demo</span>
|
||||||
|
<span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{% if session_customer %}
|
||||||
|
<div class="flex items-center gap-2 bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
|
<span class="text-xs text-slate-400">Kunde:</span>
|
||||||
|
<code class="text-xs text-emerald-300 font-mono font-semibold">{{ session_customer }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/demo/log"
|
||||||
|
class="text-sm text-slate-400 hover:text-violet-300 transition flex items-center gap-1.5">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
API Log
|
||||||
|
</a>
|
||||||
|
<a href="/demo/reset" class="text-sm text-slate-400 hover:text-white transition">↺ Reset</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Step progress bar (only if step is defined in template) -->
|
||||||
|
{% block stepper %}{% endblock %}
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="border-t border-slate-800 text-center text-slate-500 text-xs py-4">
|
||||||
|
Tink API Demo — 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); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function copyText(id, btn) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
navigator.clipboard.writeText(el.innerText).then(() => {
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = '✓';
|
||||||
|
setTimeout(() => btn.textContent = orig, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
src/templates/index.html
Normal file
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 %}
|
||||||
99
src/templates/log.html
Normal file
99
src/templates/log.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}API Log — Tink Demo{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">📋 API Request Log</h1>
|
||||||
|
<p class="text-slate-400 text-sm mt-1">Alle Tink API-kald i denne session</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-slate-400 text-sm">{{ log_count }} kald registreret</span>
|
||||||
|
{% if log_count > 0 %}
|
||||||
|
<form method="post" action="/demo/log/clear">
|
||||||
|
<button type="submit"
|
||||||
|
class="text-xs px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-300 transition">
|
||||||
|
Ryd log
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/demo/step/1"
|
||||||
|
class="text-xs px-3 py-1.5 rounded bg-violet-600 hover:bg-violet-500 text-white transition">
|
||||||
|
← Tilbage til demo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if log_count == 0 %}
|
||||||
|
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-12 text-center">
|
||||||
|
<div class="text-4xl mb-4">📭</div>
|
||||||
|
<p class="text-slate-400">Ingen API-kald endnu.</p>
|
||||||
|
<p class="text-slate-500 text-sm mt-1">Gå igennem demo-steppene for at se kaldene her.</p>
|
||||||
|
<a href="/demo/step/1" class="inline-block mt-4 px-4 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-white text-sm transition">
|
||||||
|
Start fra Step 1
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for entry in log %}
|
||||||
|
<div class="rounded-xl border {% if entry.ok %}border-emerald-700/40 bg-emerald-900/10{% else %}border-red-700/40 bg-red-900/10{% endif %} overflow-hidden">
|
||||||
|
|
||||||
|
{# Header row #}
|
||||||
|
<button onclick="this.nextElementSibling.classList.toggle('hidden')"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/5 transition">
|
||||||
|
|
||||||
|
{# Method badge #}
|
||||||
|
<span class="text-xs font-mono font-bold px-2 py-0.5 rounded
|
||||||
|
{% if entry.method == 'GET' %}bg-blue-500/20 text-blue-300 border border-blue-500/30
|
||||||
|
{% else %}bg-violet-500/20 text-violet-300 border border-violet-500/30{% endif %}">
|
||||||
|
{{ entry.method }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{# Status badge #}
|
||||||
|
<span class="text-xs font-mono px-2 py-0.5 rounded
|
||||||
|
{% if entry.ok %}bg-emerald-500/20 text-emerald-300 border border-emerald-500/30
|
||||||
|
{% else %}bg-red-500/20 text-red-300 border border-red-500/30{% endif %}">
|
||||||
|
{{ entry.status }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{# URL — strip domain for cleanliness #}
|
||||||
|
<span class="font-mono text-sm text-slate-200 flex-1 truncate">
|
||||||
|
{{ entry.url | replace("https://api.tink.com", "") }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{# Time + duration #}
|
||||||
|
<span class="text-xs text-slate-500 font-mono">{{ entry.ts }}</span>
|
||||||
|
<span class="text-xs text-slate-500 font-mono">{{ entry.duration_ms }}ms</span>
|
||||||
|
|
||||||
|
<svg class="w-4 h-4 text-slate-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Collapsible body — hidden by default #}
|
||||||
|
<div class="hidden border-t {% if entry.ok %}border-emerald-700/20{% else %}border-red-700/20{% endif %} px-4 py-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{% if entry.req_body %}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Request</div>
|
||||||
|
<pre class="text-xs font-mono text-slate-300 bg-slate-900/60 rounded p-3 overflow-auto max-h-48 whitespace-pre-wrap">{{ entry.req_body | tojson(indent=2) }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div {% if not entry.req_body %}class="md:col-span-2"{% endif %}>
|
||||||
|
<div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Response</div>
|
||||||
|
<pre class="text-xs font-mono text-slate-300 bg-slate-900/60 rounded p-3 overflow-auto max-h-48 whitespace-pre-wrap">{{ entry.resp_body | tojson(indent=2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
335
src/templates/step.html
Normal file
335
src/templates/step.html
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Step {{ step }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stepper %}
|
||||||
|
<div class="bg-slate-900/60 border-b border-slate-800">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1 overflow-x-auto">
|
||||||
|
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Events"] %}
|
||||||
|
{% for i in range(1, 7) %}
|
||||||
|
<a href="/demo/step/{{ i }}"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
|
||||||
|
{% if i == step %}bg-violet-600 text-white font-semibold
|
||||||
|
{% elif i < step %}text-slate-300 hover:text-white
|
||||||
|
{% else %}text-slate-600 hover:text-slate-400{% endif %}">
|
||||||
|
<span class="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center
|
||||||
|
{% if i < step %}bg-slate-700 text-slate-300
|
||||||
|
{% elif i == step %}bg-violet-500 text-white
|
||||||
|
{% else %}bg-slate-800 text-slate-600{% endif %}">{{ i }}</span>
|
||||||
|
{{ step_names[i-1] }}
|
||||||
|
</a>
|
||||||
|
{% if i < 6 %}
|
||||||
|
<span class="text-slate-700">›</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|
||||||
|
<!-- Left: info panel -->
|
||||||
|
<div class="lg:col-span-2 space-y-4">
|
||||||
|
|
||||||
|
<!-- Step header -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">{{ step }}</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-white">{{ title }}</h2>
|
||||||
|
<p class="text-slate-400 text-sm">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Endpoint badge -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-slate-500 uppercase tracking-wider">Endpoint</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-mono font-semibold
|
||||||
|
{% if 'v2' in api_version %}bg-violet-900/50 text-violet-300 border border-violet-700/40
|
||||||
|
{% else %}bg-slate-800 text-slate-400{% endif %}">
|
||||||
|
{{ api_version }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<code class="text-sm text-emerald-400 font-mono break-all">{{ endpoint }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
||||||
|
<p class="text-slate-300 text-sm leading-relaxed">{{ description | safe }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- curl example -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL eksempel</span>
|
||||||
|
<button onclick="copyToClipboard('curl-{{ step }}')"
|
||||||
|
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">
|
||||||
|
Kopier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre id="curl-{{ step }}" class="text-xs text-amber-300 font-mono whitespace-pre-wrap leading-relaxed">{{ curl_example }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{% if prev_step %}
|
||||||
|
<a href="/demo/step/{{ prev_step }}"
|
||||||
|
class="flex-1 text-center px-4 py-2.5 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 rounded-xl text-sm transition">
|
||||||
|
← Step {{ prev_step }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if next_step %}
|
||||||
|
<a href="/demo/step/{{ next_step }}"
|
||||||
|
class="flex-1 text-center px-4 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
|
||||||
|
Step {{ next_step }} →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: response panel -->
|
||||||
|
<div class="lg:col-span-3 space-y-4">
|
||||||
|
|
||||||
|
<!-- Tink Link special button (step 3) -->
|
||||||
|
{% if tink_link_url %}
|
||||||
|
|
||||||
|
{% if cb_success %}
|
||||||
|
<!-- Already connected — show success, hide connection UI -->
|
||||||
|
<div class="bg-emerald-950/60 border border-emerald-700/50 rounded-xl p-5 flex items-start gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-emerald-900/60 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg class="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-emerald-300 font-semibold text-base">Bank forbundet!</p>
|
||||||
|
<p class="text-emerald-400/70 text-sm mt-0.5">User token gemt i session. Trin 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: direct callback flow -->
|
||||||
|
<div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-emerald-900/50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white font-semibold mb-1">Tilslut testbank</h3>
|
||||||
|
<p class="text-slate-400 text-sm">Klik knappen, vælg Demo Bank og log ind — du redirectes automatisk tilbage.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div class="bg-slate-800/60 border border-slate-700/50 rounded-lg p-4 text-sm space-y-2">
|
||||||
|
<p class="text-slate-300 font-semibold flex items-center gap-1.5">
|
||||||
|
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
Trin i Tink Link
|
||||||
|
</p>
|
||||||
|
<ol class="text-slate-400 space-y-1.5 list-decimal list-inside leading-relaxed">
|
||||||
|
<li>Vælg <span class="text-slate-300 font-medium">Tink Demo Bank</span></li>
|
||||||
|
<li>Vælg <span class="text-slate-300">Open Banking</span> → <span class="text-slate-300">Password And OTP</span></li>
|
||||||
|
<li>Hent credentials: <span class="font-mono text-amber-300 text-xs bg-slate-900 px-1.5 py-0.5 rounded">Console → Demo Bank → Transactions → DK</span></li>
|
||||||
|
<li>Indtast username + password → OTP vises på siden → Continue</li>
|
||||||
|
<li>Vælg en konto → du redirectes automatisk tilbage her ✓</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Demo Bank credentials hint -->
|
||||||
|
<div class="bg-slate-800/70 border border-amber-700/30 rounded-lg p-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
|
||||||
|
<span class="text-amber-300 text-sm font-semibold">Demo Bank Credentials</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-slate-500 mb-1">Username</div>
|
||||||
|
<div class="flex items-center gap-2 bg-slate-900 border border-slate-700 rounded px-3 py-2">
|
||||||
|
<code id="demo-user" class="text-sm font-mono text-emerald-300 flex-1">u04877810</code>
|
||||||
|
<button onclick="copyText('demo-user', this)"
|
||||||
|
class="text-xs text-slate-500 hover:text-slate-300 transition flex-shrink-0">Kopier</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-slate-500 mb-1">Password</div>
|
||||||
|
<div class="flex items-center gap-2 bg-slate-900 border border-slate-700 rounded px-3 py-2">
|
||||||
|
<code id="demo-pass" class="text-sm font-mono text-emerald-300 flex-1">vxw774</code>
|
||||||
|
<button onclick="copyText('demo-pass', this)"
|
||||||
|
class="text-xs text-slate-500 hover:text-slate-300 transition flex-shrink-0">Kopier</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500 mt-2">Vælg <span class="text-slate-400">Tink Demo Bank → Open Banking → Password And OTP</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<a href="{{ tink_link_url }}"
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition">
|
||||||
|
Åbn Tink Link
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||||
|
</a>
|
||||||
|
{% if demo_bank_users %}
|
||||||
|
<button onclick="document.getElementById('demo-users-modal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-violet-700 text-violet-400 hover:text-violet-200 hover:border-violet-500 rounded-lg text-sm transition">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Vis testbrugere
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/demo/reset"
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 rounded-lg text-sm transition">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
Start forfra
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if demo_bank_users %}
|
||||||
|
<!-- Demo Bank users modal -->
|
||||||
|
<div id="demo-users-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" onclick="document.getElementById('demo-users-modal').classList.add('hidden')"></div>
|
||||||
|
<div class="relative bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white font-semibold text-base">Demo Bank — Testbrugere</h3>
|
||||||
|
<p class="text-slate-500 text-xs mt-0.5">Brug disse kredentialer når du logger ind i Tink Demo Bank. OTP er altid <code class="text-violet-300">1234</code> hvor det er påkrævet.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="document.getElementById('demo-users-modal').classList.add('hidden')"
|
||||||
|
class="text-slate-500 hover:text-white transition p-1.5 rounded-lg hover:bg-slate-800">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto max-h-96">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-800/50 sticky top-0">
|
||||||
|
<tr class="text-slate-400 text-xs uppercase tracking-wider">
|
||||||
|
<th class="px-4 py-2.5 text-left">Market</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">Brugernavn</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">Password</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">OTP</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">Scenarie</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{% for u in demo_bank_users %}
|
||||||
|
<tr class="hover:bg-slate-800/40 transition">
|
||||||
|
<td class="px-4 py-2.5 text-slate-300 font-medium">{{ u.market }}</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-emerald-300 text-xs">{{ u.username }}</code>
|
||||||
|
<button onclick="navigator.clipboard.writeText('{{ u.username }}')" title="Kopier"
|
||||||
|
class="text-slate-600 hover:text-slate-300 transition">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-violet-300 text-xs">{{ u.password }}</code>
|
||||||
|
<button onclick="navigator.clipboard.writeText('{{ u.password }}')" title="Kopier"
|
||||||
|
class="text-slate-600 hover:text-slate-300 transition">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 font-mono text-xs text-slate-400">{{ u.otp or "—" }}</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium {% if 'fejl' in u.scenario.lower() %}bg-red-900/40 text-red-400{% else %}bg-emerald-900/40 text-emerald-400{% endif %}">
|
||||||
|
{{ u.scenario }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-3 border-t border-slate-800 text-xs text-slate-500">
|
||||||
|
Kilde: <a href="https://docs.tink.com/resources/tutorials/test-your-integration-with-demo-bank" target="_blank" class="text-violet-400 hover:text-violet-300 underline">Tink Demo Bank dokumentation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FALLBACK: console.tink.com/callback + manual code paste -->
|
||||||
|
<details class="bg-slate-900 border border-slate-700/30 rounded-xl overflow-hidden">
|
||||||
|
<summary class="px-5 py-3 cursor-pointer text-slate-500 hover:text-slate-400 text-xs flex items-center gap-2 select-none">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Alternativ: manuel kode-indsætning via console.tink.com
|
||||||
|
<span class="ml-auto">▾</span>
|
||||||
|
</summary>
|
||||||
|
<div class="px-5 pb-4 pt-3 border-t border-slate-800 space-y-3">
|
||||||
|
<a href="{{ dev_tink_link_url }}" target="_blank"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm rounded-lg transition">
|
||||||
|
Åbn med console callback
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/demo/step/3" class="flex gap-2 items-stretch">
|
||||||
|
<input type="text" name="code" placeholder="Indsæt code=XXXX fra console.tink.com/callback..."
|
||||||
|
class="flex-1 bg-slate-800 border border-slate-700 text-slate-200 rounded-lg px-3 py-2 text-sm font-mono placeholder-slate-600 focus:outline-none focus:border-emerald-600" required>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg transition whitespace-nowrap">
|
||||||
|
Brug kode
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% endif %}{# end not cb_success #}
|
||||||
|
|
||||||
|
{% endif %}{# end tink_link_url #}
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-red-400 text-xl flex-shrink-0">✕</span>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-red-300 font-semibold mb-1">Fejl</h3>
|
||||||
|
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- JSON response -->
|
||||||
|
{% if result %}
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-800">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||||
|
<span class="text-sm text-slate-300 font-semibold">Response</span>
|
||||||
|
<span class="text-xs text-emerald-400 font-mono">200 OK</span>
|
||||||
|
{% if is_demo %}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-semibold bg-amber-900/60 text-amber-300 border border-amber-700/40">⚠ Sample Data</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button onclick="copyToClipboard('json-{{ step }}')"
|
||||||
|
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">
|
||||||
|
Kopier JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 max-h-[520px] overflow-y-auto">
|
||||||
|
<pre id="json-{{ step }}" class="raw-json">{{ result | tojson(indent=2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif not error %}
|
||||||
|
<!-- Waiting state -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl p-10 flex flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-slate-800 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400 text-sm">Klik på knappen ovenfor for at køre dette API-kald og se svaret her.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
164
src/templates/step2.html
Normal file
164
src/templates/step2.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Step 2: Opret Bruger{% endblock %}
|
||||||
|
|
||||||
|
{% block stepper %}
|
||||||
|
<div class="bg-slate-900/60 border-b border-slate-800">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1 overflow-x-auto">
|
||||||
|
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %}
|
||||||
|
{% for i in range(1, 7) %}
|
||||||
|
<a href="/demo/step/{{ i }}"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
|
||||||
|
{% if i == 2 %}bg-violet-600 text-white font-semibold
|
||||||
|
{% elif i < 2 %}text-slate-300 hover:text-white
|
||||||
|
{% else %}text-slate-600 hover:text-slate-400{% endif %}">
|
||||||
|
<span class="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center
|
||||||
|
{% if i < 2 %}bg-slate-700 text-slate-300
|
||||||
|
{% elif i == 2 %}bg-violet-500 text-white
|
||||||
|
{% else %}bg-slate-800 text-slate-600{% endif %}">{{ i }}</span>
|
||||||
|
{{ step_names[i-1] }}
|
||||||
|
</a>
|
||||||
|
{% if i < 6 %}
|
||||||
|
<span class="text-slate-700">›</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">2</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-white">Opret Bruger</h2>
|
||||||
|
<p class="text-slate-400 text-sm">Create Test Customer</p>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-slate-800 text-slate-400 border border-slate-700">v1</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
|
||||||
|
Opret en Tink-bruger med en <code class="text-violet-300">tink_external_ref</code> —
|
||||||
|
MoneyCapp's interne reference til kunden i Tink. Gemmes i jeres kundedatabase som <code class="text-violet-300">tink_external_ref</code> (ikke jeres interne <code class="text-slate-400">customer_id</code>).
|
||||||
|
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-4 mb-6">
|
||||||
|
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if existing_user_id %}
|
||||||
|
<!-- Already created this session -->
|
||||||
|
<div class="bg-emerald-950/30 border border-emerald-800/40 rounded-xl p-5 mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<span class="w-8 h-8 rounded-full bg-emerald-600/20 border border-emerald-600/40 flex items-center justify-center text-emerald-400 text-lg">✓</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-semibold">Bruger allerede oprettet i denne session</p>
|
||||||
|
<p class="text-slate-400 text-sm">Du kan fortsætte til Step 3, eller oprette en ny bruger nedenfor.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div class="bg-slate-900 rounded-lg p-3">
|
||||||
|
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">external_user_id</p>
|
||||||
|
<code class="text-emerald-300 font-mono text-sm font-semibold">{{ existing_external_id }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-900 rounded-lg p-3">
|
||||||
|
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">Tink user_id</p>
|
||||||
|
<code class="text-slate-300 font-mono text-xs">{{ existing_user_id }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/demo/step/3"
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
|
||||||
|
Fortsæt til Step 3 — Tilslut Bank →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="mb-6">
|
||||||
|
<summary class="cursor-pointer text-sm text-slate-500 hover:text-slate-300 transition">Opret en ny bruger i stedet</summary>
|
||||||
|
<div class="mt-4">
|
||||||
|
{% else %}
|
||||||
|
<div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Create user form -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden max-w-xl">
|
||||||
|
<div class="px-5 py-4 border-b border-slate-800">
|
||||||
|
<p class="text-sm font-semibold text-white">Hvem opretter vi?</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">
|
||||||
|
<code class="text-violet-300">tink_external_ref</code> = jeres reference til kunden i Tink —
|
||||||
|
adskilt fra jeres interne <code class="text-slate-400">customer_id</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Kundenavn</label>
|
||||||
|
<input type="text" name="customer_name"
|
||||||
|
placeholder="fx. Henrik Jess, Kunde A, Test User 1"
|
||||||
|
value="Henrik Jess"
|
||||||
|
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm font-mono
|
||||||
|
placeholder-slate-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/30 transition">
|
||||||
|
<p class="text-xs text-slate-600 mt-1.5">→ <code class="text-violet-300/70">tink_external_ref</code> = <code class="text-violet-300/70">moneycapp-henrik-jess-a3f9c1</code></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label>
|
||||||
|
<select name="market"
|
||||||
|
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm
|
||||||
|
focus:outline-none focus:border-violet-500 transition">
|
||||||
|
<option value="DK" selected>DK — Danmark</option>
|
||||||
|
<option value="SE">SE — Sverige</option>
|
||||||
|
<option value="NO">NO — Norge</option>
|
||||||
|
<option value="GB">GB — United Kingdom</option>
|
||||||
|
<option value="DE">DE — Deutschland</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
{% if not app_token_ok %}disabled{% endif %}
|
||||||
|
class="w-full py-3 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed
|
||||||
|
text-white rounded-xl text-sm font-semibold transition">
|
||||||
|
{% if app_token_ok %}Opret bruger i Tink →{% else %}Kør Step 1 først{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if existing_user_id %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- API info -->
|
||||||
|
<div class="mt-6 bg-slate-900/50 border border-slate-800 rounded-xl p-4">
|
||||||
|
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">API endpoint</p>
|
||||||
|
<code class="text-emerald-400 font-mono text-sm">POST https://api.tink.com/api/v1/user/create</code>
|
||||||
|
<div class="mt-3 bg-slate-950 rounded-lg p-3 overflow-x-auto">
|
||||||
|
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># MoneyCapp DB:
|
||||||
|
# customer_id = 42 ← jeres interne ID (Tink ser det aldrig)
|
||||||
|
# tink_external_ref = "moneycapp-42-a3f9c1" ← Tink-reference
|
||||||
|
|
||||||
|
# Request body
|
||||||
|
{
|
||||||
|
"external_user_id": "moneycapp-<ref>", ← tink_external_ref
|
||||||
|
"market": "DK",
|
||||||
|
"locale": "da_DK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"user_id": "abc123..." ← Tinks interne ID, gem og brug fremadrettet
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="mt-6 flex justify-start">
|
||||||
|
<a href="/demo/step/1"
|
||||||
|
class="px-4 py-2.5 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 rounded-xl text-sm transition">
|
||||||
|
← Step 1
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
245
src/templates/step6.html
Normal file
245
src/templates/step6.html
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Step 6: Webhooks & Events{% endblock %}
|
||||||
|
|
||||||
|
{% block stepper %}
|
||||||
|
<div class="bg-slate-900/60 border-b border-slate-800">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1 overflow-x-auto">
|
||||||
|
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %}
|
||||||
|
{% for i in range(1, 7) %}
|
||||||
|
<a href="/demo/step/{{ i }}"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
|
||||||
|
{% if i == 6 %}bg-violet-600 text-white font-semibold
|
||||||
|
{% elif i < 6 %}text-slate-300 hover:text-white
|
||||||
|
{% else %}text-slate-600 hover:text-slate-400{% endif %}">
|
||||||
|
<span class="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center
|
||||||
|
{% if i < 6 %}bg-slate-700 text-slate-300
|
||||||
|
{% elif i == 6 %}bg-violet-500 text-white
|
||||||
|
{% else %}bg-slate-800 text-slate-600{% endif %}">{{ i }}</span>
|
||||||
|
{{ step_names[i-1] }}
|
||||||
|
</a>
|
||||||
|
{% if i < 6 %}
|
||||||
|
<span class="text-slate-700">›</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">6</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-white">Webhooks & Real-time Events</h2>
|
||||||
|
<p class="text-slate-400 text-sm">Push-notifikationer til din backend</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
|
||||||
|
Registrér dit endpoint hos Tink — så sender de automatisk en HTTP POST til dig
|
||||||
|
hver gang en transaktion bogføres, opdateres eller en konto ændres. Zero polling.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5 mb-6">
|
||||||
|
<p class="text-red-300 text-sm font-semibold mb-1">Webhook API fejl</p>
|
||||||
|
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- How it works banner -->
|
||||||
|
<div class="bg-slate-900/50 border border-violet-800/30 rounded-xl p-5 mb-6">
|
||||||
|
<h3 class="text-white font-semibold mb-3 text-sm">Hvordan webhooks virker</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
|
||||||
|
<span class="text-violet-400 font-mono text-xs block mb-0.5">Din app</span>
|
||||||
|
Brugeren kobler bank
|
||||||
|
</div>
|
||||||
|
<span class="text-slate-600 text-lg">→</span>
|
||||||
|
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
|
||||||
|
<span class="text-emerald-400 font-mono text-xs block mb-0.5">Tink</span>
|
||||||
|
Henter transaktioner
|
||||||
|
</div>
|
||||||
|
<span class="text-slate-600 text-lg">→</span>
|
||||||
|
<div class="bg-violet-900/50 border border-violet-700/50 rounded-lg px-3 py-2 text-slate-200 font-semibold">
|
||||||
|
<span class="text-violet-300 font-mono text-xs block mb-0.5">POST /webhooks/tink</span>
|
||||||
|
Dit endpoint modtager event
|
||||||
|
</div>
|
||||||
|
<span class="text-slate-600 text-lg">→</span>
|
||||||
|
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
|
||||||
|
<span class="text-amber-400 font-mono text-xs block mb-0.5">Din app</span>
|
||||||
|
Opdaterer UI / notifikation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
<!-- List webhooks -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-semibold text-white">Registrerede webhooks</span>
|
||||||
|
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /api/v1/webhooks</code>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700 font-mono">app token</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
|
||||||
|
<button onclick="copyToClipboard('curl-list')"
|
||||||
|
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
|
||||||
|
</div>
|
||||||
|
<pre id="curl-list" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_list }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 max-h-80 overflow-y-auto">
|
||||||
|
{% if result_webhooks %}
|
||||||
|
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
|
||||||
|
<pre class="raw-json">{{ result_webhooks | tojson(indent=2) }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate-500 text-sm text-center py-6">Ingen webhooks registreret endnu.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register webhook -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-semibold text-white">Registrér webhook endpoint</span>
|
||||||
|
<code class="block text-xs text-emerald-400 font-mono mt-0.5">POST /api/v1/webhooks</code>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800/50 font-mono">registreret ✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
|
||||||
|
<button onclick="copyToClipboard('curl-register')"
|
||||||
|
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
|
||||||
|
</div>
|
||||||
|
<pre id="curl-register" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_register }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 max-h-80 overflow-y-auto">
|
||||||
|
{% if webhook_registered %}
|
||||||
|
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
|
||||||
|
<pre class="raw-json">{{ webhook_registered | tojson(indent=2) }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate-500 text-sm text-center py-6">Webhook ikke registreret.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sample event payload -->
|
||||||
|
<div class="mt-6 bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-800">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-semibold text-white">Sample Webhook Payload</span>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">Sådan ser en event ud når Tink poster til dit endpoint</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900/50 text-amber-300 border border-amber-800/50 font-mono">incoming POST</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 overflow-x-auto">
|
||||||
|
<pre class="text-xs text-emerald-300 font-mono whitespace-pre">{
|
||||||
|
"event": "account-booked-transaction:created",
|
||||||
|
"context": {
|
||||||
|
"userId": "a8b3c2d1-...",
|
||||||
|
"externalUserId": "moneycapp-user-42"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"id": "tx_9f3a2b1c...",
|
||||||
|
"accountId": "acc_7e1d4f2a...",
|
||||||
|
"amount": {
|
||||||
|
"currencyCode": "DKK",
|
||||||
|
"value": { "scale": 2, "unscaledValue": "-24900" }
|
||||||
|
},
|
||||||
|
"dates": {
|
||||||
|
"booked": "2025-05-22",
|
||||||
|
"value": "2025-05-22"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"display": "Netto Albertslund",
|
||||||
|
"original": "NETTO ALBERTSLUND"
|
||||||
|
},
|
||||||
|
"merchantInformation": {
|
||||||
|
"merchantCategoryCode": "5411",
|
||||||
|
"merchantName": "Netto"
|
||||||
|
},
|
||||||
|
"status": "BOOKED",
|
||||||
|
"types": { "type": "DEFAULT" }
|
||||||
|
}
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Your receiver endpoint -->
|
||||||
|
<div class="mt-4 bg-slate-900/60 border border-slate-700/50 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-emerald-600/20 border border-emerald-600/40 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white text-sm font-semibold">Dit webhook modtager endpoint er live</p>
|
||||||
|
<code class="text-emerald-400 text-xs font-mono">POST {{ app_base_url }}/webhooks/tink</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="text-sm text-slate-400">
|
||||||
|
<summary class="cursor-pointer hover:text-white transition text-xs font-semibold uppercase tracking-wider text-slate-500">Se receiver kode</summary>
|
||||||
|
<div class="mt-3 bg-slate-950 rounded-lg p-4 overflow-x-auto">
|
||||||
|
<pre class="text-xs text-emerald-300 font-mono whitespace-pre">@router.post("/webhooks/tink")
|
||||||
|
async def tink_webhook(request: Request):
|
||||||
|
payload = await request.json()
|
||||||
|
event_type = payload.get("event", "unknown")
|
||||||
|
content = payload.get("content", {})
|
||||||
|
|
||||||
|
# Her ville MoneyCapp opdatere sin database,
|
||||||
|
# sende push-notifikation, opdatere UI via SSE etc.
|
||||||
|
|
||||||
|
print(f"Tink event: {event_type}")
|
||||||
|
print(f"Transaction ID: {content.get('id')}")
|
||||||
|
print(f"Amount: {content.get('amount')}")
|
||||||
|
|
||||||
|
return {"status": "received"}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Final CTA -->
|
||||||
|
<div class="mt-8 bg-gradient-to-br from-violet-900/30 to-indigo-900/20 border border-violet-700/30 rounded-2xl p-8 text-center">
|
||||||
|
<div class="text-4xl mb-3">🎉</div>
|
||||||
|
<h3 class="text-2xl font-bold text-white mb-2">Det var hele flowet</h3>
|
||||||
|
<p class="text-slate-400 mb-2 max-w-lg mx-auto">
|
||||||
|
Fra brugeroprettelse over live bankdata til real-time webhooks — alt via Tink API.
|
||||||
|
</p>
|
||||||
|
<p class="text-slate-500 text-sm mb-6 max-w-xl mx-auto">
|
||||||
|
Vi har vist: auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
|
||||||
|
Det er præcis hvad MoneyCapp mangler for at gøre deres integration robust.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3 justify-center flex-wrap">
|
||||||
|
<a href="/demo/reset" class="px-5 py-2.5 border border-slate-600 text-slate-300 hover:text-white hover:border-slate-400 rounded-xl text-sm transition">↺ Kør demo igen</a>
|
||||||
|
<a href="https://docs.tink.com/api-introduction" target="_blank"
|
||||||
|
class="px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
|
||||||
|
Tink Docs →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="mt-4 flex justify-start">
|
||||||
|
<a href="/demo/step/5"
|
||||||
|
class="px-4 py-2.5 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 rounded-xl text-sm transition">
|
||||||
|
← Step 5
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
0
src/tink/__init__.py
Normal file
0
src/tink/__init__.py
Normal file
279
src/tink/client.py
Normal file
279
src/tink/client.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
Tink API client — async httpx wrapper covering auth, users,
|
||||||
|
accounts (v2), transactions (v2), events (v2), and bank connectivity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import httpx
|
||||||
|
from typing import Optional, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TinkTokens:
|
||||||
|
app_token: str = ""
|
||||||
|
user_token: str = ""
|
||||||
|
user_id: str = ""
|
||||||
|
external_user_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _log_entry(method: str, url: str, req_body: dict | None,
|
||||||
|
status: int, resp_body: dict, duration_ms: int) -> dict:
|
||||||
|
def _redact(d: dict) -> dict:
|
||||||
|
"""Replace long string values (JWT tokens etc.) with truncated versions."""
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return d
|
||||||
|
out = {}
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, str) and len(v) > 60:
|
||||||
|
out[k] = v[:12] + "…[redacted]"
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
out[k] = _redact(v)
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
return {
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"req_body": _redact(req_body) if req_body else req_body,
|
||||||
|
"status": status,
|
||||||
|
"resp_body": _redact(resp_body),
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"ts": time.strftime("%H:%M:%S"),
|
||||||
|
"ok": status < 400,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TinkClient:
|
||||||
|
def __init__(self, client_id: str, client_secret: str, redirect_uri: str,
|
||||||
|
api_base: str = "https://api.tink.com",
|
||||||
|
link_base: str = "https://link.tink.com",
|
||||||
|
on_request: Callable[[dict], None] | None = None):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.redirect_uri = redirect_uri
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self.link_base = link_base.rstrip("/")
|
||||||
|
self.on_request = on_request # callback(log_entry) called after each API call
|
||||||
|
|
||||||
|
def _log(self, method: str, url: str, req_body, status: int,
|
||||||
|
resp_body: dict, duration_ms: int):
|
||||||
|
if self.on_request:
|
||||||
|
self.on_request(_log_entry(method, url, req_body, status, resp_body, duration_ms))
|
||||||
|
|
||||||
|
async def _get(self, url: str, headers: dict, params: dict | None = None) -> dict:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url, headers=headers, params=params)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
body = resp.json() if resp.content else {}
|
||||||
|
self._log("GET", url, params, resp.status_code, body, ms)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def _post(self, url: str, headers: dict,
|
||||||
|
data: dict | None = None, json_body: dict | None = None) -> dict:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(url, headers=headers, data=data, json=json_body)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
body = resp.json() if resp.content else {}
|
||||||
|
self._log("POST", url, json_body or data, resp.status_code, body, ms)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return body
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Authentication
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_app_token(self, scope: str = "user:create") -> dict:
|
||||||
|
"""Client credentials flow — returns app-level token."""
|
||||||
|
return await self._post(
|
||||||
|
f"{self.api_base}/api/v1/oauth/token",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
data={
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": scope,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def exchange_code_for_token(self, code: str,
|
||||||
|
redirect_uri: str | None = None) -> dict:
|
||||||
|
data: dict = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
}
|
||||||
|
if redirect_uri:
|
||||||
|
data["redirect_uri"] = redirect_uri
|
||||||
|
return await self._post(
|
||||||
|
f"{self.api_base}/api/v1/oauth/token",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Users (v1)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_user(self, app_token: str, external_user_id: str,
|
||||||
|
market: str = "DK", locale: str = "da_DK") -> dict:
|
||||||
|
return await self._post(
|
||||||
|
f"{self.api_base}/api/v1/user/create",
|
||||||
|
headers={"Authorization": f"Bearer {app_token}"},
|
||||||
|
json_body={"external_user_id": external_user_id, "market": market, "locale": locale},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user(self, user_token: str) -> dict:
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/api/v1/user",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_authorization_grant_token(self, app_token: str, user_id: str,
|
||||||
|
scope: str) -> dict:
|
||||||
|
return await self._post(
|
||||||
|
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {app_token}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
data={"actor_client_id": self.client_id, "user_id": user_id, "scope": scope},
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Tink Link — bank connection URL (no HTTP call, just URL builder)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_tink_link_url(self, market: str = "DK",
|
||||||
|
authorization_code: str | None = None,
|
||||||
|
redirect_uri_override: str | None = None) -> str:
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
params: dict = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"redirect_uri": redirect_uri_override or self.redirect_uri,
|
||||||
|
"market": market,
|
||||||
|
"locale": "da_DK",
|
||||||
|
}
|
||||||
|
if authorization_code:
|
||||||
|
# scope is already embedded in the authorization_code grant — do NOT add it again
|
||||||
|
params["authorization_code"] = authorization_code
|
||||||
|
else:
|
||||||
|
# anonymous flow — scope must be explicit
|
||||||
|
params["scope"] = "accounts:read,transactions:read,credentials:read"
|
||||||
|
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Credentials (v1)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_credentials(self, user_token: str) -> dict:
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/api/v1/credentials/list",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Accounts — v2
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_accounts(self, user_token: str, page_size: int = 50,
|
||||||
|
page_token: Optional[str] = None) -> dict:
|
||||||
|
params: dict = {"pageSize": page_size}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/data/v2/accounts",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_account(self, user_token: str, account_id: str) -> dict:
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/data/v2/accounts/{account_id}",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Transactions — v2
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_transactions(self, user_token: str, page_size: int = 25,
|
||||||
|
page_token: Optional[str] = None,
|
||||||
|
account_id: Optional[str] = None) -> dict:
|
||||||
|
params: dict = {"pageSize": page_size}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
if account_id:
|
||||||
|
params["accountIdIn"] = account_id
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/data/v2/transactions",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_transaction(self, user_token: str, transaction_id: str) -> dict:
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/data/v2/transactions/{transaction_id}",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Events — v2
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_account_transaction_events(
|
||||||
|
self, user_token: str, page_size: int = 25,
|
||||||
|
page_token: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
params: dict = {"pageSize": page_size}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/events/v2/account-transactions",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_booked_transaction_events(
|
||||||
|
self, user_token: str, page_size: int = 25,
|
||||||
|
page_token: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
params: dict = {"pageSize": page_size}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/events/v2/account-booked-transactions",
|
||||||
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Webhooks — app-level
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def list_webhooks(self, app_token: str) -> dict:
|
||||||
|
return await self._get(
|
||||||
|
f"{self.api_base}/api/v1/webhooks",
|
||||||
|
headers={"Authorization": f"Bearer {app_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def register_webhook(self, app_token: str, url: str,
|
||||||
|
enabled_events: list[str] | None = None) -> dict:
|
||||||
|
return await self._post(
|
||||||
|
f"{self.api_base}/api/v1/webhooks",
|
||||||
|
headers={"Authorization": f"Bearer {app_token}"},
|
||||||
|
json_body={
|
||||||
|
"url": url,
|
||||||
|
"enabledEvents": enabled_events or [
|
||||||
|
"account-booked-transaction:created",
|
||||||
|
"account-pending-transaction:created",
|
||||||
|
"account-pending-transaction:updated",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user