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/__init__.py +0 -0
- bunq_cli/client.py +845 -0
- bunq_cli/commands/__init__.py +0 -0
- bunq_cli/commands/accounts.py +37 -0
- bunq_cli/commands/bunqme.py +75 -0
- bunq_cli/commands/callbacks.py +122 -0
- bunq_cli/commands/cards.py +122 -0
- bunq_cli/commands/drafts.py +79 -0
- bunq_cli/commands/events.py +37 -0
- bunq_cli/commands/login.py +131 -0
- bunq_cli/commands/oauth_client.py +113 -0
- bunq_cli/commands/payments.py +90 -0
- bunq_cli/commands/profiles.py +91 -0
- bunq_cli/commands/requests.py +122 -0
- bunq_cli/commands/sandbox.py +25 -0
- bunq_cli/commands/scheduled.py +108 -0
- bunq_cli/commands/statements.py +97 -0
- bunq_cli/config.py +180 -0
- bunq_cli/main.py +210 -0
- bunq_cli/oauth.py +176 -0
- bunq_cli/output.py +65 -0
- bunq_cli-0.2.0.dist-info/METADATA +68 -0
- bunq_cli-0.2.0.dist-info/RECORD +27 -0
- bunq_cli-0.2.0.dist-info/WHEEL +5 -0
- bunq_cli-0.2.0.dist-info/entry_points.txt +2 -0
- bunq_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- bunq_cli-0.2.0.dist-info/top_level.txt +1 -0
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)
|