This commit is contained in:
@@ -20,7 +20,7 @@ router = APIRouter()
|
|||||||
templates = Jinja2Templates(directory="src/templates")
|
templates = Jinja2Templates(directory="src/templates")
|
||||||
|
|
||||||
|
|
||||||
def _client() -> TinkClient:
|
def _client(log_cb=None) -> TinkClient:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
return TinkClient(
|
return TinkClient(
|
||||||
client_id=s.tink_client_id,
|
client_id=s.tink_client_id,
|
||||||
@@ -28,6 +28,7 @@ def _client() -> TinkClient:
|
|||||||
redirect_uri=s.tink_redirect_uri,
|
redirect_uri=s.tink_redirect_uri,
|
||||||
api_base=s.tink_api_base,
|
api_base=s.tink_api_base,
|
||||||
link_base=s.tink_link_base,
|
link_base=s.tink_link_base,
|
||||||
|
on_request=log_cb,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +36,23 @@ def _session(request: Request) -> dict:
|
|||||||
return request.session.setdefault("demo", {})
|
return request.session.setdefault("demo", {})
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Landing
|
# Landing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -57,7 +75,8 @@ async def reset_demo(request: Request):
|
|||||||
|
|
||||||
@router.get("/demo/step/1", response_class=HTMLResponse)
|
@router.get("/demo/step/1", response_class=HTMLResponse)
|
||||||
async def step1(request: Request):
|
async def step1(request: Request):
|
||||||
client = _client()
|
sess = _session(request)
|
||||||
|
client = _client(log_cb=_logger(sess))
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
error = None
|
error = None
|
||||||
result = None
|
result = None
|
||||||
@@ -70,12 +89,11 @@ async def step1(request: Request):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await client.get_app_token(scope="user:create")
|
result = await client.get_app_token(scope="user:create")
|
||||||
_session(request)["app_token"] = result["access_token"]
|
sess["app_token"] = result["access_token"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
return templates.TemplateResponse("step.html", {
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
"request": request,
|
|
||||||
"step": 1,
|
"step": 1,
|
||||||
"title": "Authenticate",
|
"title": "Authenticate",
|
||||||
"subtitle": "Client Credentials Flow",
|
"subtitle": "Client Credentials Flow",
|
||||||
@@ -87,7 +105,7 @@ async def step1(request: Request):
|
|||||||
"error": error,
|
"error": error,
|
||||||
"next_step": 2,
|
"next_step": 2,
|
||||||
"prev_step": None,
|
"prev_step": None,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -99,9 +117,46 @@ async def step2_get(request: Request):
|
|||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
app_token = sess.get("app_token", "")
|
app_token = sess.get("app_token", "")
|
||||||
external_user_id = f"moneycapp-demo-{secrets.token_hex(4)}"
|
error = None
|
||||||
|
|
||||||
body = json.dumps({"external_user_id": external_user_id, "market": "DK", "locale": "da_DK"}, indent=2)
|
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)
|
||||||
curl_example = (
|
curl_example = (
|
||||||
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
|
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
|
||||||
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
||||||
@@ -109,33 +164,30 @@ async def step2_get(request: Request):
|
|||||||
f" -d '{body}'"
|
f" -d '{body}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
error = None
|
|
||||||
result = None
|
|
||||||
if not app_token:
|
if not app_token:
|
||||||
error = "Mangler app token — gå tilbage til Step 1 og kør authentication først."
|
error = "Mangler app token — gå tilbage til Step 1."
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
client = _client()
|
client = _client(log_cb=_logger(sess))
|
||||||
result = await client.create_user(app_token, external_user_id)
|
result = await client.create_user(app_token, external_user_id, market=market)
|
||||||
sess["user_id"] = result.get("user_id", "")
|
sess["user_id"] = result.get("user_id", "")
|
||||||
sess["external_user_id"] = external_user_id
|
sess["external_user_id"] = external_user_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
return templates.TemplateResponse("step.html", {
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
"request": request,
|
|
||||||
"step": 2,
|
"step": 2,
|
||||||
"title": "Opret Bruger",
|
"title": "Opret Bruger",
|
||||||
"subtitle": "Create Test Customer",
|
"subtitle": f"Kunde: {external_user_id}",
|
||||||
"endpoint": "POST /api/v1/user/create",
|
"endpoint": "POST /api/v1/user/create",
|
||||||
"api_version": "v1",
|
"api_version": "v1",
|
||||||
"description": "Vi opretter en ny Tink-bruger med et unikt external_user_id — det er dit interne kunde-ID. Tink returnerer et user_id som vi bruger i de næste kald.",
|
"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.",
|
||||||
"curl_example": curl_example,
|
"curl_example": curl_example,
|
||||||
"result": result,
|
"result": result,
|
||||||
"error": error,
|
"error": error,
|
||||||
"next_step": 3,
|
"next_step": 3,
|
||||||
"prev_step": 1,
|
"prev_step": 1,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -175,7 +227,11 @@ async def step3_get(request: Request):
|
|||||||
try:
|
try:
|
||||||
credentials = await client.list_credentials(user_token)
|
credentials = await client.list_credentials(user_token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
# 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)
|
||||||
|
|
||||||
curl_example = (
|
curl_example = (
|
||||||
"# Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig\n"
|
"# Simpelt Tink Link flow — ingen pre-oprettet bruger nødvendig\n"
|
||||||
@@ -193,8 +249,7 @@ async def step3_get(request: Request):
|
|||||||
" -d 'code=$CODE'"
|
" -d 'code=$CODE'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse("step.html", {
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
"request": request,
|
|
||||||
"step": 3,
|
"step": 3,
|
||||||
"title": "Tilslut Bank",
|
"title": "Tilslut Bank",
|
||||||
"subtitle": "Tink Link — Bank Connection",
|
"subtitle": "Tink Link — Bank Connection",
|
||||||
@@ -209,7 +264,7 @@ async def step3_get(request: Request):
|
|||||||
"cb_success": cb_success,
|
"cb_success": cb_success,
|
||||||
"next_step": 4,
|
"next_step": 4,
|
||||||
"prev_step": 2,
|
"prev_step": 2,
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/demo/step/3", response_class=HTMLResponse)
|
@router.post("/demo/step/3", response_class=HTMLResponse)
|
||||||
@@ -217,7 +272,7 @@ async def step3_post(request: Request, code: str = Form(...)):
|
|||||||
"""Manual code entry — exchange a code obtained via console.tink.com/callback."""
|
"""Manual code entry — exchange a code obtained via console.tink.com/callback."""
|
||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
try:
|
try:
|
||||||
client = _client()
|
client = _client(log_cb=_logger(sess))
|
||||||
tokens = await client.exchange_code_for_token(
|
tokens = await client.exchange_code_for_token(
|
||||||
code=code.strip(),
|
code=code.strip(),
|
||||||
redirect_uri=CONSOLE_CALLBACK,
|
redirect_uri=CONSOLE_CALLBACK,
|
||||||
@@ -238,7 +293,7 @@ async def tink_callback(request: Request, code: Optional[str] = None,
|
|||||||
return RedirectResponse(f"/demo/step/3?error={error}")
|
return RedirectResponse(f"/demo/step/3?error={error}")
|
||||||
if code:
|
if code:
|
||||||
try:
|
try:
|
||||||
client = _client()
|
client = _client(log_cb=_logger(sess))
|
||||||
tokens = await client.exchange_code_for_token(code)
|
tokens = await client.exchange_code_for_token(code)
|
||||||
sess["user_token"] = tokens.get("access_token", "")
|
sess["user_token"] = tokens.get("access_token", "")
|
||||||
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||||
|
|||||||
@@ -40,7 +40,16 @@
|
|||||||
<span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
|
<span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="/" class="text-sm text-slate-400 hover:text-white transition">← Restart demo</a>
|
<div class="flex items-center gap-3">
|
||||||
|
{% if session_customer %}
|
||||||
|
<div class="flex items-center gap-2 bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
|
<span class="text-xs text-slate-400">Kunde:</span>
|
||||||
|
<code class="text-xs text-emerald-300 font-mono font-semibold">{{ session_customer }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/demo/reset" class="text-sm text-slate-400 hover:text-white transition">↺ Reset</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
157
src/templates/step2.html
Normal file
157
src/templates/step2.html
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %} — Step 2: Opret Bruger{% endblock %}
|
||||||
|
|
||||||
|
{% block stepper %}
|
||||||
|
<div class="bg-slate-900/60 border-b border-slate-800">
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1 overflow-x-auto">
|
||||||
|
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %}
|
||||||
|
{% for i in range(1, 7) %}
|
||||||
|
<a href="/demo/step/{{ i }}"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
|
||||||
|
{% if i == 2 %}bg-violet-600 text-white font-semibold
|
||||||
|
{% elif i < 2 %}text-slate-300 hover:text-white
|
||||||
|
{% else %}text-slate-600 hover:text-slate-400{% endif %}">
|
||||||
|
<span class="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center
|
||||||
|
{% if i < 2 %}bg-slate-700 text-slate-300
|
||||||
|
{% elif i == 2 %}bg-violet-500 text-white
|
||||||
|
{% else %}bg-slate-800 text-slate-600{% endif %}">{{ i }}</span>
|
||||||
|
{{ step_names[i-1] }}
|
||||||
|
</a>
|
||||||
|
{% if i < 6 %}
|
||||||
|
<span class="text-slate-700">›</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">2</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-white">Opret Bruger</h2>
|
||||||
|
<p class="text-slate-400 text-sm">Create Test Customer</p>
|
||||||
|
</div>
|
||||||
|
<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 dit eget <code class="text-violet-300">external_user_id</code> —
|
||||||
|
dette er dit interne kunde-ID som du kan søge på i Tink Console.
|
||||||
|
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-4 mb-6">
|
||||||
|
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if existing_user_id %}
|
||||||
|
<!-- Already created this session -->
|
||||||
|
<div class="bg-emerald-950/30 border border-emerald-800/40 rounded-xl p-5 mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<span class="w-8 h-8 rounded-full bg-emerald-600/20 border border-emerald-600/40 flex items-center justify-center text-emerald-400 text-lg">✓</span>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-semibold">Bruger allerede oprettet i denne session</p>
|
||||||
|
<p class="text-slate-400 text-sm">Du kan fortsætte til Step 3, eller oprette en ny bruger nedenfor.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||||
|
<div class="bg-slate-900 rounded-lg p-3">
|
||||||
|
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">external_user_id</p>
|
||||||
|
<code class="text-emerald-300 font-mono text-sm font-semibold">{{ existing_external_id }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-900 rounded-lg p-3">
|
||||||
|
<p class="text-xs text-slate-500 uppercase tracking-wider mb-1">Tink user_id</p>
|
||||||
|
<code class="text-slate-300 font-mono text-xs">{{ existing_user_id }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/demo/step/3"
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
|
||||||
|
Fortsæt til Step 3 — Tilslut Bank →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="mb-6">
|
||||||
|
<summary class="cursor-pointer text-sm text-slate-500 hover:text-slate-300 transition">Opret en ny bruger i stedet</summary>
|
||||||
|
<div class="mt-4">
|
||||||
|
{% else %}
|
||||||
|
<div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Create user form -->
|
||||||
|
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden max-w-xl">
|
||||||
|
<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">external_user_id bruges til at identificere kunden i Tink Console</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Kundenavn</label>
|
||||||
|
<input type="text" name="customer_name"
|
||||||
|
placeholder="fx. Henrik Jess, Kunde A, Test User 1"
|
||||||
|
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">→ bliver til <code class="text-violet-300/70">moneycapp-henrik-jess</code></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label>
|
||||||
|
<select name="market"
|
||||||
|
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm
|
||||||
|
focus:outline-none focus:border-violet-500 transition">
|
||||||
|
<option value="DK" selected>DK — Danmark</option>
|
||||||
|
<option value="SE">SE — Sverige</option>
|
||||||
|
<option value="NO">NO — Norge</option>
|
||||||
|
<option value="GB">GB — United Kingdom</option>
|
||||||
|
<option value="DE">DE — Deutschland</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
{% if not app_token_ok %}disabled{% endif %}
|
||||||
|
class="w-full py-3 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed
|
||||||
|
text-white rounded-xl text-sm font-semibold transition">
|
||||||
|
{% if app_token_ok %}Opret bruger i Tink →{% else %}Kør Step 1 først{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if existing_user_id %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% else %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- API info -->
|
||||||
|
<div class="mt-6 bg-slate-900/50 border border-slate-800 rounded-xl p-4">
|
||||||
|
<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"># Request body
|
||||||
|
{
|
||||||
|
"external_user_id": "moneycapp-<kundenavn>", ← dit interne ID
|
||||||
|
"market": "DK",
|
||||||
|
"locale": "da_DK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"user_id": "abc123..." ← Tinks interne ID, brug dette fremadrettet
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="mt-6 flex justify-start">
|
||||||
|
<a href="/demo/step/1"
|
||||||
|
class="px-4 py-2.5 border border-slate-700 text-slate-300 hover:text-white hover:border-slate-500 rounded-xl text-sm transition">
|
||||||
|
← Step 1
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,8 +4,9 @@ accounts (v2), transactions (v2), events (v2), and bank connectivity.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
from typing import Optional, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@@ -17,15 +18,57 @@ class TinkTokens:
|
|||||||
external_user_id: str = ""
|
external_user_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _log_entry(method: str, url: str, req_body: dict | None,
|
||||||
|
status: int, resp_body: dict, duration_ms: int) -> dict:
|
||||||
|
return {
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"req_body": req_body,
|
||||||
|
"status": status,
|
||||||
|
"resp_body": resp_body,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"ts": time.strftime("%H:%M:%S"),
|
||||||
|
"ok": status < 400,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TinkClient:
|
class TinkClient:
|
||||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str,
|
def __init__(self, client_id: str, client_secret: str, redirect_uri: str,
|
||||||
api_base: str = "https://api.tink.com",
|
api_base: str = "https://api.tink.com",
|
||||||
link_base: str = "https://link.tink.com"):
|
link_base: str = "https://link.tink.com",
|
||||||
|
on_request: Callable[[dict], None] | None = None):
|
||||||
self.client_id = client_id
|
self.client_id = client_id
|
||||||
self.client_secret = client_secret
|
self.client_secret = client_secret
|
||||||
self.redirect_uri = redirect_uri
|
self.redirect_uri = redirect_uri
|
||||||
self.api_base = api_base.rstrip("/")
|
self.api_base = api_base.rstrip("/")
|
||||||
self.link_base = link_base.rstrip("/")
|
self.link_base = link_base.rstrip("/")
|
||||||
|
self.on_request = on_request # callback(log_entry) called after each API call
|
||||||
|
|
||||||
|
def _log(self, method: str, url: str, req_body, status: int,
|
||||||
|
resp_body: dict, duration_ms: int):
|
||||||
|
if self.on_request:
|
||||||
|
self.on_request(_log_entry(method, url, req_body, status, resp_body, duration_ms))
|
||||||
|
|
||||||
|
async def _get(self, url: str, headers: dict, params: dict | None = None) -> dict:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url, headers=headers, params=params)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
body = resp.json() if resp.content else {}
|
||||||
|
self._log("GET", url, params, resp.status_code, body, ms)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def _post(self, url: str, headers: dict,
|
||||||
|
data: dict | None = None, json_body: dict | None = None) -> dict:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(url, headers=headers, data=data, json=json_body)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
body = resp.json() if resp.content else {}
|
||||||
|
self._log("POST", url, json_body or data, resp.status_code, body, ms)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return body
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Authentication
|
# Authentication
|
||||||
@@ -33,27 +76,19 @@ class TinkClient:
|
|||||||
|
|
||||||
async def get_app_token(self, scope: str = "user:create") -> dict:
|
async def get_app_token(self, scope: str = "user:create") -> dict:
|
||||||
"""Client credentials flow — returns app-level token."""
|
"""Client credentials flow — returns app-level token."""
|
||||||
async with httpx.AsyncClient() as client:
|
return await self._post(
|
||||||
resp = await client.post(
|
f"{self.api_base}/api/v1/oauth/token",
|
||||||
f"{self.api_base}/api/v1/oauth/token",
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
data={
|
data={
|
||||||
"client_id": self.client_id,
|
"client_id": self.client_id,
|
||||||
"client_secret": self.client_secret,
|
"client_secret": self.client_secret,
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
},
|
},
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def exchange_code_for_token(self, code: str,
|
async def exchange_code_for_token(self, code: str,
|
||||||
redirect_uri: str | None = None) -> dict:
|
redirect_uri: str | None = None) -> dict:
|
||||||
"""Authorization code → user-level access token.
|
|
||||||
|
|
||||||
Pass redirect_uri only when it differs from self.redirect_uri
|
|
||||||
(e.g. dev flow via console.tink.com/callback).
|
|
||||||
"""
|
|
||||||
data: dict = {
|
data: dict = {
|
||||||
"client_id": self.client_id,
|
"client_id": self.client_id,
|
||||||
"client_secret": self.client_secret,
|
"client_secret": self.client_secret,
|
||||||
@@ -62,78 +97,48 @@ class TinkClient:
|
|||||||
}
|
}
|
||||||
if redirect_uri:
|
if redirect_uri:
|
||||||
data["redirect_uri"] = redirect_uri
|
data["redirect_uri"] = redirect_uri
|
||||||
async with httpx.AsyncClient() as client:
|
return await self._post(
|
||||||
resp = await client.post(
|
f"{self.api_base}/api/v1/oauth/token",
|
||||||
f"{self.api_base}/api/v1/oauth/token",
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
data=data,
|
data=data,
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Users (v1 — only version available)
|
# Users (v1)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def create_user(self, app_token: str, external_user_id: str,
|
async def create_user(self, app_token: str, external_user_id: str,
|
||||||
market: str = "DK", locale: str = "da_DK") -> dict:
|
market: str = "DK", locale: str = "da_DK") -> dict:
|
||||||
"""Create a Tink user. Returns user_id."""
|
return await self._post(
|
||||||
async with httpx.AsyncClient() as client:
|
f"{self.api_base}/api/v1/user/create",
|
||||||
resp = await client.post(
|
headers={"Authorization": f"Bearer {app_token}"},
|
||||||
f"{self.api_base}/api/v1/user/create",
|
json_body={"external_user_id": external_user_id, "market": market, "locale": locale},
|
||||||
json={
|
)
|
||||||
"external_user_id": external_user_id,
|
|
||||||
"market": market,
|
|
||||||
"locale": locale,
|
|
||||||
},
|
|
||||||
headers={"Authorization": f"Bearer {app_token}"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def get_user(self, user_token: str) -> dict:
|
async def get_user(self, user_token: str) -> dict:
|
||||||
"""Get current user info."""
|
return await self._get(
|
||||||
async with httpx.AsyncClient() as client:
|
f"{self.api_base}/api/v1/user",
|
||||||
resp = await client.get(
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
f"{self.api_base}/api/v1/user",
|
)
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def get_authorization_grant_token(self, app_token: str, user_id: str,
|
async def get_authorization_grant_token(self, app_token: str, user_id: str,
|
||||||
scope: str) -> dict:
|
scope: str) -> dict:
|
||||||
"""Get a delegate authorization grant for a specific user (form-encoded)."""
|
return await self._post(
|
||||||
async with httpx.AsyncClient() as client:
|
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
|
||||||
resp = await client.post(
|
headers={
|
||||||
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
|
"Authorization": f"Bearer {app_token}",
|
||||||
data={
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"actor_client_id": self.client_id,
|
},
|
||||||
"user_id": user_id,
|
data={"actor_client_id": self.client_id, "user_id": user_id, "scope": scope},
|
||||||
"scope": scope,
|
)
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {app_token}",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Tink Link — bank connection URL
|
# Tink Link — bank connection URL (no HTTP call, just URL builder)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
def get_tink_link_url(self, market: str = "DK",
|
def get_tink_link_url(self, market: str = "DK",
|
||||||
authorization_code: str | None = None,
|
authorization_code: str | None = None,
|
||||||
redirect_uri_override: str | None = None) -> str:
|
redirect_uri_override: str | None = None) -> str:
|
||||||
"""Build the Tink Link URL for bank connection.
|
|
||||||
|
|
||||||
Uses simplified flow (no authorization_code) by default,
|
|
||||||
matching the Tink Console demo URL pattern.
|
|
||||||
Use redirect_uri_override to swap in a different callback URL
|
|
||||||
(e.g. https://console.tink.com/callback for local dev).
|
|
||||||
"""
|
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"client_id": self.client_id,
|
"client_id": self.client_id,
|
||||||
@@ -147,17 +152,14 @@ class TinkClient:
|
|||||||
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
|
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Credentials (bank connections) — v1
|
# Credentials (v1)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def list_credentials(self, user_token: str) -> dict:
|
async def list_credentials(self, user_token: str) -> dict:
|
||||||
async with httpx.AsyncClient() as client:
|
return await self._get(
|
||||||
resp = await client.get(
|
f"{self.api_base}/api/v1/credentials/list",
|
||||||
f"{self.api_base}/api/v1/credentials/list",
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Accounts — v2
|
# Accounts — v2
|
||||||
@@ -165,28 +167,20 @@ class TinkClient:
|
|||||||
|
|
||||||
async def list_accounts(self, user_token: str, page_size: int = 50,
|
async def list_accounts(self, user_token: str, page_size: int = 50,
|
||||||
page_token: Optional[str] = None) -> dict:
|
page_token: Optional[str] = None) -> dict:
|
||||||
"""GET /data/v2/accounts — new v2 endpoint."""
|
|
||||||
params: dict = {"pageSize": page_size}
|
params: dict = {"pageSize": page_size}
|
||||||
if page_token:
|
if page_token:
|
||||||
params["pageToken"] = page_token
|
params["pageToken"] = page_token
|
||||||
async with httpx.AsyncClient() as client:
|
return await self._get(
|
||||||
resp = await client.get(
|
f"{self.api_base}/data/v2/accounts",
|
||||||
f"{self.api_base}/data/v2/accounts",
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
params=params,
|
params=params,
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def get_account(self, user_token: str, account_id: str) -> dict:
|
async def get_account(self, user_token: str, account_id: str) -> dict:
|
||||||
"""GET /data/v2/accounts/{id}"""
|
return await self._get(
|
||||||
async with httpx.AsyncClient() as client:
|
f"{self.api_base}/data/v2/accounts/{account_id}",
|
||||||
resp = await client.get(
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
f"{self.api_base}/data/v2/accounts/{account_id}",
|
)
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Transactions — v2
|
# Transactions — v2
|
||||||
@@ -195,101 +189,74 @@ class TinkClient:
|
|||||||
async def list_transactions(self, user_token: str, page_size: int = 25,
|
async def list_transactions(self, user_token: str, page_size: int = 25,
|
||||||
page_token: Optional[str] = None,
|
page_token: Optional[str] = None,
|
||||||
account_id: Optional[str] = None) -> dict:
|
account_id: Optional[str] = None) -> dict:
|
||||||
"""GET /data/v2/transactions — new v2 endpoint."""
|
|
||||||
params: dict = {"pageSize": page_size}
|
params: dict = {"pageSize": page_size}
|
||||||
if page_token:
|
if page_token:
|
||||||
params["pageToken"] = page_token
|
params["pageToken"] = page_token
|
||||||
if account_id:
|
if account_id:
|
||||||
params["accountIdIn"] = account_id
|
params["accountIdIn"] = account_id
|
||||||
async with httpx.AsyncClient() as client:
|
return await self._get(
|
||||||
resp = await client.get(
|
f"{self.api_base}/data/v2/transactions",
|
||||||
f"{self.api_base}/data/v2/transactions",
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
params=params,
|
params=params,
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def get_transaction(self, user_token: str, transaction_id: str) -> dict:
|
async def get_transaction(self, user_token: str, transaction_id: str) -> dict:
|
||||||
"""GET /data/v2/transactions/{id}"""
|
return await self._get(
|
||||||
async with httpx.AsyncClient() as client:
|
f"{self.api_base}/data/v2/transactions/{transaction_id}",
|
||||||
resp = await client.get(
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
f"{self.api_base}/data/v2/transactions/{transaction_id}",
|
)
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Events — v2
|
# Events — v2
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def list_account_transaction_events(
|
async def list_account_transaction_events(
|
||||||
self, user_token: str,
|
self, user_token: str, page_size: int = 25,
|
||||||
page_size: int = 25,
|
|
||||||
page_token: Optional[str] = None,
|
page_token: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""GET /events/v2/account-transactions"""
|
|
||||||
params: dict = {"pageSize": page_size}
|
params: dict = {"pageSize": page_size}
|
||||||
if page_token:
|
if page_token:
|
||||||
params["pageToken"] = page_token
|
params["pageToken"] = page_token
|
||||||
async with httpx.AsyncClient() as client:
|
return await self._get(
|
||||||
resp = await client.get(
|
f"{self.api_base}/events/v2/account-transactions",
|
||||||
f"{self.api_base}/events/v2/account-transactions",
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
params=params,
|
params=params,
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def list_booked_transaction_events(
|
async def list_booked_transaction_events(
|
||||||
self, user_token: str,
|
self, user_token: str, page_size: int = 25,
|
||||||
page_size: int = 25,
|
|
||||||
page_token: Optional[str] = None,
|
page_token: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""GET /events/v2/account-booked-transactions"""
|
|
||||||
params: dict = {"pageSize": page_size}
|
params: dict = {"pageSize": page_size}
|
||||||
if page_token:
|
if page_token:
|
||||||
params["pageToken"] = page_token
|
params["pageToken"] = page_token
|
||||||
async with httpx.AsyncClient() as client:
|
return await self._get(
|
||||||
resp = await client.get(
|
f"{self.api_base}/events/v2/account-booked-transactions",
|
||||||
f"{self.api_base}/events/v2/account-booked-transactions",
|
headers={"Authorization": f"Bearer {user_token}"},
|
||||||
params=params,
|
params=params,
|
||||||
headers={"Authorization": f"Bearer {user_token}"},
|
)
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Webhooks — app-level (uses client credentials token)
|
# Webhooks — app-level
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
async def list_webhooks(self, app_token: str) -> dict:
|
async def list_webhooks(self, app_token: str) -> dict:
|
||||||
"""GET /api/v1/webhooks"""
|
return await self._get(
|
||||||
async with httpx.AsyncClient() as client:
|
f"{self.api_base}/api/v1/webhooks",
|
||||||
resp = await client.get(
|
headers={"Authorization": f"Bearer {app_token}"},
|
||||||
f"{self.api_base}/api/v1/webhooks",
|
)
|
||||||
headers={"Authorization": f"Bearer {app_token}"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def register_webhook(self, app_token: str, url: str,
|
async def register_webhook(self, app_token: str, url: str,
|
||||||
enabled_events: list[str] | None = None) -> dict:
|
enabled_events: list[str] | None = None) -> dict:
|
||||||
"""POST /api/v1/webhooks"""
|
return await self._post(
|
||||||
payload = {
|
f"{self.api_base}/api/v1/webhooks",
|
||||||
"url": url,
|
headers={"Authorization": f"Bearer {app_token}"},
|
||||||
"enabledEvents": enabled_events or [
|
json_body={
|
||||||
"account-booked-transaction:created",
|
"url": url,
|
||||||
"account-pending-transaction:created",
|
"enabledEvents": enabled_events or [
|
||||||
"account-pending-transaction:updated",
|
"account-booked-transaction:created",
|
||||||
],
|
"account-pending-transaction:created",
|
||||||
}
|
"account-pending-transaction:updated",
|
||||||
async with httpx.AsyncClient() as client:
|
],
|
||||||
resp = await client.post(
|
},
|
||||||
f"{self.api_base}/api/v1/webhooks",
|
)
|
||||||
json=payload,
|
|
||||||
headers={"Authorization": f"Bearer {app_token}"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user