bunq-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bunq_cli/client.py ADDED
@@ -0,0 +1,845 @@
1
+ """Bunq API client wrapping the official SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+ import requests
13
+ from bunq.sdk.context.api_context import ApiContext
14
+ from bunq.sdk.context.api_environment_type import ApiEnvironmentType
15
+ from bunq.sdk.context.bunq_context import BunqContext
16
+ from bunq.sdk.http.api_client import ApiClient
17
+ from bunq.sdk.http.bunq_response_raw import BunqResponseRaw
18
+ from bunq.sdk.http.pagination import Pagination
19
+ from bunq.sdk.model.generated.endpoint import (
20
+ CardApiObject,
21
+ MonetaryAccountApiObject,
22
+ NotificationFilterUrlApiObject,
23
+ PaymentApiObject,
24
+ RequestInquiryApiObject,
25
+ RequestResponseApiObject,
26
+ )
27
+ from bunq.sdk.model.generated.object_ import (
28
+ AmountObject,
29
+ NotificationFilterUrlObject,
30
+ PointerObject,
31
+ )
32
+
33
+ from bunq_cli.config import DEFAULT_CONTEXT_PATH, restrict_permissions
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ DEVICE_DESCRIPTION = "bunq-cli"
38
+ SANDBOX_USER_URL = "https://public-api.sandbox.bunq.com/v1/sandbox-user-person"
39
+
40
+
41
+ def _list_cards(params: dict[str, str]) -> Any:
42
+ """Like CardApiObject.list, but accepts CardDebit/CardCredit/... list items.
43
+
44
+ The SDK's list parser only accepts items wrapped as "Card".
45
+ """
46
+ api_client = ApiClient(CardApiObject._get_api_context())
47
+ url = CardApiObject._ENDPOINT_URL_LISTING.format(CardApiObject._determine_user_id())
48
+ raw = api_client.get(url, params, {})
49
+ body = json.loads(raw.body_bytes)
50
+ body["Response"] = [
51
+ {"Card": next(v for k, v in item.items() if k.startswith("Card"))}
52
+ for item in body["Response"]
53
+ ]
54
+ fixed = BunqResponseRaw(json.dumps(body).encode(), raw.headers)
55
+ return CardApiObject._from_json_list(fixed, "Card")
56
+
57
+
58
+ def _counterparty_name(ref: Any) -> str | None:
59
+ label = ref.label_monetary_account if ref else None
60
+ return label.display_name if label else None
61
+
62
+
63
+ class BunqApiError(click.ClickException):
64
+ """API error with context."""
65
+
66
+ def __init__(self, message: str) -> None:
67
+ super().__init__(f"Bunq API error: {message}")
68
+
69
+
70
+ def _ensure_context(
71
+ context_path: Path | None = None,
72
+ sandbox: bool = False,
73
+ ) -> None:
74
+ """Load an existing API context, refreshing the session if needed."""
75
+ path = str(context_path or DEFAULT_CONTEXT_PATH)
76
+ try:
77
+ api_context = ApiContext.restore(path)
78
+ except Exception as exc:
79
+ raise BunqApiError(
80
+ f"Could not load API context from {path}. Run 'bunq login' first."
81
+ ) from exc
82
+
83
+ if api_context.ensure_session_active():
84
+ restrict_permissions(Path(path))
85
+ api_context.save(path)
86
+
87
+ BunqContext.load_api_context(api_context)
88
+
89
+
90
+ def create_context(
91
+ api_key: str,
92
+ context_path: Path | None = None,
93
+ sandbox: bool = False,
94
+ permitted_ips: list[str] | None = None,
95
+ ) -> None:
96
+ """Create a new API context (device registration + session).
97
+
98
+ bunq invalidates API keys not assigned to an IP within 4 hours; the
99
+ device registration assigns them to ``permitted_ips`` (default ``*``).
100
+ """
101
+ path = str(context_path or DEFAULT_CONTEXT_PATH)
102
+ env = ApiEnvironmentType.SANDBOX if sandbox else ApiEnvironmentType.PRODUCTION
103
+ api_context = ApiContext.create(
104
+ env, api_key, DEVICE_DESCRIPTION, all_permitted_ip=list(permitted_ips or ["*"])
105
+ )
106
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
107
+ restrict_permissions(Path(path))
108
+ api_context.save(path)
109
+ BunqContext.load_api_context(api_context)
110
+
111
+
112
+ def destroy_context(context_path: Path | None = None) -> None:
113
+ """Close the session and delete the context file."""
114
+ path = Path(context_path or DEFAULT_CONTEXT_PATH)
115
+ if path.exists():
116
+ try:
117
+ api_context = ApiContext.restore(str(path))
118
+ api_context.close_session()
119
+ except Exception:
120
+ pass
121
+ path.unlink(missing_ok=True)
122
+
123
+
124
+ def current_user_name() -> str | None:
125
+ """Display name of the user the loaded context acts for (the granting user for OAuth)."""
126
+ raw = ApiClient(BunqContext.api_context()).get("user", {}, {})
127
+ user_type, user = next(iter(json.loads(raw.body_bytes)["Response"][0].items()))
128
+ if user_type == "UserApiKey":
129
+ user = next(iter(user["granted_by_user"].values()))
130
+ return user.get("display_name")
131
+
132
+
133
+ def create_sandbox_user() -> str:
134
+ """Create a sandbox test user and return its API key."""
135
+ response = requests.post(
136
+ SANDBOX_USER_URL,
137
+ headers={
138
+ "Content-Type": "application/json",
139
+ "Cache-Control": "no-cache",
140
+ "User-Agent": DEVICE_DESCRIPTION,
141
+ "X-Bunq-Client-Request-Id": str(uuid.uuid4()),
142
+ "X-Bunq-Language": "en_US",
143
+ "X-Bunq-Region": "nl_NL",
144
+ "X-Bunq-Geolocation": "0 0 0 0 000",
145
+ },
146
+ timeout=30,
147
+ )
148
+ if not response.ok:
149
+ raise BunqApiError(f"Creating sandbox user failed ({response.status_code}): {response.text}")
150
+ return response.json()["Response"][0]["ApiKey"]["api_key"]
151
+
152
+
153
+ def _amount(value: str, currency: str) -> dict:
154
+ return {"value": value, "currency": currency}
155
+
156
+
157
+ def _iban(iban: str, name: str) -> dict:
158
+ return {"type": "IBAN", "value": iban, "name": name}
159
+
160
+
161
+ def _name(label: dict | None) -> str | None:
162
+ return (label or {}).get("display_name")
163
+
164
+
165
+ class BunqClient:
166
+ """High-level client wrapping the bunq SDK for CLI use."""
167
+
168
+ def __init__(
169
+ self,
170
+ context_path: Path | None = None,
171
+ sandbox: bool = False,
172
+ verbose: bool = False,
173
+ ) -> None:
174
+ self.verbose = verbose
175
+ self.sandbox = sandbox
176
+ _ensure_context(context_path, sandbox)
177
+
178
+ def _log(self, message: str) -> None:
179
+ if self.verbose:
180
+ click.echo(message, err=True)
181
+
182
+ # -- Raw API access (the SDK misparses or lacks fields for several endpoints) --
183
+
184
+ @staticmethod
185
+ def _user_path(path: str) -> str:
186
+ return f"user/{BunqContext.user_context().user_id}/{path}"
187
+
188
+ @classmethod
189
+ def _account_path(cls, path: str, account_id: int | None) -> str:
190
+ if account_id is None:
191
+ account_id = BunqContext.user_context().primary_monetary_account.id_
192
+ return cls._user_path(f"monetary-account/{account_id}/{path}")
193
+
194
+ def _call(
195
+ self,
196
+ method: str,
197
+ path: str,
198
+ body: dict | None = None,
199
+ params: dict[str, str] | None = None,
200
+ ) -> list[dict]:
201
+ """Call the API and return the unwrapped items of the "Response" array."""
202
+ self._log(f"{method} {path}")
203
+ api = ApiClient(BunqContext.api_context())
204
+ if method == "GET":
205
+ raw = api.get(path, params or {}, {})
206
+ elif method == "DELETE":
207
+ raw = api.delete(path, {})
208
+ else:
209
+ raw = getattr(api, method.lower())(path, json.dumps(body or {}).encode(), {})
210
+ if not raw.body_bytes:
211
+ return []
212
+ response = json.loads(raw.body_bytes).get("Response", [])
213
+ return [next(iter(item.values())) for item in response]
214
+
215
+ def _create(self, path: str, body: dict) -> int:
216
+ return self._call("POST", path, body)[0]["id"]
217
+
218
+ @staticmethod
219
+ def _page_params(count: int, older_id: int | None) -> dict[str, str]:
220
+ params = {"count": str(count)}
221
+ if older_id is not None:
222
+ params["older_id"] = str(older_id)
223
+ return params
224
+
225
+ # -- Accounts --
226
+
227
+ def list_accounts(self) -> list[dict]:
228
+ """List all monetary accounts."""
229
+ self._log("GET monetary-account (list)")
230
+ pagination = Pagination()
231
+ pagination.count = 100
232
+ response = MonetaryAccountApiObject.list(
233
+ params=pagination.url_params_count_only
234
+ )
235
+ return [self._account_to_dict(a) for a in response.value]
236
+
237
+ def get_account(self, account_id: int) -> dict:
238
+ """Get a single monetary account."""
239
+ self._log(f"GET monetary-account/{account_id}")
240
+ response = MonetaryAccountApiObject.get(account_id)
241
+ return self._account_to_dict(response.value)
242
+
243
+ @staticmethod
244
+ def _account_to_dict(account: Any) -> dict:
245
+ """Convert a MonetaryAccountApiObject to a flat dict."""
246
+ # The SDK returns an anchor object; find the non-None sub-type
247
+ for attr in (
248
+ "MonetaryAccountBank",
249
+ "MonetaryAccountSavings",
250
+ "MonetaryAccountJoint",
251
+ "MonetaryAccountLight",
252
+ "MonetaryAccountExternal",
253
+ "MonetaryAccountExternalSavings",
254
+ "MonetaryAccountInvestment",
255
+ "MonetaryAccountCard",
256
+ "MonetaryAccountSwitchService",
257
+ ):
258
+ obj = getattr(account, attr, None)
259
+ if obj is not None:
260
+ iban = ""
261
+ if obj.alias:
262
+ for alias in obj.alias:
263
+ if alias.type_ == "IBAN":
264
+ iban = alias.value
265
+ break
266
+ return {
267
+ "id": obj.id_,
268
+ "description": obj.description,
269
+ "type": attr.replace("MonetaryAccount", ""),
270
+ "currency": getattr(obj, "currency", None)
271
+ or (obj.balance.currency if obj.balance else None),
272
+ "balance": obj.balance.value if obj.balance else None,
273
+ "iban": iban,
274
+ "status": obj.status,
275
+ }
276
+ return {"id": getattr(account, "id_", None)}
277
+
278
+ # -- Payments --
279
+
280
+ def list_payments(
281
+ self,
282
+ account_id: int | None = None,
283
+ count: int = 25,
284
+ older_id: int | None = None,
285
+ newer_id: int | None = None,
286
+ ) -> list[dict]:
287
+ """List payments on an account."""
288
+ self._log(f"GET payment (list) account={account_id}")
289
+ pagination = Pagination()
290
+ pagination.count = count
291
+ if older_id is not None:
292
+ pagination.older_id = older_id
293
+ if newer_id is not None:
294
+ pagination.newer_id = newer_id
295
+
296
+ params = pagination.url_params_count_only
297
+ if older_id is not None:
298
+ params = pagination.url_params_previous_page
299
+ elif newer_id is not None:
300
+ params = pagination.url_params_next_page
301
+
302
+ response = PaymentApiObject.list(
303
+ monetary_account_id=account_id,
304
+ params=params,
305
+ )
306
+ return [self._payment_to_dict(p) for p in response.value]
307
+
308
+ def list_all_payments(self, account_id: int | None = None) -> list[dict]:
309
+ """List all payments, handling pagination automatically."""
310
+ self._log(f"GET payment (list all) account={account_id}")
311
+ results: list[dict] = []
312
+ pagination = Pagination()
313
+ pagination.count = 100
314
+
315
+ response = PaymentApiObject.list(
316
+ monetary_account_id=account_id,
317
+ params=pagination.url_params_count_only,
318
+ )
319
+ results.extend(self._payment_to_dict(p) for p in response.value)
320
+
321
+ while response.pagination.has_previous_page():
322
+ response = PaymentApiObject.list(
323
+ monetary_account_id=account_id,
324
+ params=response.pagination.url_params_previous_page,
325
+ )
326
+ results.extend(self._payment_to_dict(p) for p in response.value)
327
+
328
+ return results
329
+
330
+ def get_payment(self, payment_id: int, account_id: int | None = None) -> dict:
331
+ """Get a single payment."""
332
+ self._log(f"GET payment/{payment_id} account={account_id}")
333
+ response = PaymentApiObject.get(
334
+ payment_id, monetary_account_id=account_id
335
+ )
336
+ return self._payment_to_dict(response.value)
337
+
338
+ def create_payment(
339
+ self,
340
+ amount: str,
341
+ currency: str,
342
+ recipient_iban: str,
343
+ recipient_name: str,
344
+ description: str,
345
+ account_id: int | None = None,
346
+ ) -> int:
347
+ """Create a payment. Returns the payment ID."""
348
+ self._log(f"POST payment account={account_id}")
349
+ response = PaymentApiObject.create(
350
+ amount=AmountObject(amount, currency),
351
+ counterparty_alias=PointerObject("IBAN", recipient_iban, recipient_name),
352
+ description=description,
353
+ monetary_account_id=account_id,
354
+ )
355
+ return response.value
356
+
357
+ @staticmethod
358
+ def _payment_to_dict(payment: Any) -> dict:
359
+ return {
360
+ "id": payment.id_,
361
+ "created": payment.created,
362
+ "amount": payment.amount.value if payment.amount else None,
363
+ "currency": payment.amount.currency if payment.amount else None,
364
+ "counterparty": _counterparty_name(payment.counterparty_alias),
365
+ "description": payment.description,
366
+ "type": payment.type_,
367
+ "balance_after": (
368
+ payment.balance_after_mutation.value
369
+ if payment.balance_after_mutation
370
+ else None
371
+ ),
372
+ }
373
+
374
+ # -- Request Inquiries (payment requests) --
375
+
376
+ def list_requests(self, account_id: int | None = None) -> list[dict]:
377
+ """List outgoing payment requests."""
378
+ self._log(f"GET request-inquiry (list) account={account_id}")
379
+ pagination = Pagination()
380
+ pagination.count = 100
381
+ response = RequestInquiryApiObject.list(
382
+ monetary_account_id=account_id,
383
+ params=pagination.url_params_count_only,
384
+ )
385
+ return [self._request_inquiry_to_dict(r) for r in response.value]
386
+
387
+ def get_request(
388
+ self, request_id: int, account_id: int | None = None
389
+ ) -> dict:
390
+ """Get a single outgoing payment request."""
391
+ self._log(f"GET request-inquiry/{request_id} account={account_id}")
392
+ response = RequestInquiryApiObject.get(
393
+ request_id, monetary_account_id=account_id
394
+ )
395
+ return self._request_inquiry_to_dict(response.value)
396
+
397
+ def create_request(
398
+ self,
399
+ amount: str,
400
+ currency: str,
401
+ counterparty_type: str,
402
+ counterparty_value: str,
403
+ description: str,
404
+ account_id: int | None = None,
405
+ ) -> int:
406
+ """Create a payment request. Returns the request ID."""
407
+ self._log(f"POST request-inquiry account={account_id}")
408
+ response = RequestInquiryApiObject.create(
409
+ amount_inquired=AmountObject(amount, currency),
410
+ counterparty_alias=PointerObject(counterparty_type, counterparty_value),
411
+ description=description,
412
+ allow_bunqme=True,
413
+ monetary_account_id=account_id,
414
+ )
415
+ return response.value
416
+
417
+ def revoke_request(
418
+ self, request_id: int, account_id: int | None = None
419
+ ) -> None:
420
+ """Revoke a pending payment request."""
421
+ self._log(f"PUT request-inquiry/{request_id} (revoke)")
422
+ RequestInquiryApiObject.update(
423
+ request_id,
424
+ status="REVOKED",
425
+ monetary_account_id=account_id,
426
+ )
427
+
428
+ def list_incoming_requests(self, account_id: int | None = None) -> list[dict]:
429
+ """List incoming payment requests."""
430
+ self._log(f"GET request-response (list) account={account_id}")
431
+ pagination = Pagination()
432
+ pagination.count = 100
433
+ response = RequestResponseApiObject.list(
434
+ monetary_account_id=account_id,
435
+ params=pagination.url_params_count_only,
436
+ )
437
+ return [self._request_response_to_dict(r) for r in response.value]
438
+
439
+ def accept_request(
440
+ self, request_id: int, account_id: int | None = None
441
+ ) -> None:
442
+ """Accept an incoming payment request."""
443
+ self._log(f"PUT request-response/{request_id} (accept)")
444
+ RequestResponseApiObject.update(
445
+ request_id,
446
+ status="ACCEPTED",
447
+ monetary_account_id=account_id,
448
+ )
449
+
450
+ def reject_request(
451
+ self, request_id: int, account_id: int | None = None
452
+ ) -> None:
453
+ """Reject an incoming payment request."""
454
+ self._log(f"PUT request-response/{request_id} (reject)")
455
+ RequestResponseApiObject.update(
456
+ request_id,
457
+ status="REJECTED",
458
+ monetary_account_id=account_id,
459
+ )
460
+
461
+ @staticmethod
462
+ def _request_inquiry_to_dict(req: Any) -> dict:
463
+ return {
464
+ "id": req.id_,
465
+ "created": req.created,
466
+ "updated": req.updated,
467
+ "amount": req.amount_inquired.value if req.amount_inquired else None,
468
+ "currency": req.amount_inquired.currency if req.amount_inquired else None,
469
+ "counterparty": _counterparty_name(req.counterparty_alias),
470
+ "description": req.description,
471
+ "status": req.status,
472
+ }
473
+
474
+ @staticmethod
475
+ def _request_response_to_dict(req: Any) -> dict:
476
+ return {
477
+ "id": req.id_,
478
+ "created": req.created,
479
+ "updated": req.updated,
480
+ "amount": req.amount_inquired.value if req.amount_inquired else None,
481
+ "currency": req.amount_inquired.currency if req.amount_inquired else None,
482
+ "counterparty": _counterparty_name(req.counterparty_alias),
483
+ "description": req.description,
484
+ "status": req.status,
485
+ }
486
+
487
+ # -- Cards --
488
+
489
+ def list_cards(self) -> list[dict]:
490
+ """List all cards."""
491
+ self._log("GET card (list)")
492
+ pagination = Pagination()
493
+ pagination.count = 100
494
+ response = _list_cards(pagination.url_params_count_only)
495
+ return [self._card_to_dict(c) for c in response.value]
496
+
497
+ def get_card(self, card_id: int) -> dict:
498
+ """Get a single card."""
499
+ self._log(f"GET card/{card_id}")
500
+ response = CardApiObject.get(card_id)
501
+ return self._card_to_dict(response.value)
502
+
503
+ def update_card(self, card_id: int, **kwargs: Any) -> dict:
504
+ """Update card settings."""
505
+ self._log(f"PUT card/{card_id}")
506
+ CardApiObject.update(card_id, **kwargs)
507
+ return self.get_card(card_id)
508
+
509
+ @staticmethod
510
+ def _card_to_dict(card: Any) -> dict:
511
+ return {
512
+ "id": card.id_,
513
+ "type": card.type_,
514
+ "product_type": card.product_type,
515
+ "status": card.status,
516
+ "second_line": card.second_line,
517
+ "expiry_date": card.expiry_date,
518
+ "card_limit": (
519
+ card.card_limit.value if card.card_limit else None
520
+ ),
521
+ "card_limit_atm": (
522
+ card.card_limit_atm.value if card.card_limit_atm else None
523
+ ),
524
+ "countries": ",".join(
525
+ p.country for p in (card.country_permission or [])
526
+ ),
527
+ }
528
+
529
+ # -- Card transactions (mastercard-action) --
530
+
531
+ def list_card_transactions(
532
+ self, account_id: int | None = None, count: int = 25, older_id: int | None = None
533
+ ) -> list[dict]:
534
+ path = self._account_path("mastercard-action", account_id)
535
+ items = self._call("GET", path, params=self._page_params(count, older_id))
536
+ return [self._card_transaction_to_dict(t) for t in items]
537
+
538
+ def get_card_transaction(self, transaction_id: int, account_id: int | None = None) -> dict:
539
+ path = self._account_path(f"mastercard-action/{transaction_id}", account_id)
540
+ return self._card_transaction_to_dict(self._call("GET", path)[0])
541
+
542
+ @staticmethod
543
+ def _card_transaction_to_dict(t: dict) -> dict:
544
+ amount = t.get("amount_billing") or {}
545
+ return {
546
+ "id": t.get("id"),
547
+ "created": t.get("created"),
548
+ "amount": amount.get("value"),
549
+ "currency": amount.get("currency"),
550
+ "merchant": _name(t.get("counterparty_alias")),
551
+ "city": t.get("city"),
552
+ "description": t.get("description"),
553
+ "card_id": t.get("card_id"),
554
+ "decision": t.get("decision"),
555
+ "status": t.get("payment_status"),
556
+ }
557
+
558
+ # -- Events --
559
+
560
+ def list_events(
561
+ self, account_id: int | None = None, count: int = 25, older_id: int | None = None
562
+ ) -> list[dict]:
563
+ params = self._page_params(count, older_id)
564
+ if account_id is not None:
565
+ params["monetary_account_id"] = str(account_id)
566
+ items = self._call("GET", self._user_path("event"), params=params)
567
+ return [self._event_to_dict(e) for e in items]
568
+
569
+ def get_event(self, event_id: int) -> dict:
570
+ event = self._call("GET", self._user_path(f"event/{event_id}"))[0]
571
+ return {**self._event_to_dict(event), "object": event.get("object")}
572
+
573
+ @staticmethod
574
+ def _event_to_dict(e: dict) -> dict:
575
+ object_type, obj = next(iter((e.get("object") or {"": {}}).items()))
576
+ return {
577
+ "id": e.get("id"),
578
+ "created": e.get("created"),
579
+ "action": e.get("action"),
580
+ "object_type": object_type,
581
+ "object_id": (obj or {}).get("id"),
582
+ "account_id": e.get("monetary_account_id"),
583
+ "status": e.get("status"),
584
+ }
585
+
586
+ # -- Statements (customer-statement) --
587
+
588
+ def list_statements(self, account_id: int | None = None) -> list[dict]:
589
+ items = self._call("GET", self._account_path("customer-statement", account_id))
590
+ return [self._statement_to_dict(s) for s in items]
591
+
592
+ def create_statement(
593
+ self,
594
+ statement_format: str,
595
+ date_start: str,
596
+ date_end: str,
597
+ regional_format: str | None = None,
598
+ account_id: int | None = None,
599
+ ) -> int:
600
+ body = {
601
+ "statement_format": statement_format,
602
+ "date_start": date_start,
603
+ "date_end": date_end,
604
+ }
605
+ if regional_format:
606
+ body["regional_format"] = regional_format
607
+ return self._create(self._account_path("customer-statement", account_id), body)
608
+
609
+ def download_statement(self, statement_id: int, account_id: int | None = None) -> bytes:
610
+ path = self._account_path(f"customer-statement/{statement_id}/content", account_id)
611
+ self._log(f"GET {path}")
612
+ return ApiClient(BunqContext.api_context()).get(path, {}, {}).body_bytes
613
+
614
+ def delete_statement(self, statement_id: int, account_id: int | None = None) -> None:
615
+ self._call("DELETE", self._account_path(f"customer-statement/{statement_id}", account_id))
616
+
617
+ @staticmethod
618
+ def _statement_to_dict(s: dict) -> dict:
619
+ return {
620
+ "id": s.get("id"),
621
+ "created": s.get("created"),
622
+ "date_start": s.get("date_start"),
623
+ "date_end": s.get("date_end"),
624
+ "format": s.get("statement_format"),
625
+ "regional_format": s.get("regional_format"),
626
+ "status": s.get("status"),
627
+ }
628
+
629
+ # -- Scheduled payments --
630
+
631
+ def list_scheduled_payments(self, account_id: int | None = None) -> list[dict]:
632
+ items = self._call("GET", self._account_path("schedule-payment", account_id))
633
+ return [self._scheduled_payment_to_dict(s) for s in items]
634
+
635
+ def get_scheduled_payment(self, schedule_id: int, account_id: int | None = None) -> dict:
636
+ path = self._account_path(f"schedule-payment/{schedule_id}", account_id)
637
+ return self._scheduled_payment_to_dict(self._call("GET", path)[0])
638
+
639
+ def create_scheduled_payment(
640
+ self,
641
+ amount: str,
642
+ currency: str,
643
+ recipient_iban: str,
644
+ recipient_name: str,
645
+ description: str,
646
+ time_start: str,
647
+ recurrence_unit: str,
648
+ recurrence_size: int,
649
+ time_end: str | None = None,
650
+ account_id: int | None = None,
651
+ ) -> int:
652
+ schedule = {
653
+ "time_start": time_start,
654
+ "recurrence_unit": recurrence_unit,
655
+ "recurrence_size": recurrence_size,
656
+ }
657
+ if time_end:
658
+ schedule["time_end"] = time_end
659
+ body = {
660
+ "payment": {
661
+ "amount": _amount(amount, currency),
662
+ "counterparty_alias": _iban(recipient_iban, recipient_name),
663
+ "description": description,
664
+ },
665
+ "schedule": schedule,
666
+ }
667
+ return self._create(self._account_path("schedule-payment", account_id), body)
668
+
669
+ def cancel_scheduled_payment(self, schedule_id: int, account_id: int | None = None) -> None:
670
+ self._call("DELETE", self._account_path(f"schedule-payment/{schedule_id}", account_id))
671
+
672
+ @staticmethod
673
+ def _scheduled_payment_to_dict(s: dict) -> dict:
674
+ payment = s.get("payment") or {}
675
+ schedule = s.get("schedule") or {}
676
+ amount = payment.get("amount") or {}
677
+ return {
678
+ "id": s.get("id"),
679
+ "status": s.get("status"),
680
+ "amount": amount.get("value"),
681
+ "currency": amount.get("currency"),
682
+ "counterparty": _name(payment.get("counterparty_alias")),
683
+ "description": payment.get("description"),
684
+ "every": f"{schedule.get('recurrence_size')} {schedule.get('recurrence_unit')}",
685
+ "next": schedule.get("time_next"),
686
+ "start": schedule.get("time_start"),
687
+ "end": schedule.get("time_end"),
688
+ }
689
+
690
+ # -- Draft payments --
691
+
692
+ def list_draft_payments(self, account_id: int | None = None) -> list[dict]:
693
+ items = self._call("GET", self._account_path("draft-payment", account_id))
694
+ return [self._draft_payment_to_dict(d) for d in items]
695
+
696
+ def get_draft_payment(self, draft_id: int, account_id: int | None = None) -> dict:
697
+ path = self._account_path(f"draft-payment/{draft_id}", account_id)
698
+ return self._draft_payment_to_dict(self._call("GET", path)[0])
699
+
700
+ def create_draft_payment(
701
+ self,
702
+ amount: str,
703
+ currency: str,
704
+ recipient_iban: str,
705
+ recipient_name: str,
706
+ description: str,
707
+ account_id: int | None = None,
708
+ ) -> int:
709
+ body = {
710
+ "entries": [
711
+ {
712
+ "amount": _amount(amount, currency),
713
+ "counterparty_alias": _iban(recipient_iban, recipient_name),
714
+ "description": description,
715
+ }
716
+ ],
717
+ "number_of_required_accepts": 1,
718
+ }
719
+ return self._create(self._account_path("draft-payment", account_id), body)
720
+
721
+ def reject_draft_payment(self, draft_id: int, account_id: int | None = None) -> None:
722
+ path = self._account_path(f"draft-payment/{draft_id}", account_id)
723
+ updated = self._call("GET", path)[0]["updated"]
724
+ self._call("PUT", path, {"status": "REJECTED", "previous_updated_timestamp": updated})
725
+
726
+ @staticmethod
727
+ def _draft_payment_to_dict(d: dict) -> dict:
728
+ entries = d.get("entries") or [{}]
729
+ amount = entries[0].get("amount") or {}
730
+ return {
731
+ "id": d.get("id"),
732
+ "created": d.get("created"),
733
+ "status": d.get("status"),
734
+ "amount": amount.get("value"),
735
+ "currency": amount.get("currency"),
736
+ "counterparty": _name(entries[0].get("counterparty_alias")),
737
+ "description": entries[0].get("description"),
738
+ "entries": len(d.get("entries") or []),
739
+ }
740
+
741
+ # -- bunq.me tabs --
742
+
743
+ def list_bunqme_tabs(self, account_id: int | None = None) -> list[dict]:
744
+ items = self._call("GET", self._account_path("bunqme-tab", account_id))
745
+ return [self._bunqme_tab_to_dict(t) for t in items]
746
+
747
+ def get_bunqme_tab(self, tab_id: int, account_id: int | None = None) -> dict:
748
+ path = self._account_path(f"bunqme-tab/{tab_id}", account_id)
749
+ return self._bunqme_tab_to_dict(self._call("GET", path)[0])
750
+
751
+ def create_bunqme_tab(
752
+ self,
753
+ amount: str,
754
+ currency: str,
755
+ description: str,
756
+ redirect_url: str | None = None,
757
+ account_id: int | None = None,
758
+ ) -> dict:
759
+ entry = {"amount_inquired": _amount(amount, currency), "description": description}
760
+ if redirect_url:
761
+ entry["redirect_url"] = redirect_url
762
+ tab_id = self._create(
763
+ self._account_path("bunqme-tab", account_id), {"bunqme_tab_entry": entry}
764
+ )
765
+ return self.get_bunqme_tab(tab_id, account_id)
766
+
767
+ def cancel_bunqme_tab(self, tab_id: int, account_id: int | None = None) -> None:
768
+ path = self._account_path(f"bunqme-tab/{tab_id}", account_id)
769
+ self._call("PUT", path, {"status": "CANCELLED"})
770
+
771
+ @staticmethod
772
+ def _bunqme_tab_to_dict(t: dict) -> dict:
773
+ entry = t.get("bunqme_tab_entry") or {}
774
+ amount = entry.get("amount_inquired") or {}
775
+ return {
776
+ "id": t.get("id"),
777
+ "created": t.get("created"),
778
+ "status": t.get("status"),
779
+ "amount": amount.get("value"),
780
+ "currency": amount.get("currency"),
781
+ "description": entry.get("description"),
782
+ "url": t.get("bunqme_tab_share_url"),
783
+ "expires": t.get("time_expiry"),
784
+ }
785
+
786
+ # -- Callbacks (notification filters) --
787
+
788
+ def list_callbacks(self) -> list[dict]:
789
+ """List notification filters."""
790
+ self._log("GET notification-filter-url (list)")
791
+ response = NotificationFilterUrlApiObject.list()
792
+ return [
793
+ self._callback_to_dict(f)
794
+ for item in response.value
795
+ for f in (item.notification_filters or [])
796
+ ]
797
+
798
+ def create_callback(self, url: str, categories: list[str]) -> list[dict]:
799
+ """Add notification filters for the given categories."""
800
+ self._log(f"POST notification-filter-url categories={categories}")
801
+ # POST replaces the full set of filters, so keep the existing ones.
802
+ filters = [
803
+ NotificationFilterUrlObject(f["category"], f["notification_target"])
804
+ for f in self.list_callbacks()
805
+ ]
806
+ existing = {(f.category, f.notification_target) for f in filters}
807
+ filters += [
808
+ NotificationFilterUrlObject(category, url)
809
+ for category in categories
810
+ if (category, url) not in existing
811
+ ]
812
+ NotificationFilterUrlApiObject.create(notification_filters=filters)
813
+ return self.list_callbacks()
814
+
815
+ def delete_all_callbacks(self) -> None:
816
+ """Remove all notification filters."""
817
+ self._log("DELETE notification-filter-url (all)")
818
+ NotificationFilterUrlApiObject.create(notification_filters=[])
819
+
820
+ @staticmethod
821
+ def _callback_to_dict(f: Any) -> dict:
822
+ return {
823
+ "id": getattr(f, "id_", None),
824
+ "category": f.category,
825
+ "notification_target": f.notification_target,
826
+ }
827
+
828
+ def list_callback_failures(self) -> list[dict]:
829
+ items = self._call("GET", self._user_path("notification-filter-failure"))
830
+ return [
831
+ {
832
+ "id": f.get("id"),
833
+ "created": f.get("created"),
834
+ "category": f.get("category"),
835
+ "event_type": f.get("event_type"),
836
+ "object_id": f.get("object_id"),
837
+ "response_code": f.get("response_code"),
838
+ "error": f.get("exception_message"),
839
+ }
840
+ for f in items
841
+ ]
842
+
843
+ def retry_callback_failures(self, failure_ids: list[int]) -> None:
844
+ body = {"notification_filter_failed_ids": ",".join(map(str, failure_ids))}
845
+ self._call("POST", self._user_path("notification-filter-failure"), body)