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 dfc8ffd..afae7cf 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) @@ -430,9 +436,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") @@ -440,31 +446,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) @@ -695,8 +699,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 f0c4ae4..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 @@