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
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:
@@ -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.
|
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 = {
|
MOCK_CREDENTIALS = {
|
||||||
"credentials": [
|
"credentials": [
|
||||||
{
|
{
|
||||||
@@ -12,8 +18,8 @@ MOCK_CREDENTIALS = {
|
|||||||
"providerName": "dk-demobank-open-banking-embedded",
|
"providerName": "dk-demobank-open-banking-embedded",
|
||||||
"type": "PASSWORD",
|
"type": "PASSWORD",
|
||||||
"status": "UPDATED",
|
"status": "UPDATED",
|
||||||
"statusUpdated": "2026-05-22T08:00:00Z",
|
"statusUpdated": _dt(),
|
||||||
"updated": "2026-05-22T08:00:00Z",
|
"updated": _dt(),
|
||||||
"fields": {"username": "demo_user_001"},
|
"fields": {"username": "demo_user_001"},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -37,7 +43,7 @@ MOCK_ACCOUNTS = {
|
|||||||
"iban": {"iban": "DK5000400440116243"},
|
"iban": {"iban": "DK5000400440116243"},
|
||||||
"financialInstitution": {"accountNumber": "0440116243"},
|
"financialInstitution": {"accountNumber": "0440116243"},
|
||||||
},
|
},
|
||||||
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
|
"dates": {"lastRefreshed": _dt()},
|
||||||
"financialInstitutionId": "dk-demobank",
|
"financialInstitutionId": "dk-demobank",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,7 +59,7 @@ MOCK_ACCOUNTS = {
|
|||||||
"iban": {"iban": "DK5000400440116244"},
|
"iban": {"iban": "DK5000400440116244"},
|
||||||
"financialInstitution": {"accountNumber": "0440116244"},
|
"financialInstitution": {"accountNumber": "0440116244"},
|
||||||
},
|
},
|
||||||
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
|
"dates": {"lastRefreshed": _dt()},
|
||||||
"financialInstitutionId": "dk-demobank",
|
"financialInstitutionId": "dk-demobank",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -66,7 +72,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-001-demo",
|
"id": "tx-001-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "Spotify AB",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
||||||
@@ -77,7 +83,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-002-demo",
|
"id": "tx-002-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "Netto Supermarked",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
||||||
@@ -88,8 +94,8 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-003-demo",
|
"id": "tx-003-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "3500000", "scale": "2"}, "currencyCode": "DKK"},
|
"amount": {"value": {"unscaledValue": "3500000", "scale": "2"}, "currencyCode": "DKK"},
|
||||||
"dates": {"booked": "2026-05-20", "value": "2026-05-20"},
|
"dates": {"booked": _d(-2), "value": _d(-2)},
|
||||||
"description": "Løn maj 2026",
|
"description": f"Løn {_today.strftime('%b %Y')}",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "income.salary", "name": "Løn"}},
|
"categories": {"pfm": {"id": "income.salary", "name": "Løn"}},
|
||||||
"types": {"type": "DEFAULT"},
|
"types": {"type": "DEFAULT"},
|
||||||
@@ -98,7 +104,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-004-demo",
|
"id": "tx-004-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-120000", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "Matas Strøget",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}},
|
"categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}},
|
||||||
@@ -109,7 +115,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-005-demo",
|
"id": "tx-005-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-8500000", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "Husleje maj",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}},
|
"categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}},
|
||||||
@@ -119,7 +125,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-006-demo",
|
"id": "tx-006-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-35000", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "DSB Rejse",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}},
|
"categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}},
|
||||||
@@ -130,7 +136,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-007-demo",
|
"id": "tx-007-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-19900", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "Netflix International",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
|
||||||
@@ -141,7 +147,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-008-demo",
|
"id": "tx-008-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-62500", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "Fakta Falkoner",
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
|
||||||
@@ -152,7 +158,7 @@ MOCK_TRANSACTIONS = {
|
|||||||
"id": "tx-009-demo",
|
"id": "tx-009-demo",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
|
"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",
|
"description": "7-Eleven Nørreport",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}},
|
"categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}},
|
||||||
@@ -169,26 +175,26 @@ MOCK_EVENTS_BOOKED = {
|
|||||||
"id": "evt-booked-001",
|
"id": "evt-booked-001",
|
||||||
"type": "account-booked-transaction",
|
"type": "account-booked-transaction",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"created": "2026-05-22T08:12:33Z",
|
"created": _dt(),
|
||||||
"transaction": {
|
"transaction": {
|
||||||
"id": "tx-001-demo",
|
"id": "tx-001-demo",
|
||||||
"description": "Spotify AB",
|
"description": "Spotify AB",
|
||||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"dates": {"booked": "2026-05-22"},
|
"dates": {"booked": _d()},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "evt-booked-002",
|
"id": "evt-booked-002",
|
||||||
"type": "account-booked-transaction",
|
"type": "account-booked-transaction",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"created": "2026-05-21T14:22:10Z",
|
"created": _dt(-1),
|
||||||
"transaction": {
|
"transaction": {
|
||||||
"id": "tx-002-demo",
|
"id": "tx-002-demo",
|
||||||
"description": "Netto Supermarked",
|
"description": "Netto Supermarked",
|
||||||
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
|
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"dates": {"booked": "2026-05-21"},
|
"dates": {"booked": _d(-1)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -201,26 +207,26 @@ MOCK_EVENTS_ALL = {
|
|||||||
"id": "evt-pending-001",
|
"id": "evt-pending-001",
|
||||||
"type": "account-transaction",
|
"type": "account-transaction",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"created": "2026-05-23T11:05:44Z",
|
"created": _dt(1),
|
||||||
"transaction": {
|
"transaction": {
|
||||||
"id": "tx-009-demo",
|
"id": "tx-009-demo",
|
||||||
"description": "7-Eleven Nørreport",
|
"description": "7-Eleven Nørreport",
|
||||||
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
|
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"dates": {"booked": "2026-05-23"},
|
"dates": {"booked": _d(1)},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "evt-booked-001",
|
"id": "evt-booked-001",
|
||||||
"type": "account-booked-transaction",
|
"type": "account-booked-transaction",
|
||||||
"accountId": "acc-8f3a2e1b-demo",
|
"accountId": "acc-8f3a2e1b-demo",
|
||||||
"created": "2026-05-22T08:12:33Z",
|
"created": _dt(),
|
||||||
"transaction": {
|
"transaction": {
|
||||||
"id": "tx-001-demo",
|
"id": "tx-001-demo",
|
||||||
"description": "Spotify AB",
|
"description": "Spotify AB",
|
||||||
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
|
||||||
"status": "BOOKED",
|
"status": "BOOKED",
|
||||||
"dates": {"booked": "2026-05-22"},
|
"dates": {"booked": _d()},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ settings = get_settings()
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Tink API Demo",
|
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",
|
version="1.0.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ with the live JSON response and a curl example.
|
|||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import secrets
|
import secrets
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form, HTTPException
|
from fastapi import APIRouter, Request, Form, HTTPException
|
||||||
@@ -18,6 +21,7 @@ from src import demo_data
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="src/templates")
|
templates = Jinja2Templates(directory="src/templates")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _client(log_cb=None) -> TinkClient:
|
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
|
# Server-side token store — keeps JWTs OUT of the session cookie
|
||||||
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead)
|
# (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}
|
_token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
|
||||||
_callback_locks: dict[str, asyncio.Lock] = {} # sid → Lock (prevents concurrent code exchange)
|
_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")
|
@router.get("/demo/debug-session")
|
||||||
async def debug_session(request: Request):
|
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)
|
sess = _session(request)
|
||||||
safe = {
|
safe = {
|
||||||
k: (v[:20] + "…" if isinstance(v, str) and len(v) > 20 else v)
|
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):
|
credentials_id: Optional[str] = None):
|
||||||
"""Tink Link OAuth callback — exchange code for user token."""
|
"""Tink Link OAuth callback — exchange code for user token."""
|
||||||
sess = _session(request)
|
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:
|
if error:
|
||||||
print(f"[CALLBACK] Tink returned error: {error}")
|
logger.warning("Tink returned error: %s", error)
|
||||||
return RedirectResponse(f"/demo/step/3?error={error}")
|
return RedirectResponse(f"/demo/step/3?error={error}")
|
||||||
if code:
|
if code:
|
||||||
sid = sess.get("sid", "unknown")
|
sid = sess.get("sid", "unknown")
|
||||||
@@ -440,31 +446,29 @@ async def tink_callback(request: Request, code: Optional[str] = None,
|
|||||||
_callback_locks[sid] = asyncio.Lock()
|
_callback_locks[sid] = asyncio.Lock()
|
||||||
async with _callback_locks[sid]:
|
async with _callback_locks[sid]:
|
||||||
if _load_token(sess, "user_token"):
|
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)
|
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||||
try:
|
try:
|
||||||
s = get_settings()
|
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))
|
client = _client(log_cb=_logger(sess))
|
||||||
tokens = await client.exchange_code_for_token(
|
tokens = await client.exchange_code_for_token(
|
||||||
code, redirect_uri=s.tink_redirect_uri
|
code, redirect_uri=s.tink_redirect_uri
|
||||||
)
|
)
|
||||||
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
|
|
||||||
user_token = tokens.get("access_token", "")
|
user_token = tokens.get("access_token", "")
|
||||||
if not user_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(
|
return RedirectResponse(
|
||||||
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
|
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
_store_token(sess, "user_token", user_token)
|
_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)
|
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
logger.exception("Token exchange failed: %s", e)
|
||||||
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}")
|
|
||||||
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
|
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)
|
return RedirectResponse("/demo/step/3", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -695,8 +699,20 @@ async def clear_log(request: Request):
|
|||||||
|
|
||||||
@router.post("/webhooks/tink")
|
@router.post("/webhooks/tink")
|
||||||
async def webhook_receiver(request: Request):
|
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()
|
body = await request.json()
|
||||||
# In production you'd verify the signature and store events
|
logger.info("Webhook received: %s", json.dumps(body)[:200])
|
||||||
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
|
|
||||||
return {"status": "received"}
|
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 class="w-8 h-8 rounded-lg bg-violet-600 flex items-center justify-center text-white font-bold text-sm">T</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-semibold text-white">Tink API Demo</span>
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="border-t border-slate-800 text-center text-slate-500 text-xs py-4">
|
<footer class="border-t border-slate-800 text-center text-slate-500 text-xs py-4">
|
||||||
Tink API Demo — i80.dk
|
Tink Open Banking API Demo
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -114,9 +114,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<!-- Not yet connected — show connection UI -->
|
<!-- 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 -->
|
<!-- PRIMARY: direct callback flow -->
|
||||||
<div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4">
|
<div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4">
|
||||||
<div class="flex items-start gap-3">
|
<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>
|
<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>
|
</div>
|
||||||
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
|
<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> —
|
Opret en Tink-bruger med et <code class="text-violet-300">external_user_id</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>).
|
jeres interne reference til kunden (f.eks. et kundenummer eller UUID fra jeres database).
|
||||||
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,8 +91,8 @@
|
|||||||
<div class="px-5 py-4 border-b border-slate-800">
|
<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-sm font-semibold text-white">Hvem opretter vi?</p>
|
||||||
<p class="text-xs text-slate-400 mt-0.5">
|
<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 —
|
<code class="text-violet-300">external_user_id</code> = jeres interne kundereference —
|
||||||
adskilt fra jeres interne <code class="text-slate-400">customer_id</code>
|
gemmes i jeres database som f.eks. <code class="text-slate-400">tink_user_ref</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
|
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
|
||||||
@@ -101,7 +103,7 @@
|
|||||||
value="Henrik Jess"
|
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
|
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">
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label>
|
<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>
|
<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>
|
<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">
|
<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:
|
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># Your DB schema (example):
|
||||||
# customer_id = 42 ← jeres interne ID (Tink ser det aldrig)
|
# customer_id = 42 ← your internal ID (Tink never sees this)
|
||||||
# tink_external_ref = "moneycapp-42-a3f9c1" ← Tink-reference
|
# tink_user_ref = "moneycapp-42-a3f9c1" ← store Tink's external_user_id here
|
||||||
|
|
||||||
# Request body
|
# Request body
|
||||||
{
|
{
|
||||||
"external_user_id": "moneycapp-<ref>", ← tink_external_ref
|
"external_user_id": "moneycapp-<ref>", ← your reference, stored in DB
|
||||||
"market": "DK",
|
"market": "DK",
|
||||||
"locale": "da_DK"
|
"locale": "da_DK"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,7 +224,6 @@ async def tink_webhook(request: Request):
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-slate-500 text-sm mb-6 max-w-xl mx-auto">
|
<p class="text-slate-500 text-sm mb-6 max-w-xl mx-auto">
|
||||||
Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
|
Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
|
||||||
Et komplet Tink-flow — klar til at blive bygget ind i jeres platform.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3 justify-center flex-wrap">
|
<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>
|
<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>
|
||||||
</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 -->
|
<!-- Navigation -->
|
||||||
<div class="mt-4 flex justify-start">
|
<div class="mt-4 flex justify-start">
|
||||||
<a href="/demo/step/5"
|
<a href="/demo/step/5"
|
||||||
|
|||||||
Reference in New Issue
Block a user