docs: developer documentation & customer-facing polish
All checks were successful
Build and Deploy / deploy (push) Successful in 45s
All checks were successful
Build and Deploy / deploy (push) Successful in 45s
Code documentation: - client.py: docstrings with Tink API docs URLs on every method - demo.py: docstrings on all route handlers explaining Tink flow context - webhook receiver: C# HMAC-SHA256 signature verification example Customer-facing cleanup: - Removed 'sales demo' / 'MoneyCapp × Tink' internal branding - Neutral footer, consistent terminology (external_user_id, not tink_external_ref) - Sandbox note on Step 3: anonymous flow vs production authorization_code flow - Step 6: 'Next Steps for C#/.NET implementation' section - demo_data.py: dynamic relative dates (no hardcoded year) - print() → logging.getLogger, /debug-session gated behind DEMO_MODE - Step 1 always resets session state (fresh start on every visit) - README: neutral/collaborative tone, what-it-is-not section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
84
README.md
84
README.md
@@ -1,60 +1,52 @@
|
||||
# MoneyCapp × Tink API Demo
|
||||
# Tink API Demo
|
||||
|
||||
"Sales-y Swagger" — step-for-step gennemgang af Tink integrationsflowet med live JSON responses.
|
||||
En simpel Python-demo der viser Tink open banking-flowet fra ende til anden — bygget som proof-of-concept til at illustrere hvordan integrationen kan se ud i praksis.
|
||||
|
||||
## Hvad det er
|
||||
Formålet er ikke at erstatte en produktion C#/Umbraco-implementation, men at vise at Tink-flowet er veldokumenteret og relativt ligetil at bygge — uanset platform.
|
||||
|
||||
En hosted demo-app der viser hele Tink onboarding-flowet:
|
||||
## Hvad demo'en viser
|
||||
|
||||
| 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** |
|
||||
| Step | Hvad sker der | Tink endpoint |
|
||||
|------|---------------|---------------|
|
||||
| 1 | Hent app token (client credentials) | `POST /api/v1/oauth/token` |
|
||||
| 2 | Opret kunde i Tink med ekstern reference | `POST /api/v1/user/create` |
|
||||
| 3 | Åbn Tink Link — brugeren tilslutter sin bank | Tink Link v1 |
|
||||
| 4 | Hent brugerens konti med balances | `GET /data/v2/accounts` |
|
||||
| 5 | Hent transaktioner | `GET /data/v2/transactions` |
|
||||
| 6 | Webhook-registrering til real-time notifikationer | `POST /events/v2/webhook-endpoints` |
|
||||
|
||||
## Quick start (lokal dev)
|
||||
Live JSON responses på hvert trin — præcis som det vil se ud i en reel integration.
|
||||
|
||||
```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
|
||||
## Kør det selv
|
||||
|
||||
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
|
||||
|
||||
> **Kun relevant for i80-infrastruktur.** For din egen infra: byg Docker image og kør med env vars.
|
||||
|
||||
1. Læg credentials i Consul KV:
|
||||
```bash
|
||||
consul kv put tink-demo/TINK_CLIENT_ID <din_client_id>
|
||||
consul kv put tink-demo/TINK_CLIENT_SECRET <din_client_secret>
|
||||
```
|
||||
2. Tilføj `https://tink-demo.i80.dk/callback` som Redirect URI i Tink Console
|
||||
3. Push til `main` → Gitea Actions bygger og deployer automatisk
|
||||
|
||||
## Docker (self-hosted)
|
||||
Du skal bruge egne Tink sandbox-credentials fra [console.tink.com](https://console.tink.com).
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Udfyld TINK_CLIENT_ID og TINK_CLIENT_SECRET
|
||||
# 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
|
||||
make run
|
||||
# Åbn http://localhost:8000
|
||||
```
|
||||
|
||||
Eller med Docker:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up
|
||||
```
|
||||
|
||||
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`
|
||||
## Tink Console opsætning
|
||||
|
||||
1. Gå til [console.tink.com](https://console.tink.com) → opret en app
|
||||
2. Kopiér **Client ID** og **Client Secret** til `.env`
|
||||
3. Under **Redirect URIs**: tilføj `http://localhost:8000/callback`
|
||||
4. Under **Scopes**: aktiver `accounts:read`, `transactions:read`, `credentials:read`, `user:create`
|
||||
|
||||
## Hvad det ikke er
|
||||
|
||||
Dette er en demo — ikke produktionskode. Der er ingen database, ingen brugerstyring og tokens lever kun i hukommelsen. En reel implementation vil naturligvis bygge videre på jeres eksisterende platform og arkitektur.
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ Used when DEMO_MODE=true or when no bank is connected.
|
||||
Data mimics real Tink API v2 responses for a Danish user.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
_today = date.today()
|
||||
_d = lambda offset=0: (_today + timedelta(days=offset)).isoformat()
|
||||
_dt = lambda offset=0: f"{_d(offset)}T08:00:00Z"
|
||||
|
||||
MOCK_CREDENTIALS = {
|
||||
"credentials": [
|
||||
{
|
||||
@@ -12,8 +18,8 @@ MOCK_CREDENTIALS = {
|
||||
"providerName": "dk-demobank-open-banking-embedded",
|
||||
"type": "PASSWORD",
|
||||
"status": "UPDATED",
|
||||
"statusUpdated": "2026-05-22T08:00:00Z",
|
||||
"updated": "2026-05-22T08:00:00Z",
|
||||
"statusUpdated": _dt(),
|
||||
"updated": _dt(),
|
||||
"fields": {"username": "demo_user_001"},
|
||||
}
|
||||
]
|
||||
@@ -37,7 +43,7 @@ MOCK_ACCOUNTS = {
|
||||
"iban": {"iban": "DK5000400440116243"},
|
||||
"financialInstitution": {"accountNumber": "0440116243"},
|
||||
},
|
||||
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
|
||||
"dates": {"lastRefreshed": _dt()},
|
||||
"financialInstitutionId": "dk-demobank",
|
||||
},
|
||||
{
|
||||
@@ -53,7 +59,7 @@ MOCK_ACCOUNTS = {
|
||||
"iban": {"iban": "DK5000400440116244"},
|
||||
"financialInstitution": {"accountNumber": "0440116244"},
|
||||
},
|
||||
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
|
||||
"dates": {"lastRefreshed": _dt()},
|
||||
"financialInstitutionId": "dk-demobank",
|
||||
},
|
||||
],
|
||||
@@ -66,7 +72,7 @@ MOCK_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"},
|
||||
"dates": {"booked": _d(), "value": _d()},
|
||||
"description": "Spotify AB",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
||||
@@ -77,7 +83,7 @@ MOCK_TRANSACTIONS = {
|
||||
"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"},
|
||||
"dates": {"booked": _d(-1), "value": _d(-1)},
|
||||
"description": "Netto Supermarked",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
||||
@@ -88,8 +94,8 @@ MOCK_TRANSACTIONS = {
|
||||
"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",
|
||||
"dates": {"booked": _d(-2), "value": _d(-2)},
|
||||
"description": f"Løn {_today.strftime('%b %Y')}",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "income.salary", "name": "Løn"}},
|
||||
"types": {"type": "DEFAULT"},
|
||||
@@ -98,7 +104,7 @@ MOCK_TRANSACTIONS = {
|
||||
"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"},
|
||||
"dates": {"booked": _d(-3), "value": _d(-3)},
|
||||
"description": "Matas Strøget",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}},
|
||||
@@ -109,7 +115,7 @@ MOCK_TRANSACTIONS = {
|
||||
"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"},
|
||||
"dates": {"booked": _d(-21), "value": _d(-21)},
|
||||
"description": "Husleje maj",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}},
|
||||
@@ -119,7 +125,7 @@ MOCK_TRANSACTIONS = {
|
||||
"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"},
|
||||
"dates": {"booked": _d(-4), "value": _d(-4)},
|
||||
"description": "DSB Rejse",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}},
|
||||
@@ -130,7 +136,7 @@ MOCK_TRANSACTIONS = {
|
||||
"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"},
|
||||
"dates": {"booked": _d(-5), "value": _d(-5)},
|
||||
"description": "Netflix International",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
||||
@@ -141,7 +147,7 @@ MOCK_TRANSACTIONS = {
|
||||
"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"},
|
||||
"dates": {"booked": _d(-6), "value": _d(-6)},
|
||||
"description": "Fakta Falkoner",
|
||||
"status": "BOOKED",
|
||||
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
||||
@@ -152,7 +158,7 @@ MOCK_TRANSACTIONS = {
|
||||
"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"},
|
||||
"dates": {"booked": _d(1), "value": _d(1)},
|
||||
"description": "7-Eleven Nørreport",
|
||||
"status": "PENDING",
|
||||
"categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}},
|
||||
@@ -169,26 +175,26 @@ MOCK_EVENTS_BOOKED = {
|
||||
"id": "evt-booked-001",
|
||||
"type": "account-booked-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-22T08:12:33Z",
|
||||
"created": _dt(),
|
||||
"transaction": {
|
||||
"id": "tx-001-demo",
|
||||
"description": "Spotify AB",
|
||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"status": "BOOKED",
|
||||
"dates": {"booked": "2026-05-22"},
|
||||
"dates": {"booked": _d()},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "evt-booked-002",
|
||||
"type": "account-booked-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-21T14:22:10Z",
|
||||
"created": _dt(-1),
|
||||
"transaction": {
|
||||
"id": "tx-002-demo",
|
||||
"description": "Netto Supermarked",
|
||||
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"status": "BOOKED",
|
||||
"dates": {"booked": "2026-05-21"},
|
||||
"dates": {"booked": _d(-1)},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -201,26 +207,26 @@ MOCK_EVENTS_ALL = {
|
||||
"id": "evt-pending-001",
|
||||
"type": "account-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-23T11:05:44Z",
|
||||
"created": _dt(1),
|
||||
"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"},
|
||||
"dates": {"booked": _d(1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "evt-booked-001",
|
||||
"type": "account-booked-transaction",
|
||||
"accountId": "acc-8f3a2e1b-demo",
|
||||
"created": "2026-05-22T08:12:33Z",
|
||||
"created": _dt(),
|
||||
"transaction": {
|
||||
"id": "tx-001-demo",
|
||||
"description": "Spotify AB",
|
||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
||||
"status": "BOOKED",
|
||||
"dates": {"booked": "2026-05-22"},
|
||||
"dates": {"booked": _d()},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@ settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="Tink API Demo",
|
||||
description="MoneyCapp × Tink — sales demo showing v2 API endpoints",
|
||||
description="Tink Open Banking API — integration walkthrough covering auth, users, accounts (v2), transactions (v2) and webhooks.",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ with the live JSON response and a curl example.
|
||||
import json
|
||||
import uuid
|
||||
import secrets
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Form, HTTPException
|
||||
@@ -18,6 +21,7 @@ from src import demo_data
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="src/templates")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _client(log_cb=None) -> TinkClient:
|
||||
@@ -40,7 +44,6 @@ def _session(request: Request) -> dict:
|
||||
# Server-side token store — keeps JWTs OUT of the session cookie
|
||||
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead)
|
||||
# ---------------------------------------------------------------------------
|
||||
import asyncio
|
||||
|
||||
_token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
|
||||
_callback_locks: dict[str, asyncio.Lock] = {} # sid → Lock (prevents concurrent code exchange)
|
||||
@@ -112,7 +115,10 @@ async def reset_demo(request: Request):
|
||||
|
||||
@router.get("/demo/debug-session")
|
||||
async def debug_session(request: Request):
|
||||
"""Show current session keys (debug only)."""
|
||||
"""Session state inspector — only available when DEMO_MODE=true."""
|
||||
s = get_settings()
|
||||
if not s.demo_mode:
|
||||
raise HTTPException(status_code=404)
|
||||
sess = _session(request)
|
||||
safe = {
|
||||
k: (v[:20] + "…" if isinstance(v, str) and len(v) > 20 else v)
|
||||
@@ -130,7 +136,19 @@ async def debug_session(request: Request):
|
||||
|
||||
@router.get("/demo/step/1", response_class=HTMLResponse)
|
||||
async def step1(request: Request):
|
||||
sess = _session(request)
|
||||
"""
|
||||
Step 1: Client Credentials authentication.
|
||||
Fetches an app-level token with scope 'user:create,authorization:grant'.
|
||||
Docs: https://docs.tink.com/api#connectivity/oauth/create-an-oauth-token
|
||||
"""
|
||||
# Step 1 always starts a clean session — equivalent to reset
|
||||
old_sid = request.session.get("demo", {}).get("sid", "")
|
||||
if old_sid:
|
||||
_token_store.pop(old_sid, None)
|
||||
_callback_locks.pop(old_sid, None)
|
||||
request.session.pop("demo", None)
|
||||
|
||||
sess = _session(request) # creates a fresh demo dict with a new sid
|
||||
client = _client(log_cb=_logger(sess))
|
||||
s = get_settings()
|
||||
error = None
|
||||
@@ -169,6 +187,10 @@ async def step1(request: Request):
|
||||
|
||||
@router.get("/demo/step/2", response_class=HTMLResponse)
|
||||
async def step2_get(request: Request):
|
||||
"""
|
||||
Step 2 (GET): Render user creation form.
|
||||
Shows existing user if already created in this session.
|
||||
"""
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
app_token = _load_token(sess, "app_token")
|
||||
@@ -196,6 +218,12 @@ async def step2_get(request: Request):
|
||||
async def step2_post(request: Request,
|
||||
customer_name: str = Form(default=""),
|
||||
market: str = Form(default="DK")):
|
||||
"""
|
||||
Step 2 (POST): Create a Tink user tied to a customer reference.
|
||||
external_user_id is built from the customer name + random hex suffix to ensure uniqueness.
|
||||
Tink returns a user_id (UUID) that is stored in session for subsequent calls.
|
||||
Docs: https://docs.tink.com/api#user/create-user
|
||||
"""
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
app_token = _load_token(sess, "app_token")
|
||||
@@ -259,6 +287,13 @@ CONSOLE_CALLBACK = "https://console.tink.com/callback"
|
||||
|
||||
@router.get("/demo/step/3", response_class=HTMLResponse)
|
||||
async def step3_get(request: Request):
|
||||
"""
|
||||
Step 3: Bank connection via Tink Link.
|
||||
Calls authorization-grant/delegate to get a one-time code, then builds the
|
||||
Tink Link URL that redirects the user to select and log into their bank.
|
||||
After successful bank login, Tink redirects back to /callback with an auth code.
|
||||
Docs: https://docs.tink.com/resources/tink-link/tink-link-web-permanent-users
|
||||
"""
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
client = _client(log_cb=_logger(sess))
|
||||
@@ -408,9 +443,9 @@ async def tink_callback(request: Request, code: 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())}")
|
||||
logger.info("Tink callback: code=%r error=%r", code, error)
|
||||
if error:
|
||||
print(f"[CALLBACK] Tink returned error: {error}")
|
||||
logger.warning("Tink returned error: %s", error)
|
||||
return RedirectResponse(f"/demo/step/3?error={error}")
|
||||
if code:
|
||||
sid = sess.get("sid", "unknown")
|
||||
@@ -418,31 +453,29 @@ async def tink_callback(request: Request, code: Optional[str] = None,
|
||||
_callback_locks[sid] = asyncio.Lock()
|
||||
async with _callback_locks[sid]:
|
||||
if _load_token(sess, "user_token"):
|
||||
print(f"[CALLBACK] Already have user_token — skipping duplicate exchange")
|
||||
logger.debug("Already have user_token — skipping duplicate code exchange")
|
||||
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||
try:
|
||||
s = get_settings()
|
||||
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
|
||||
logger.info("Exchanging code for token, redirect_uri=%r", s.tink_redirect_uri)
|
||||
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}")
|
||||
logger.error("Token exchange succeeded but access_token missing: %s", 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)")
|
||||
logger.info("user_token saved (%d chars)", len(user_token))
|
||||
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()}")
|
||||
logger.exception("Token exchange failed: %s", e)
|
||||
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
|
||||
print(f"[CALLBACK] No code — bare redirect to step 3")
|
||||
logger.debug("Callback with no code — redirecting to step 3")
|
||||
return RedirectResponse("/demo/step/3", status_code=303)
|
||||
|
||||
|
||||
@@ -452,6 +485,11 @@ async def tink_callback(request: Request, code: Optional[str] = None,
|
||||
|
||||
@router.get("/demo/step/4", response_class=HTMLResponse)
|
||||
async def step4(request: Request):
|
||||
"""
|
||||
Step 4: List accounts (v2 endpoint).
|
||||
Requires a user_token from the callback. Falls back to mock data if demo_mode is set.
|
||||
Docs: https://docs.tink.com/api#account-service-v2/account/list-accounts
|
||||
"""
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
user_token = _load_token(sess, "user_token")
|
||||
@@ -501,6 +539,11 @@ async def step4(request: Request):
|
||||
|
||||
@router.get("/demo/step/5", response_class=HTMLResponse)
|
||||
async def step5(request: Request, account_id: Optional[str] = None):
|
||||
"""
|
||||
Step 5: List transactions (v2 endpoint), optionally filtered by account_id.
|
||||
Uses cursor-based pagination via nextPageToken.
|
||||
Docs: https://docs.tink.com/api#transaction-service-v2/transaction/list-transactions
|
||||
"""
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
user_token = _load_token(sess, "user_token")
|
||||
@@ -549,6 +592,12 @@ async def step5(request: Request, account_id: Optional[str] = None):
|
||||
|
||||
@router.get("/demo/step/6", response_class=HTMLResponse)
|
||||
async def step6(request: Request):
|
||||
"""
|
||||
Step 6: Webhooks — list existing and register a new endpoint.
|
||||
Demonstrates real-time event delivery (account updates, new transactions).
|
||||
Webhook receiver is at /webhooks/tink — logs payloads to api_log.
|
||||
Docs: https://docs.tink.com/api#webhook/create-webhook-endpoint
|
||||
"""
|
||||
sess = _session(request)
|
||||
s = get_settings()
|
||||
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||
@@ -657,8 +706,20 @@ async def clear_log(request: Request):
|
||||
|
||||
@router.post("/webhooks/tink")
|
||||
async def webhook_receiver(request: Request):
|
||||
"""Receive Tink webhook events (configure URL in Tink Console)."""
|
||||
"""
|
||||
Receive Tink webhook events posted by Tink after bank data refresh.
|
||||
|
||||
IMPORTANT — In production, verify Tink's HMAC-SHA256 signature before processing:
|
||||
Docs: https://docs.tink.com/api#webhook/webhook-endpoints
|
||||
|
||||
C# example:
|
||||
var signature = Request.Headers["X-Tink-Signature"];
|
||||
var isValid = VerifyHmacSha256(requestBody, signature, webhookSecret);
|
||||
if (!isValid) return Unauthorized();
|
||||
|
||||
Store the event payload in your database and trigger business logic
|
||||
(e.g., refresh account balances, notify the customer).
|
||||
"""
|
||||
body = await request.json()
|
||||
# In production you'd verify the signature and store events
|
||||
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
|
||||
logger.info("Webhook received: %s", json.dumps(body)[:200])
|
||||
return {"status": "received"}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<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>
|
||||
<span class="text-slate-400 text-sm ml-2">Open Banking Demo</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
<!-- 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
|
||||
Tink Open Banking API Demo
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -117,6 +117,19 @@
|
||||
{% else %}
|
||||
<!-- Not yet connected — show connection UI -->
|
||||
|
||||
<!-- Sandbox note: anonymous flow vs production -->
|
||||
<div class="bg-amber-950/40 border border-amber-700/40 rounded-xl px-5 py-3.5 flex items-start gap-3 text-sm">
|
||||
<svg class="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" 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>
|
||||
<div class="text-amber-200/80 leading-relaxed">
|
||||
<span class="font-semibold text-amber-300">Sandbox-note:</span>
|
||||
Dette demo bruger <em>anonymous Tink Link flow</em> (ingen <code class="font-mono text-xs">authorization_code</code> i URL'en) —
|
||||
en sandbox-begrænsning. I produktion <strong>skal</strong> du kalde
|
||||
<code class="font-mono text-xs">authorization-grant/delegate</code> og sende koden med til Tink Link,
|
||||
så bank-forbindelsen bindes til den korrekte Tink-bruger.
|
||||
<a href="https://docs.tink.com/resources/tink-link/tink-link-web-permanent-users" target="_blank" class="text-amber-400 underline hover:text-amber-300 ml-1">Docs ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
|
||||
@@ -38,9 +38,11 @@
|
||||
<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.
|
||||
Opret en Tink-bruger med et <code class="text-violet-300">external_user_id</code> —
|
||||
jeres interne reference til kunden (f.eks. et kundenummer eller UUID fra jeres database).
|
||||
Gem dette som en kolonne i jeres kundedatabase (<code class="text-violet-300">tink_user_ref</code> el.lign.) —
|
||||
det er ikke det samme som jeres interne <code class="text-slate-400">customer_id</code>.
|
||||
Tink returnerer et <code class="text-violet-300">user_id</code> (UUID) der bruges i efterfølgende kald.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -89,8 +91,8 @@
|
||||
<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>
|
||||
<code class="text-violet-300">external_user_id</code> = jeres interne kundereference —
|
||||
gemmes i jeres database som f.eks. <code class="text-slate-400">tink_user_ref</code>
|
||||
</p>
|
||||
</div>
|
||||
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
|
||||
@@ -101,7 +103,7 @@
|
||||
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>
|
||||
<p class="text-xs text-slate-600 mt-1.5">→ <code class="text-violet-300/70">external_user_id</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>
|
||||
@@ -136,13 +138,13 @@
|
||||
<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
|
||||
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># Your DB schema (example):
|
||||
# customer_id = 42 ← your internal ID (Tink never sees this)
|
||||
# tink_user_ref = "moneycapp-42-a3f9c1" ← store Tink's external_user_id here
|
||||
|
||||
# Request body
|
||||
{
|
||||
"external_user_id": "moneycapp-<ref>", ← tink_external_ref
|
||||
"external_user_id": "moneycapp-<ref>", ← your reference, stored in DB
|
||||
"market": "DK",
|
||||
"locale": "da_DK"
|
||||
}
|
||||
|
||||
@@ -223,8 +223,7 @@ async def tink_webhook(request: Request):
|
||||
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.
|
||||
Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
|
||||
</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>
|
||||
@@ -235,6 +234,33 @@ async def tink_webhook(request: Request):
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Steps for implementation -->
|
||||
<div class="mt-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-7">
|
||||
<h4 class="text-white font-semibold text-base mb-4 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
||||
Næste skridt mod en produktion-klar integration
|
||||
</h4>
|
||||
<div class="grid md:grid-cols-2 gap-3 text-sm text-slate-400">
|
||||
<div class="bg-slate-800/60 rounded-lg p-4 space-y-1.5">
|
||||
<p class="text-violet-300 font-semibold text-xs uppercase tracking-wider mb-2">Backend (C# / .NET)</p>
|
||||
<p>1. Lav en <code class="text-violet-300 font-mono text-xs">TinkApiClient</code> wrapper (brug <code class="text-xs font-mono">tink/client.py</code> som reference)</p>
|
||||
<p>2. Gem <code class="text-violet-300 font-mono text-xs">external_user_id</code> + <code class="text-violet-300 font-mono text-xs">user_id</code> i din kundedatabase</p>
|
||||
<p>3. Implementér <code class="text-violet-300 font-mono text-xs">/callback</code> endpoint med token exchange</p>
|
||||
<p>4. Gem tokens sikkert (encrypted, server-side — ikke i cookie)</p>
|
||||
</div>
|
||||
<div class="bg-slate-800/60 rounded-lg p-4 space-y-1.5">
|
||||
<p class="text-violet-300 font-semibold text-xs uppercase tracking-wider mb-2">Webhooks & Production</p>
|
||||
<p>5. Byg webhook receiver med <a href="https://docs.tink.com/api#webhook/webhook-endpoints" target="_blank" class="text-violet-400 hover:text-violet-300 underline">HMAC-SHA256 signature verification</a></p>
|
||||
<p>6. Skift til production Tink-credentials (Tink Console)</p>
|
||||
<p>7. Registrér din production callback URI i Tink Console</p>
|
||||
<p>8. Brug <code class="text-violet-300 font-mono text-xs">authorization-grant/delegate</code> i prod-flowet (ikke anon)</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-600 mt-4">
|
||||
Kildekoden til dette demo (<code class="font-mono">src/tink/client.py</code> og <code class="font-mono">src/routes/demo.py</code>) er skrevet for at være letlæselig og direkte overførbar til andre platforme.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="mt-4 flex justify-start">
|
||||
<a href="/demo/step/5"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Tink API client — async httpx wrapper covering auth, users,
|
||||
accounts (v2), transactions (v2), events (v2), and bank connectivity.
|
||||
|
||||
Tink API reference: https://docs.tink.com/api-introduction
|
||||
Endpoint reference: https://docs.tink.com/api#overview
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -89,7 +92,10 @@ class TinkClient:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def get_app_token(self, scope: str = "user:create") -> dict:
|
||||
"""Client credentials flow — returns app-level token."""
|
||||
"""
|
||||
Client credentials flow — returns app-level access token.
|
||||
Docs: https://docs.tink.com/api#connectivity/oauth/create-an-oauth-token
|
||||
"""
|
||||
return await self._post(
|
||||
f"{self.api_base}/api/v1/oauth/token",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
@@ -103,6 +109,10 @@ class TinkClient:
|
||||
|
||||
async def exchange_code_for_token(self, code: str,
|
||||
redirect_uri: str | None = None) -> dict:
|
||||
"""
|
||||
Exchange the authorization_code from Tink Link callback for a user-scoped token.
|
||||
Docs: https://docs.tink.com/api#connectivity/oauth/create-an-oauth-token
|
||||
"""
|
||||
data: dict = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
@@ -123,6 +133,11 @@ class TinkClient:
|
||||
|
||||
async def create_user(self, app_token: str, external_user_id: str,
|
||||
market: str = "DK", locale: str = "da_DK") -> dict:
|
||||
"""
|
||||
Create a Tink user linked to your internal customer reference.
|
||||
external_user_id = your system's reference (e.g. "moneycapp-42"). Tink returns a user_id.
|
||||
Docs: https://docs.tink.com/api#user/create-user
|
||||
"""
|
||||
return await self._post(
|
||||
f"{self.api_base}/api/v1/user/create",
|
||||
headers={"Authorization": f"Bearer {app_token}"},
|
||||
@@ -137,6 +152,11 @@ class TinkClient:
|
||||
|
||||
async def get_authorization_grant_token(self, app_token: str, user_id: str,
|
||||
scope: str) -> dict:
|
||||
"""
|
||||
Delegate an authorization grant for a specific user — generates a one-time code
|
||||
that binds the Tink Link session to that user.
|
||||
Docs: https://docs.tink.com/api#connectivity/oauth/create-authorization
|
||||
"""
|
||||
return await self._post(
|
||||
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
|
||||
headers={
|
||||
@@ -153,6 +173,12 @@ class TinkClient:
|
||||
def get_tink_link_url(self, market: str = "DK",
|
||||
authorization_code: str | None = None,
|
||||
redirect_uri_override: str | None = None) -> str:
|
||||
"""
|
||||
Build the Tink Link URL for bank connection.
|
||||
With authorization_code: links the session to a specific Tink user (production flow).
|
||||
Without: anonymous flow (sandbox only).
|
||||
Docs: https://docs.tink.com/resources/tink-link/tink-link-web-permanent-users
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
params: dict = {
|
||||
"client_id": self.client_id,
|
||||
@@ -184,6 +210,10 @@ class TinkClient:
|
||||
|
||||
async def list_accounts(self, user_token: str, page_size: int = 50,
|
||||
page_token: Optional[str] = None) -> dict:
|
||||
"""
|
||||
List all accounts for a user including balance and IBAN.
|
||||
Docs: https://docs.tink.com/api#account-service-v2/account/list-accounts
|
||||
"""
|
||||
params: dict = {"pageSize": page_size}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
@@ -206,6 +236,10 @@ class TinkClient:
|
||||
async def list_transactions(self, user_token: str, page_size: int = 25,
|
||||
page_token: Optional[str] = None,
|
||||
account_id: Optional[str] = None) -> dict:
|
||||
"""
|
||||
List transactions with cursor-based pagination.
|
||||
Docs: https://docs.tink.com/api#transaction-service-v2/transaction/list-transactions
|
||||
"""
|
||||
params: dict = {"pageSize": page_size}
|
||||
if page_token:
|
||||
params["pageToken"] = page_token
|
||||
@@ -265,6 +299,10 @@ class TinkClient:
|
||||
|
||||
async def register_webhook(self, app_token: str, url: str,
|
||||
enabled_events: list[str] | None = None) -> dict:
|
||||
"""
|
||||
Register a webhook endpoint for real-time transaction notifications.
|
||||
Docs: https://docs.tink.com/api#webhook/create-webhook-endpoint
|
||||
"""
|
||||
return await self._post(
|
||||
f"{self.api_base}/api/v1/webhooks",
|
||||
headers={"Authorization": f"Bearer {app_token}"},
|
||||
|
||||
Reference in New Issue
Block a user