diff --git a/README.md b/README.md index f8a2692..7cf75d0 100644 --- a/README.md +++ b/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 - consul kv put tink-demo/TINK_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. + diff --git a/src/demo_data.py b/src/demo_data.py index 8a87146..a466a01 100644 --- a/src/demo_data.py +++ b/src/demo_data.py @@ -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()}, }, }, ], diff --git a/src/main.py b/src/main.py index d641bb4..d17603b 100644 --- a/src/main.py +++ b/src/main.py @@ -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", ) diff --git a/src/routes/demo.py b/src/routes/demo.py index ee764c9..c4b3b15 100644 --- a/src/routes/demo.py +++ b/src/routes/demo.py @@ -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"} diff --git a/src/templates/base.html b/src/templates/base.html index 7359b07..e503d1f 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -37,7 +37,7 @@
T
Tink API Demo - MoneyCapp × Tink + Open Banking Demo
@@ -71,7 +71,7 @@
- Tink API Demo — MoneyCapp sales prototype — i80.dk + Tink Open Banking API Demo