2026-05-22 18:30:59 +02:00
"""
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 " )
2026-05-22 19:18:38 +02:00
def _client ( log_cb = None ) - > TinkClient :
2026-05-22 18:30:59 +02:00
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 ,
2026-05-22 19:18:38 +02:00
on_request = log_cb ,
2026-05-22 18:30:59 +02:00
)
def _session ( request : Request ) - > dict :
return request . session . setdefault ( " demo " , { } )
2026-05-22 19:18:38 +02:00
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
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# 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. """
request . session . pop ( " demo " , None )
return RedirectResponse ( " /demo/step/1 " )
# ---------------------------------------------------------------------------
# Step 1 — Authenticate (client credentials)
# ---------------------------------------------------------------------------
@router.get ( " /demo/step/1 " , response_class = HTMLResponse )
async def step1 ( request : Request ) :
2026-05-22 19:18:38 +02:00
sess = _session ( request )
client = _client ( log_cb = _logger ( sess ) )
2026-05-22 18:30:59 +02:00
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 ' "
)
try :
result = await client . get_app_token ( scope = " user:create " )
2026-05-22 19:18:38 +02:00
sess [ " app_token " ] = result [ " access_token " ]
2026-05-22 18:30:59 +02:00
except Exception as e :
error = str ( e )
2026-05-22 19:18:38 +02:00
return templates . TemplateResponse ( " step.html " , _ctx ( request , {
2026-05-22 18:30:59 +02:00
" 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 ,
2026-05-22 19:18:38 +02:00
} ) )
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# 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 = sess . get ( " app_token " , " " )
2026-05-22 19:18:38 +02:00
error = None
2026-05-22 18:30:59 +02:00
2026-05-22 19:18:38 +02:00
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 = sess . get ( " app_token " , " " )
error = None
result = None
# Build external_user_id from customer name
if customer_name . strip ( ) :
slug = customer_name . strip ( ) . lower ( ) . replace ( " " , " - " )
import re
slug = re . sub ( r " [^a-z0-9 \ -] " , " " , slug )
external_user_id = f " moneycapp- { slug } "
else :
external_user_id = f " moneycapp-demo- { secrets . token_hex ( 4 ) } "
body = json . dumps ( { " external_user_id " : external_user_id , " market " : market , " locale " : " da_DK " } , indent = 2 )
2026-05-22 18:30:59 +02:00
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 :
2026-05-22 19:18:38 +02:00
error = " Mangler app token — gå tilbage til Step 1. "
2026-05-22 18:30:59 +02:00
else :
try :
2026-05-22 19:18:38 +02:00
client = _client ( log_cb = _logger ( sess ) )
result = await client . create_user ( app_token , external_user_id , market = market )
2026-05-22 18:30:59 +02:00
sess [ " user_id " ] = result . get ( " user_id " , " " )
sess [ " external_user_id " ] = external_user_id
except Exception as e :
error = str ( e )
2026-05-22 19:18:38 +02:00
return templates . TemplateResponse ( " step.html " , _ctx ( request , {
2026-05-22 18:30:59 +02:00
" step " : 2 ,
" title " : " Opret Bruger " ,
2026-05-22 19:18:38 +02:00
" subtitle " : f " Kunde: { external_user_id } " ,
2026-05-22 18:30:59 +02:00
" endpoint " : " POST /api/v1/user/create " ,
" api_version " : " v1 " ,
2026-05-22 19:18:38 +02:00
" description " : f " Oprettet Tink-bruger med external_user_id <code class= ' text-violet-300 font-mono ' > { external_user_id } </code> — dette er dit interne kunde-ID. Tink returnerer et user_id som bruges i næste kald. " ,
2026-05-22 18:30:59 +02:00
" curl_example " : curl_example ,
" result " : result ,
" error " : error ,
" next_step " : 3 ,
" prev_step " : 1 ,
2026-05-22 19:18:38 +02:00
} ) )
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# 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 ( )
tink_link_url = client . get_tink_link_url ( market = " DK " )
dev_tink_link_url = client . get_tink_link_url (
market = " DK " , redirect_uri_override = CONSOLE_CALLBACK
)
error = None
credentials = None
cb_success = request . query_params . get ( " cb_success " )
cb_error = request . query_params . get ( " cb_error " )
# Demo mode: auto-mark as connected with mock data
if s . demo_mode and not sess . get ( " user_token " ) :
sess [ " user_token " ] = " demo-mode-token "
sess [ " demo_mode " ] = True
# Check if already connected (returning from callback)
user_token = sess . get ( " 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 :
2026-05-22 19:18:38 +02:00
# credentials:read may not be granted in simple Tink Link flow — not fatal
if " 403 " in str ( e ) :
credentials = { " note " : " credentials:read kræver authorization-grant flow " }
else :
error = str ( e )
2026-05-22 18:30:59 +02:00
curl_example = (
" # Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig \n "
f " # Redirect brugeren direkte til: \n "
f " https://link.tink.com/1.0/transactions/connect-accounts \n "
f " ?client_id=$CLIENT_ID \n "
f " &redirect_uri=$REDIRECT_URI \n "
f " &market=DK \n "
f " &scope=accounts:read,transactions:read,credentials:read \n \n "
" # Tink Link redirecter tilbage med ?code=... som du exchangeer 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=$CODE ' "
)
2026-05-22 19:18:38 +02:00
return templates . TemplateResponse ( " step.html " , _ctx ( request , {
2026-05-22 18:30:59 +02:00
" step " : 3 ,
" title " : " Tilslut Bank " ,
" subtitle " : " Tink Link — Bank Connection " ,
" endpoint " : " Tink Link /1.0/transactions/connect-accounts " ,
" api_version " : " Link v1 " ,
" description " : " Brugeren åbner Tink Link, vælger Tink Demo Bank og logger ind med test-credentials fra Console. Tink redirecter tilbage med en authorization_code som automatisk exchang ' es til et user token. " ,
" curl_example " : curl_example ,
" result " : credentials ,
" tink_link_url " : tink_link_url ,
" dev_tink_link_url " : dev_tink_link_url ,
" error " : error or ( f " Callback fejl: { cb_error } " if cb_error else None ) ,
" cb_success " : cb_success ,
" next_step " : 4 ,
" prev_step " : 2 ,
2026-05-22 19:18:38 +02:00
} ) )
2026-05-22 18:30:59 +02:00
@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 :
2026-05-22 19:18:38 +02:00
client = _client ( log_cb = _logger ( sess ) )
2026-05-22 18:30:59 +02:00
tokens = await client . exchange_code_for_token (
code = code . strip ( ) ,
redirect_uri = CONSOLE_CALLBACK ,
)
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 )
if error :
return RedirectResponse ( f " /demo/step/3?error= { error } " )
if code :
try :
2026-05-22 19:18:38 +02:00
client = _client ( log_cb = _logger ( sess ) )
2026-05-22 18:30:59 +02:00
tokens = await client . exchange_code_for_token ( code )
sess [ " user_token " ] = tokens . get ( " access_token " , " " )
2026-05-22 19:04:06 +02:00
return RedirectResponse ( " /demo/step/3?cb_success=1 " , status_code = 303 )
2026-05-22 18:30:59 +02:00
except Exception as e :
2026-05-22 19:04:06 +02:00
return RedirectResponse ( f " /demo/step/3?cb_error= { str ( e ) } " , status_code = 303 )
return RedirectResponse ( " /demo/step/3 " , status_code = 303 )
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# 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 = sess . get ( " 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 ( )
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 " , {
" request " : 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 = sess . get ( " 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 ( )
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 " , {
" request " : 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
2026-05-22 19:04:06 +02:00
result_webhooks = None
webhook_registered = None
2026-05-22 18:30:59 +02:00
2026-05-22 19:04:06 +02:00
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 ' "
2026-05-22 18:30:59 +02:00
)
2026-05-22 19:04:06 +02:00
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 "
" } ' "
2026-05-22 18:30:59 +02:00
)
2026-05-22 19:04:06 +02:00
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 " ,
}
2026-05-22 18:30:59 +02:00
else :
try :
client = _client ( )
2026-05-22 19:04:06 +02:00
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 ]
2026-05-22 18:30:59 +02:00
except Exception as e :
2026-05-22 19:04:06 +02:00
err_str = str ( e )
if " 404 " in err_str :
# Sandbox doesn't expose webhook management — show sample data instead
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
2026-05-22 18:30:59 +02:00
return templates . TemplateResponse ( " step6.html " , {
" request " : request ,
" step " : 6 ,
2026-05-22 19:04:06 +02:00
" title " : " Webhooks & Events " ,
" subtitle " : " Real-time Event Notifications " ,
2026-05-22 18:30:59 +02:00
" error " : error ,
2026-05-22 19:04:06 +02:00
" result_webhooks " : result_webhooks ,
" webhook_registered " : webhook_registered ,
" webhook_url " : webhook_url ,
" curl_list " : curl_list ,
" curl_register " : curl_register ,
2026-05-22 18:30:59 +02:00
" is_demo " : is_demo ,
" app_base_url " : s . app_base_url ,
" next_step " : None ,
" prev_step " : 5 ,
} )
# ---------------------------------------------------------------------------
# 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 " }