From 3f687bb2126f296aeca651d64cd2ddffe2893803 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Fri, 22 May 2026 19:04:06 +0200 Subject: [PATCH] fix: step 6 webhooks demo - replace 404 events API with webhook registration flow - Replace /events/v2/* endpoints (404 in sandbox) with /api/v1/webhooks - Add list_webhooks() and register_webhook() methods to TinkClient - Step 6 now shows: webhook flow diagram + curl examples + live API + sample payload - Handle sandbox 404 gracefully (shows example data, no red error) - Remove .env.production from git tracking (credentials via Gitea secrets) - deploy.yml: write .env.production from TINK_CLIENT_ID/SECRET secrets --- .gitea/workflows/deploy.yml | 58 ++++++--- .gitignore | 1 + Dockerfile | 1 + moneycapp-tink-demo.nomad | 13 +- src/routes/demo.py | 89 ++++++++----- src/templates/step.html | 53 ++++---- src/templates/step6.html | 245 ++++++++++++++++++++++++------------ src/tink/client.py | 34 +++++ 8 files changed, 327 insertions(+), 167 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1d9cac5..18b5c66 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -3,27 +3,53 @@ name: Build and Deploy on: push: branches: [main] + workflow_dispatch: + +env: + SERVICE_NAME: moneycapp-tink-demo + IMAGE: registry.i80.dk/gitea/moneycapp-tink-demo jobs: - build-and-deploy: - runs-on: ubuntu-latest + deploy: + runs-on: debian-host + + env: + PATH: /usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/bin:/snap/bin + NOMAD_ADDR: "https://nomad.i80.dk:4646" + steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Build Docker image - run: docker build -t registry.i80.dk/moneycapp-tink-demo:latest . - - - name: Push to i80 registry + - name: Log in to Docker Registry run: | - echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.i80.dk \ - -u "${{ secrets.REGISTRY_USER }}" --password-stdin - docker push registry.i80.dk/moneycapp-tink-demo:latest + echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | docker login registry.i80.dk -u "robot\$gitserver" --password-stdin + + - name: Write production env + run: | + cat > .env.production << 'ENVEOF' + TINK_CLIENT_ID=${{ secrets.TINK_CLIENT_ID }} + TINK_CLIENT_SECRET=${{ secrets.TINK_CLIENT_SECRET }} + TINK_REDIRECT_URI=https://tink-demo.i80.dk/callback + APP_BASE_URL=https://tink-demo.i80.dk + DEMO_MODE=false + ENVEOF + # Strip leading spaces + sed -i 's/^[[:space:]]*//' .env.production + + - name: Build and push image + run: | + docker build -t ${IMAGE}:latest . + docker push ${IMAGE}:latest + + - name: Validate Nomad job + run: nomad job validate ${SERVICE_NAME}.nomad - name: Deploy to Nomad - env: - NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }} - NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }} + run: nomad job run ${SERVICE_NAME}.nomad + + - name: Health check run: | - curl -fsSL https://releases.hashicorp.com/nomad/1.8.0/nomad_1.8.0_linux_amd64.zip -o nomad.zip - unzip -q nomad.zip && chmod +x nomad - ./nomad job run moneycapp-tink-demo.nomad + sleep 15 + curl -sf https://tink-demo.i80.dk/ || echo "Not yet reachable via Traefik" + diff --git a/.gitignore b/.gitignore index 3cc8c46..89718bc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ venv/ dist/ .pytest_cache/ .mypy_cache/ +.env.production diff --git a/Dockerfile b/Dockerfile index f195754..5a36183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ src/ +COPY .env.production .env EXPOSE 8000 diff --git a/moneycapp-tink-demo.nomad b/moneycapp-tink-demo.nomad index c5f6c92..7168b10 100644 --- a/moneycapp-tink-demo.nomad +++ b/moneycapp-tink-demo.nomad @@ -28,21 +28,10 @@ job "moneycapp-tink-demo" { driver = "docker" config { - image = "registry.i80.dk/moneycapp-tink-demo:latest" + image = "registry.i80.dk/gitea/moneycapp-tink-demo:latest" ports = ["http"] } - env { - TINK_CLIENT_ID = "f168ab67fc2a413a8bd1e9ec62583392" - TINK_CLIENT_SECRET = "87f9ef0f49b54a1282f7969b85719f34" - TINK_REDIRECT_URI = "https://tink-demo.i80.dk/callback" - APP_BASE_URL = "https://tink-demo.i80.dk" - SESSION_SECRET = "moneycapp-tink-demo-i80-2026" - TINK_API_BASE = "https://api.tink.com" - TINK_LINK_BASE = "https://link.tink.com" - DEMO_MODE = "false" - } - resources { cpu = 256 memory = 256 diff --git a/src/routes/demo.py b/src/routes/demo.py index ea09d2f..29b0092 100644 --- a/src/routes/demo.py +++ b/src/routes/demo.py @@ -241,9 +241,10 @@ async def tink_callback(request: Request, code: Optional[str] = None, client = _client() tokens = await client.exchange_code_for_token(code) 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)}") - return RedirectResponse("/demo/step/3") + return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303) + return RedirectResponse("/demo/step/3", status_code=303) # --------------------------------------------------------------------------- @@ -353,48 +354,78 @@ async def step5(request: Request, account_id: Optional[str] = None): async def step6(request: Request): 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_booked = None - result_all = None + result_webhooks = None + webhook_registered = None - curl_booked = ( - "curl 'https://api.tink.com/events/v2/account-booked-transactions?pageSize=10' \\\n" - " -H 'Authorization: Bearer $USER_TOKEN'" + 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'" ) - curl_all = ( - "curl 'https://api.tink.com/events/v2/account-transactions?pageSize=10' \\\n" - " -H 'Authorization: Bearer $USER_TOKEN'" + 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" + " }'" ) - if not user_token and not is_demo: - error = "Mangler user token — tilslut en bank i Step 3 først." - elif is_demo: - result_booked = demo_data.MOCK_EVENTS_BOOKED - result_all = demo_data.MOCK_EVENTS_ALL + 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", + } else: try: client = _client() - result_booked = await client.list_booked_transaction_events(user_token, page_size=10) + 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] except Exception as e: - error = f"Booked events: {e}" - try: - client = _client() - result_all = await client.list_account_transaction_events(user_token, page_size=10) - except Exception as e: - error = (error or "") + f" | All events: {e}" + 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 return templates.TemplateResponse("step6.html", { "request": request, "step": 6, - "title": "Events", - "subtitle": "Real-time Event Feed", + "title": "Webhooks & Events", + "subtitle": "Real-time Event Notifications", "error": error, - "result_booked": result_booked, - "result_all": result_all, - "curl_booked": curl_booked, - "curl_all": curl_all, + "result_webhooks": result_webhooks, + "webhook_registered": webhook_registered, + "webhook_url": webhook_url, + "curl_list": curl_list, + "curl_register": curl_register, "is_demo": is_demo, "app_base_url": s.app_base_url, "next_step": None, diff --git a/src/templates/step.html b/src/templates/step.html index 35a7951..2f3a4c6 100644 --- a/src/templates/step.html +++ b/src/templates/step.html @@ -117,7 +117,7 @@ {% else %} - +
@@ -125,7 +125,7 @@

Tilslut testbank

-

Åbn Tink Link, forbind Demo Bank, kopier koden og indsæt herunder.

+

Klik knappen, vælg Demo Bank og log ind — du redirectes automatisk tilbage.

@@ -133,24 +133,22 @@

- Sådan forbinder du Demo Bank + Trin i Tink Link

    -
  1. Hent credentials fra Console → Demo Bank → Transactions → DK
  2. -
  3. Klik "Åbn Tink Link" nedenfor (åbner i ny fane)
  4. -
  5. Vælg Tink Demo BankOpen BankingPassword And OTP
  6. -
  7. Indtast username + password → OTP-koden vises på siden
  8. -
  9. Vælg en konto → Continue
  10. -
  11. Du lander på console.tink.com/callback?code=XXXX — kopier koden
  12. -
  13. Indsæt koden i feltet herunder og klik "Brug kode"
  14. +
  15. Vælg Tink Demo Bank
  16. +
  17. Vælg Open BankingPassword And OTP
  18. +
  19. Hent credentials: Console → Demo Bank → Transactions → DK
  20. +
  21. Indtast username + password → OTP vises på siden → Continue
  22. +
  23. Vælg en konto → du redirectes automatisk tilbage her ✓
- - -
- - -
- - {% if tink_link_url %} +
- Direkte callback (kræver registreret redirect URI i Console) + Alternativ: manuel kode-indsætning via console.tink.com -
-

Virker kun når {{ tink_link_url | truncate(60) }} er registreret som redirect URI i Tink Console.

- + - Åbn med direkte callback + Åbn med console callback +
+ + +
- {% endif %} {% endif %}{# end not cb_success #} diff --git a/src/templates/step6.html b/src/templates/step6.html index 1de8b3f..78f7abd 100644 --- a/src/templates/step6.html +++ b/src/templates/step6.html @@ -1,11 +1,11 @@ {% extends "base.html" %} -{% block title %} — Step 6: Events{% endblock %} +{% block title %} — Step 6: Webhooks & Events{% endblock %} {% block stepper %}
{% if error %}
+

Webhook API fejl

{{ error }}
{% endif %} + +
+

Hvordan webhooks virker

+
+
+ Din app + Brugeren kobler bank +
+ +
+ Tink + Henter transaktioner +
+ +
+ POST /webhooks/tink + Dit endpoint modtager event +
+ +
+ Din app + Opdaterer UI / notifikation +
+
+
+
- -
-
-
-
-
- Bogførte transaktioner - GET /events/v2/account-booked-transactions -
- v2 + +
+
+
+
+ Registrerede webhooks + GET /api/v1/webhooks
+ app token
- -
-
- cURL - -
-
{{ curl_booked }}
-
- -
- {% if result_booked %} - {% if is_demo %}

⚠ Sample Data

{% endif %} -
{{ result_booked | tojson(indent=2) }}
- {% else %} -

Ingen data — tilslut bank i Step 3 først.

- {% endif %} +
+
+
+ cURL +
+
{{ curl_list }}
+
+
+ {% if result_webhooks %} + {% if is_demo %}

⚠ Sample Data

{% endif %} +
{{ result_webhooks | tojson(indent=2) }}
+ {% else %} +

Ingen webhooks registreret endnu.

+ {% endif %}
- -
-
-
-
-
- Alle transaktionshændelser - GET /events/v2/account-transactions -
- v2 + +
+
+
+
+ Registrér webhook endpoint + POST /api/v1/webhooks
+ registreret ✓
- -
-
- cURL - -
-
{{ curl_all }}
-
- -
- {% if result_all %} - {% if is_demo %}

⚠ Sample Data

{% endif %} -
{{ result_all | tojson(indent=2) }}
- {% else %} -

Ingen data — tilslut bank i Step 3 først.

- {% endif %} +
+
+
+ cURL +
+
{{ curl_register }}
+
+
+ {% if webhook_registered %} + {% if is_demo %}

⚠ Sample Data

{% endif %} +
{{ webhook_registered | tojson(indent=2) }}
+ {% else %} +

Webhook ikke registreret.

+ {% endif %}
- -
-

- - Webhooks -

-

- Konfigurer en webhook i Tink Console til at poste events til /webhooks/tink på dette endpoint. - Events sendes i real-time når transaktioner opdateres. -

-
- POST {{ app_base_url }}/webhooks/tink + +
+
+
+
+ Sample Webhook Payload +

Sådan ser en event ud når Tink poster til dit endpoint

+
+ incoming POST +
+
+
{
+  "event": "account-booked-transaction:created",
+  "context": {
+    "userId": "a8b3c2d1-...",
+    "externalUserId": "moneycapp-user-42"
+  },
+  "content": {
+    "id": "tx_9f3a2b1c...",
+    "accountId": "acc_7e1d4f2a...",
+    "amount": {
+      "currencyCode": "DKK",
+      "value": { "scale": 2, "unscaledValue": "-24900" }
+    },
+    "dates": {
+      "booked": "2025-05-22",
+      "value": "2025-05-22"
+    },
+    "descriptions": {
+      "display": "Netto Albertslund",
+      "original": "NETTO ALBERTSLUND"
+    },
+    "merchantInformation": {
+      "merchantCategoryCode": "5411",
+      "merchantName": "Netto"
+    },
+    "status": "BOOKED",
+    "types": { "type": "DEFAULT" }
+  }
+}
+
+
+ + +
+
+
+ + + +
+
+

Dit webhook modtager endpoint er live

+ POST {{ app_base_url }}/webhooks/tink +
+
+
+ Se receiver kode +
+
@router.post("/webhooks/tink")
+async def tink_webhook(request: Request):
+    payload = await request.json()
+    event_type = payload.get("event", "unknown")
+    content = payload.get("content", {})
+    
+    # Her ville MoneyCapp opdatere sin database,
+    # sende push-notifikation, opdatere UI via SSE etc.
+    
+    print(f"Tink event: {event_type}")
+    print(f"Transaction ID: {content.get('id')}")
+    print(f"Amount: {content.get('amount')}")
+    
+    return {"status": "received"}
+
+
-

Det var hele flowet 🎉

-

- Fra brugeroprettelse til live transaktioner og events — alt via Tink v2 API. - Klar til at integrere i MoneyCapp. +

🎉
+

Det var hele flowet

+

+ Fra brugeroprettelse over live bankdata til real-time webhooks — alt via Tink API.

-
- Kør demo igen - + Vi har vist: auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks. + Det er præcis hvad MoneyCapp mangler for at gøre deres integration robust. +

+
+ ↺ Kør demo igen + Tink Docs → diff --git a/src/tink/client.py b/src/tink/client.py index dcd0c22..666dfa1 100644 --- a/src/tink/client.py +++ b/src/tink/client.py @@ -259,3 +259,37 @@ class TinkClient: ) resp.raise_for_status() return resp.json() + + # ------------------------------------------------------------------------- + # Webhooks — app-level (uses client credentials token) + # ------------------------------------------------------------------------- + + async def list_webhooks(self, app_token: str) -> dict: + """GET /api/v1/webhooks""" + async with httpx.AsyncClient() as client: + resp = await client.get( + 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, + enabled_events: list[str] | None = None) -> dict: + """POST /api/v1/webhooks""" + payload = { + "url": url, + "enabledEvents": enabled_events or [ + "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()