fix: customer-facing cleanup — remove internal branding, print→logging, dynamic dates, sandbox note, next steps
All checks were successful
Build and Deploy / deploy (push) Successful in 49s

- main.py: neutral API description (remove 'sales demo')
- base.html: 'Open Banking Demo' nav, neutral footer
- demo.py: /debug-session gated behind DEMO_MODE, all print() → logging,
  webhook receiver has C# signature verification example + Tink docs link,
  removed duplicate import
- demo_data.py: all hardcoded 2026 dates replaced with dynamic date helpers
- step2.html: external_user_id terminology (remove tink_external_ref)
- step.html: sandbox note on Step 3 (anonymous vs production flow)
- step6.html: 'Next Steps' section for C#/.NET implementation
This commit is contained in:
Henrik Jess Nielsen
2026-05-23 01:50:20 +02:00
parent 3851b6c478
commit f6d17ae3c5
7 changed files with 115 additions and 53 deletions

View File

@@ -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()},
},
},
],

View File

@@ -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",
)

View File

@@ -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"}

View File

@@ -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 &mdash; i80.dk
Tink Open Banking API Demo
</footer>
<script>

View File

@@ -114,9 +114,21 @@
</div>
</div>
{% 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">

View File

@@ -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-&lt;ref&gt;", ← tink_external_ref
"external_user_id": "moneycapp-&lt;ref&gt;", ← your reference, stored in DB
"market": "DK",
"locale": "da_DK"
}

View File

@@ -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">
Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
Et komplet Tink-flow — klar til at blive bygget ind i jeres platform.
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"