├── tests ├── __init__.py ├── http │ ├── __init__.py │ ├── test_bad_request_with_response_id.py │ ├── test_pagination_scenario.py │ └── test_pagination.py ├── model │ ├── __init__.py │ ├── generated │ │ ├── __init__.py │ │ ├── endpoint │ │ │ ├── __init__.py │ │ │ ├── test_session.py │ │ │ ├── test_monetary_account_joint.py │ │ │ ├── test_attachment_public.py │ │ │ ├── test_monetary_account_bank.py │ │ │ ├── test_avatar.py │ │ │ ├── test_request_inquiry.py │ │ │ ├── test_card_debit.py │ │ │ └── test_payment.py │ │ └── object │ │ │ ├── __init__.py │ │ │ └── test_notification_url.py │ └── core │ │ ├── test_oauth_authorization_uri.py │ │ └── test_notification_filter.py ├── context │ ├── __init__.py │ ├── test_user_context.py │ ├── test_psd2_context.py │ └── test_api_context.py ├── assets │ ├── vader.png │ ├── NotificationUrlJsons │ │ ├── ChatMessageAnnouncement.json │ │ ├── MonetaryAccountBank.json │ │ ├── ShareInviteBankResponse.json │ │ ├── BunqMeTab.json │ │ ├── Mutation.json │ │ ├── ShareInviteBankInquiry.json │ │ ├── RequestResponse.json │ │ ├── RequestInquiry.json │ │ ├── ScheduledPayment.json │ │ └── MasterCardAction.json │ └── ResponseJsons │ │ └── MonetaryAccountJoint.json ├── README.md ├── config.py └── bunq_test.py ├── VERSION ├── bunq ├── sdk │ ├── __init__.py │ ├── context │ │ ├── __init__.py │ │ ├── api_environment_type.py │ │ ├── installation_context.py │ │ ├── bunq_context.py │ │ ├── user_context.py │ │ └── session_context.py │ ├── http │ │ ├── __init__.py │ │ ├── http_util.py │ │ ├── bunq_response_raw.py │ │ ├── bunq_response.py │ │ ├── anonymous_api_client.py │ │ └── pagination.py │ ├── json │ │ ├── __init__.py │ │ ├── float_adapter.py │ │ ├── api_environment_type_adapter.py │ │ ├── date_time_adapter.py │ │ ├── monetary_account_reference_adapter.py │ │ ├── geolocation_adapter.py │ │ ├── installation_adapter.py │ │ ├── installation_context_adapter.py │ │ ├── share_detail_adapter.py │ │ ├── anchor_object_adapter.py │ │ ├── pagination_adapter.py │ │ └── session_server_adapter.py │ ├── model │ │ ├── __init__.py │ │ ├── core │ │ │ ├── __init__.py │ │ │ ├── anchor_object_interface.py │ │ │ ├── oauth_response_type.py │ │ │ ├── oauth_grant_type.py │ │ │ ├── id.py │ │ │ ├── uuid.py │ │ │ ├── public_key_server.py │ │ │ ├── session_token.py │ │ │ ├── notification_filter_url_user_internal.py │ │ │ ├── notification_filter_push_user_internal.py │ │ │ ├── notification_filter_url_monetary_account_internal.py │ │ │ ├── payment_service_provider_credential_internal.py │ │ │ ├── installation.py │ │ │ ├── oauth_authorization_uri.py │ │ │ ├── device_server_internal.py │ │ │ ├── session_server.py │ │ │ ├── oauth_access_token.py │ │ │ └── bunq_model.py │ │ └── generated │ │ │ └── __init__.py │ ├── util │ │ ├── __init__.py │ │ ├── type_alias.py │ │ └── util.py │ ├── exception │ │ ├── __init__.py │ │ ├── bad_request_exception.py │ │ ├── forbidden_exception.py │ │ ├── not_found_exception.py │ │ ├── unauthorized_exception.py │ │ ├── bunq_exception.py │ │ ├── uncaught_exception_error.py │ │ ├── method_not_allowed_exception.py │ │ ├── please_contact_bunq_exception.py │ │ ├── too_many_requests_exception.py │ │ ├── unknown_api_error_exception.py │ │ ├── api_exception.py │ │ ├── EXCEPTIONS.md │ │ └── exception_factory.py │ └── security │ │ ├── __init__.py │ │ └── security.py └── __init__.py ├── MANIFEST.in ├── .gitattributes ├── .gitmodules ├── requirements.txt ├── README.md ├── .idea ├── vcs.xml ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml └── codeStyleSettings.xml ├── setup.cfg ├── run.py ├── .zappr.yaml ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── slack-on-issue.yml ├── LICENSE.md ├── CONTRIBUTING.md ├── .gitignore └── setup.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.27.17.56 2 | -------------------------------------------------------------------------------- /bunq/sdk/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/http/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/context/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/http/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/json/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/context/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/exception/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/security/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/model/generated/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bunq/sdk/model/generated/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/model/generated/object/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include *.md 3 | include VERSION -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | bunq/sdk/model/generated/* linguist-generated=true 2 | -------------------------------------------------------------------------------- /tests/assets/vader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bunq/sdk_python/HEAD/tests/assets/vader.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "examples"] 2 | path = examples 3 | url = https://github.com/bunq/tinker_python.git 4 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/anchor_object_interface.py: -------------------------------------------------------------------------------- 1 | class AnchorObjectInterface: 2 | def is_all_field_none(self) -> None: 3 | pass 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aenum==2.2.4 2 | chardet==3.0.4 3 | pycryptodomex==3.9.8 4 | requests[socks]==2.24.0 5 | simplejson==3.17.2 6 | urllib3==1.25.10 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 📚 For full documentation about this sdk, visit [doc.bunq.com](https://doc.bunq.com/getting-started/tools/software-development-kits-sdks/python/usage). 2 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | 📚 For full API Test documentation, visit [doc.bunq.com](https://doc.bunq.com/getting-started/tools/software-development-kits-sdks/python/tests). 2 | -------------------------------------------------------------------------------- /bunq/sdk/exception/bad_request_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class BadRequestException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/forbidden_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class ForbiddenException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/not_found_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class NotFoundException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/unauthorized_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class UnauthorizedException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/util/type_alias.py: -------------------------------------------------------------------------------- 1 | from typing import Union, TypeVar 2 | 3 | JsonValue = Union[int, str, bool, float, bytes, list, dict, object, None] 4 | 5 | T = TypeVar('T') 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/bunq_exception.py: -------------------------------------------------------------------------------- 1 | class BunqException(Exception): 2 | def __init__(self, message: str) -> None: 3 | super(BunqException, self).__init__(message) 4 | -------------------------------------------------------------------------------- /bunq/sdk/exception/uncaught_exception_error.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class UncaughtExceptionError(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/method_not_allowed_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class MethodNotAllowedException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/please_contact_bunq_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class PleaseContactBunqException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/too_many_requests_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class TooManyRequestsException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /bunq/sdk/exception/unknown_api_error_exception.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.api_exception import ApiException 2 | 3 | 4 | class UnknownApiErrorException(ApiException): 5 | pass 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=0 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | import sys 3 | 4 | if len(sys.argv) != 2: 5 | print('Invalid argument count. Usage: python3 run.py examples/example_name.py') 6 | 7 | path = sys.argv[1] 8 | module_ = path.rstrip('.py').replace('/', '.') 9 | exec('import {}'.format(module_)) 10 | exec('{}.run()'.format(module_)) 11 | -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 11 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/oauth_response_type.py: -------------------------------------------------------------------------------- 1 | import aenum 2 | 3 | 4 | class OauthResponseType(aenum.AutoNumberEnum): 5 | """ 6 | :type CODE: str 7 | :type response_type: str 8 | """ 9 | 10 | CODE = 'code' 11 | 12 | def __init__(self, response_type: str) -> None: 13 | self._response_type = response_type 14 | 15 | @property 16 | def response_type(self) -> str: 17 | return self._response_type 18 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/oauth_grant_type.py: -------------------------------------------------------------------------------- 1 | import aenum 2 | 3 | 4 | class OauthGrantType(aenum.AutoNumberEnum): 5 | """ 6 | :type AUTHORIZATION_CODE: str 7 | :type grant_type: str 8 | """ 9 | 10 | AUTHORIZATION_CODE = 'authorization_code' 11 | 12 | def __init__(self, grant_type: str) -> None: 13 | self._grant_type = grant_type 14 | 15 | @property 16 | def grant_type(self) -> str: 17 | return self.grant_type 18 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/id.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.model.core.bunq_model import BunqModel 2 | 3 | 4 | class Id(BunqModel): 5 | """ 6 | :type _id_: int 7 | """ 8 | 9 | def __init__(self) -> None: 10 | self._id_ = None 11 | 12 | @property 13 | def id_(self) -> int: 14 | return self._id_ 15 | 16 | def is_all_field_none(self) -> bool: 17 | if self.id_ is not None: 18 | return False 19 | 20 | return True 21 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/uuid.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.model.core.bunq_model import BunqModel 2 | 3 | 4 | class Uuid(BunqModel): 5 | """ 6 | :type _uuid: str 7 | """ 8 | 9 | def __init__(self) -> None: 10 | self._uuid = None 11 | 12 | @property 13 | def uuid(self) -> str: 14 | return self._uuid 15 | 16 | def is_all_field_none(self) -> bool: 17 | if self.uuid is not None: 18 | return False 19 | 20 | return True 21 | -------------------------------------------------------------------------------- /bunq/sdk/http/http_util.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | 4 | class HttpUtil: 5 | QUERY_FORMAT = '{}={}' 6 | QUERY_DELIMITER = '&' 7 | 8 | @classmethod 9 | def create_query_string(cls, all_parameter: Dict[str, str]): 10 | encoded_parameters = [] 11 | 12 | for parameter, value in all_parameter.items(): 13 | encoded_parameters.append(cls.QUERY_FORMAT.format(parameter, value)) 14 | 15 | return cls.QUERY_DELIMITER.join(encoded_parameters) 16 | -------------------------------------------------------------------------------- /.zappr.yaml: -------------------------------------------------------------------------------- 1 | commit: 2 | message: 3 | patterns: 4 | - '([A-Za-z0-9 ]+)\. (\(bunq\/sdk_python#[0-9]+\))' 5 | specification: 6 | title: 7 | minimum-length: 8 | enabled: true 9 | length: 8 10 | body: 11 | minimum-length: 12 | enabled: true 13 | length: 8 14 | contains-url: true 15 | contains-issue-number: true 16 | template: 17 | differs-from-body: true 18 | pull-request: 19 | labels: 20 | additional: true 21 | required: 22 | - Can be merged 23 | -------------------------------------------------------------------------------- /bunq/sdk/context/api_environment_type.py: -------------------------------------------------------------------------------- 1 | import aenum 2 | 3 | 4 | class ApiEnvironmentType(aenum.AutoNumberEnum): 5 | """ 6 | :type PRODUCTION: ApiEnvironmentType 7 | :type SANDBOX: ApiEnvironmentType 8 | :type uri_base: str 9 | """ 10 | 11 | PRODUCTION = 'https://api.bunq.com/v1/' 12 | SANDBOX = 'https://public-api.sandbox.bunq.com/v1/' 13 | 14 | def __init__(self, uri_base: str) -> None: 15 | self._uri_base = uri_base 16 | 17 | @property 18 | def uri_base(self) -> str: 19 | return self._uri_base 20 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/public_key_server.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.model.core.bunq_model import BunqModel 2 | 3 | 4 | class PublicKeyServer(BunqModel): 5 | """ 6 | :type _server_public_key: str 7 | """ 8 | 9 | def __init__(self) -> None: 10 | self._server_public_key = None 11 | 12 | @property 13 | def server_public_key(self) -> str: 14 | return self._server_public_key 15 | 16 | def is_all_field_none(self) -> bool: 17 | if self.server_public_key is not None: 18 | return False 19 | 20 | return True 21 | -------------------------------------------------------------------------------- /bunq/sdk/http/bunq_response_raw.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | 4 | class BunqResponseRaw: 5 | """ 6 | :type _body_bytes: bytes 7 | :type _headers: dict[str, str] 8 | """ 9 | 10 | def __init__(self, 11 | body_bytes: bytes, 12 | headers: Dict[str, str]) -> None: 13 | self._body_bytes = body_bytes 14 | self._headers = headers 15 | 16 | @property 17 | def body_bytes(self) -> bytes: 18 | return self._body_bytes 19 | 20 | @property 21 | def headers(self) -> Dict[str, str]: 22 | return self._headers 23 | -------------------------------------------------------------------------------- /bunq/sdk/json/float_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from bunq.sdk.json import converter 4 | 5 | 6 | class FloatAdapter(converter.JsonAdapter): 7 | # Precision to round the floats to before outputting them 8 | _PRECISION_FLOAT = 2 9 | 10 | @classmethod 11 | def deserialize(cls, 12 | target_class: Type[float], 13 | string: str) -> float: 14 | _ = target_class 15 | 16 | return float(string) 17 | 18 | @classmethod 19 | def serialize(cls, number: float) -> str: 20 | return str(round(number, cls._PRECISION_FLOAT)) 21 | -------------------------------------------------------------------------------- /bunq/sdk/json/api_environment_type_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from bunq.sdk.context.api_environment_type import ApiEnvironmentType 4 | from bunq.sdk.json import converter 5 | 6 | 7 | class ApiEnvironmentTypeAdapter(converter.JsonAdapter): 8 | @classmethod 9 | def deserialize(cls, 10 | target_class: Type[ApiEnvironmentType], 11 | name: str) -> ApiEnvironmentType: 12 | return ApiEnvironmentType[name] 13 | 14 | @classmethod 15 | def serialize(cls, api_environment_type: ApiEnvironmentType) -> str: 16 | return api_environment_type.name 17 | -------------------------------------------------------------------------------- /bunq/sdk/json/date_time_adapter.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Type 3 | 4 | from bunq.sdk.json import converter 5 | 6 | 7 | class DateTimeAdapter(converter.JsonAdapter): 8 | # bunq timestamp format 9 | _FORMAT_TIMESTAMP = '%Y-%m-%d %H:%M:%S.%f' 10 | 11 | @classmethod 12 | def deserialize(cls, 13 | target_class: Type[datetime.datetime], 14 | string: str) -> datetime.datetime: 15 | return target_class.strptime(string, cls._FORMAT_TIMESTAMP) 16 | 17 | @classmethod 18 | def serialize(cls, timestamp: datetime.datetime) -> str: 19 | return timestamp.strftime(cls._FORMAT_TIMESTAMP) 20 | -------------------------------------------------------------------------------- /bunq/sdk/exception/api_exception.py: -------------------------------------------------------------------------------- 1 | class ApiException(Exception): 2 | def __init__(self, 3 | message: str, 4 | response_code: int, 5 | response_id: str) -> None: 6 | self._response_id = response_id 7 | self._message = message 8 | self._response_code = response_code 9 | 10 | super(ApiException, self).__init__(message) 11 | 12 | @property 13 | def message(self) -> str: 14 | return self._message 15 | 16 | @property 17 | def response_code(self) -> int: 18 | return self._response_code 19 | 20 | @property 21 | def response_id(self) -> str: 22 | return self._response_id 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Steps to reproduce: 2 | 1. 3 | 4 | ## What should happen: 5 | 1. 6 | 7 | ## What happens: 8 | 1. 9 | 10 | ## Traceback 11 | [//]: # (If there is a traceback please share it in a quote! You can do this by pasting the traceback text, highlighting it and pressing the quote button.) 12 | 13 | ## SDK version and environment 14 | - Tested on [1.14.1](https://github.com/bunq/sdk_python/releases/tag/1.14.1) 15 | - [ ] Sandbox 16 | - [ ] Production 17 | 18 | ## Response id 19 | [//]: # (If this error has something to do with a request that fails, please provide the response id of the request.) 20 | - Response id: 21 | 22 | ## Extra info: 23 | [//]: # (Please provide any other relevant information here) 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | [//]: # (Thanks for opening this pull request! Before you proceed please make sure that you have an issue that explains what this pull request will do. 2 | Make sure that all your commits link to this issue e.g. "My commit. \(bunq/sdk_python#\)". 3 | If this pull request is changing files that are located in "bunq/sdk/model/generated" then this pull request will be closed as these files must/can only be changed on bunq's side.) 4 | 5 | ## This PR closes/fixes the following issues: 6 | [//]: # (If for some reason your pull request does not require a test case you can just mark this box as checked and explain why it does not require a test case.) 7 | - Closes bunq/sdk_python# 8 | - [ ] Tested 9 | -------------------------------------------------------------------------------- /tests/context/test_user_context.py: -------------------------------------------------------------------------------- 1 | from tests.bunq_test import BunqSdkTestCase 2 | 3 | class TestUserContext(BunqSdkTestCase): 4 | """ 5 | Tests: 6 | UserContext 7 | """ 8 | 9 | @classmethod 10 | def setUpClass(cls): 11 | super().setUpClass() 12 | cls._API_CONTEXT = cls._get_api_context() 13 | 14 | def test_build_user_context(self): 15 | from bunq.sdk.context.user_context import UserContext 16 | user_context = UserContext( 17 | self._API_CONTEXT.session_context.user_id, 18 | self._API_CONTEXT.session_context.get_user_reference() 19 | ) 20 | user_context.refresh_user_context() 21 | 22 | self.assertIsNotNone(user_context.user_id) 23 | self.assertIsNotNone(user_context.primary_monetary_account.id_) 24 | -------------------------------------------------------------------------------- /tests/http/test_bad_request_with_response_id.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.context.bunq_context import BunqContext 2 | from bunq.sdk.exception.api_exception import ApiException 3 | from bunq.sdk.model.generated.endpoint import MonetaryAccountBankApiObject 4 | from tests.bunq_test import BunqSdkTestCase 5 | 6 | 7 | class TestPagination(BunqSdkTestCase): 8 | """ 9 | Tests if the response id from a failed request can be retrieved 10 | successfully. 11 | """ 12 | 13 | _INVALID_MONETARY_ACCOUNT_ID = 0 14 | 15 | def test_bad_request_with_response_id(self): 16 | BunqContext.load_api_context(self._get_api_context()) 17 | 18 | with self.assertRaises(ApiException) as caught_exception: 19 | MonetaryAccountBankApiObject.get(self._INVALID_MONETARY_ACCOUNT_ID) 20 | self.assertIsNotNone(caught_exception.exception.response_id) 21 | -------------------------------------------------------------------------------- /bunq/sdk/json/monetary_account_reference_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Dict 2 | 3 | from bunq.sdk.json import converter 4 | from bunq.sdk.model.generated.object_ import MonetaryAccountReference, LabelMonetaryAccountObject 5 | 6 | 7 | class MonetaryAccountReferenceAdapter(converter.JsonAdapter): 8 | @classmethod 9 | def deserialize(cls, 10 | target_class: Type[MonetaryAccountReference], 11 | obj: Dict) -> MonetaryAccountReference: 12 | label_monetary_account = converter.deserialize(LabelMonetaryAccountObject, obj) 13 | 14 | return target_class.create_from_label_monetary_account(label_monetary_account) 15 | 16 | @classmethod 17 | def serialize(cls, monetary_account_reference: MonetaryAccountReference) -> Dict: 18 | return converter.serialize(monetary_account_reference.pointer) 19 | -------------------------------------------------------------------------------- /bunq/sdk/context/installation_context.py: -------------------------------------------------------------------------------- 1 | from Cryptodome.PublicKey import RSA 2 | from Cryptodome.PublicKey.RSA import RsaKey 3 | 4 | 5 | class InstallationContext: 6 | """ 7 | :type _token: str 8 | :type _private_key_client: RSA.RsaKey 9 | :type _public_key_server: RSA.RsaKey 10 | """ 11 | 12 | def __init__(self, 13 | token: str, 14 | private_key_client: RsaKey, 15 | public_key_server: RsaKey) -> None: 16 | self._token = token 17 | self._private_key_client = private_key_client 18 | self._public_key_server = public_key_server 19 | 20 | @property 21 | def token(self) -> str: 22 | return self._token 23 | 24 | @property 25 | def private_key_client(self) -> RsaKey: 26 | return self._private_key_client 27 | 28 | @property 29 | def public_key_server(self) -> RsaKey: 30 | return self._public_key_server 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 bunq b.v. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_session.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from bunq.sdk.context.bunq_context import BunqContext 4 | from bunq.sdk.model.generated.endpoint import SessionApiObject 5 | from tests.bunq_test import BunqSdkTestCase 6 | 7 | 8 | class TestSession(BunqSdkTestCase): 9 | """ 10 | Tests: 11 | SessionApiObject 12 | """ 13 | 14 | _SESSION_ID = 0 15 | _BUNQ_CONFIG_FILE = "bunq-test.conf" 16 | _DEVICE_DESCRIPTION = 'Python test device' 17 | 18 | def test_session_delete(self): 19 | """ 20 | Tests the deletion and resetting of the current active session 21 | 22 | This test has no assertion as of its testing to see if the code runs 23 | without errors. 24 | 25 | Notes 26 | ----- 27 | time.sleep() is needed as of you can only make 1 POST call to 28 | Session endpoint per second. 29 | """ 30 | 31 | SessionApiObject.delete(self._SESSION_ID) 32 | time.sleep(2) 33 | BunqContext.api_context().reset_session() 34 | BunqContext.api_context().save(self._BUNQ_CONFIG_FILE) 35 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/session_token.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.model.core.bunq_model import BunqModel 2 | 3 | 4 | class SessionToken(BunqModel): 5 | """ 6 | :type _id_: int 7 | :type _created: str 8 | :type _updated: str 9 | :type _token: str 10 | """ 11 | 12 | def __init__(self) -> None: 13 | self._id_ = None 14 | self._created = None 15 | self._updated = None 16 | self._token = None 17 | 18 | @property 19 | def id_(self) -> int: 20 | return self._id_ 21 | 22 | @property 23 | def created(self) -> str: 24 | return self._created 25 | 26 | @property 27 | def updated(self) -> str: 28 | return self._updated 29 | 30 | @property 31 | def token(self) -> str: 32 | return self._token 33 | 34 | def is_all_field_none(self) -> bool: 35 | if self.id_ is not None: 36 | return False 37 | 38 | if self.created is not None: 39 | return False 40 | 41 | if self.updated is not None: 42 | return False 43 | 44 | if self.token is not None: 45 | return False 46 | 47 | return True 48 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/ChatMessageAnnouncement.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "BUNQME_TAB", 5 | "event_type": "BUNQME_TAB_CREATED", 6 | "object": { 7 | "ChatMessageAnnouncement": { 8 | "id": 30, 9 | "created": "2017-09-05 12:00:00.912674", 10 | "updated": "2017-09-05 12:00:00.912674", 11 | "conversation_id": 3, 12 | "content": { 13 | "ChatMessageContentText": { 14 | "text": "Hey! You have failed to pay more than five direct debits over the past 30 days. Although such things can happen, please keep in mind that we might be forced to take measures if we get a lot of complaints about it. To help prevent this we summarised some information which might prove useful.\n\nRead more here. https:\/\/www.bunq.com\/en\/direct-debits\/\n\nPlease let us know if you have any further questions, we would love to help you out!" 15 | } 16 | }, 17 | "client_message_uuid": null 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/model/core/test_oauth_authorization_uri.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.context.bunq_context import BunqContext 2 | from bunq.sdk.model.core.oauth_authorization_uri import OauthAuthorizationUri 3 | from bunq.sdk.model.core.oauth_response_type import OauthResponseType 4 | from bunq.sdk.model.generated.endpoint import OauthClient 5 | from tests.bunq_test import BunqSdkTestCase 6 | 7 | 8 | class TestOauthAuthorizationUri(BunqSdkTestCase): 9 | _TEST_EXPECT_URI = 'https://oauth.sandbox.bunq.com/auth?redirect_uri=redirecturi&response_type=code&state=state' 10 | _TEST_REDIRECT_URI = 'redirecturi' 11 | _TEST_STATUS = 'status' 12 | _TEST_STATE = 'state' 13 | 14 | @classmethod 15 | def setUpClass(cls) -> None: 16 | BunqContext.load_api_context(cls._get_api_context()) 17 | 18 | def test_oauth_authorization_uri_create(self) -> None: 19 | uri = OauthAuthorizationUri.create( 20 | OauthResponseType(OauthResponseType.CODE), 21 | self._TEST_REDIRECT_URI, 22 | OauthClient(self._TEST_STATUS), 23 | self._TEST_STATE 24 | ).get_authorization_uri() 25 | 26 | self.assertEqual(self._TEST_EXPECT_URI, uri) 27 | -------------------------------------------------------------------------------- /bunq/sdk/http/bunq_response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Dict, Generic 4 | 5 | from bunq import Pagination 6 | from bunq.sdk.util.type_alias import T 7 | 8 | 9 | class BunqResponse(Generic[T]): 10 | """ 11 | :type _value: T 12 | :type _headers: dict[str, str] 13 | :type _pagination: Pagination|None 14 | """ 15 | 16 | def __init__(self, 17 | value: T, 18 | headers: Dict[str, str], 19 | pagination: Pagination = None) -> None: 20 | self._value = value 21 | self._headers = headers 22 | self._pagination = pagination 23 | 24 | @property 25 | def value(self) -> T: 26 | return self._value 27 | 28 | @property 29 | def headers(self) -> Dict[str, str]: 30 | return self._headers 31 | 32 | @property 33 | def pagination(self) -> Pagination: 34 | return self._pagination 35 | 36 | @classmethod 37 | def cast_from_bunq_response(cls, bunq_response: BunqResponse) -> BunqResponse: 38 | return cls( 39 | bunq_response.value, 40 | bunq_response.headers, 41 | bunq_response.pagination 42 | ) 43 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_monetary_account_joint.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from bunq.sdk.model.generated.endpoint import MonetaryAccountJointApiObject 4 | from tests.bunq_test import BunqSdkTestCase 5 | 6 | 7 | class TestMonetaryAccountJoint(BunqSdkTestCase): 8 | """ 9 | Tests: 10 | - MonetaryAccountJointApiObject 11 | - CoOwner 12 | """ 13 | 14 | _BASE_PATH_JSON_MODEL = '../../../assets/ResponseJsons' 15 | _MONETARY_ACCOUNT_JOINT_JSON = '/MonetaryAccountJoint.json' 16 | _FILE_MODE_READ = 'r' 17 | 18 | @classmethod 19 | def setUpClass(cls): 20 | pass 21 | 22 | def setUp(self): 23 | pass 24 | 25 | def test_monetary_account_joint_parser(self): 26 | base_path = os.path.dirname(__file__) 27 | file_path = os.path.abspath( 28 | os.path.join(base_path, self._BASE_PATH_JSON_MODEL + self._MONETARY_ACCOUNT_JOINT_JSON) 29 | ) 30 | 31 | with open(file_path, self._FILE_MODE_READ) as f: 32 | json_string = f.read() 33 | 34 | joint_account = MonetaryAccountJointApiObject.from_json(json_string) 35 | 36 | self.assertIsInstance(joint_account, MonetaryAccountJointApiObject) 37 | self.assertIsNotNone(joint_account) 38 | self.assertIsNotNone(joint_account.all_co_owner) 39 | 40 | for co_owner in joint_account.all_co_owner: 41 | self.assertIsNotNone(co_owner.alias) 42 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_attachment_public.py: -------------------------------------------------------------------------------- 1 | from typing import AnyStr 2 | 3 | from bunq.sdk.http.api_client import ApiClient 4 | from bunq.sdk.model.generated.endpoint import AttachmentPublicContentApiObject, AttachmentPublicApiObject 5 | from tests.bunq_test import BunqSdkTestCase 6 | 7 | 8 | class TestAttachmentPublicApiObject(BunqSdkTestCase): 9 | """ 10 | Tests: 11 | AttachmentPublicApiObject 12 | AttachmentPublicContentApiObject 13 | """ 14 | 15 | def test_file_upload_and_retrieval(self): 16 | """ 17 | Tests uploading an attachment, retrieves it and compare them to see 18 | if the uploaded attachment is indeed the attachment we are getting 19 | back. 20 | """ 21 | 22 | custom_headers = { 23 | ApiClient.HEADER_CONTENT_TYPE: self._CONTENT_TYPE, 24 | ApiClient.HEADER_ATTACHMENT_DESCRIPTION: 25 | self._ATTACHMENT_DESCRIPTION, 26 | } 27 | 28 | attachment_uuid = AttachmentPublicApiObject.create(self.attachment_contents, custom_headers).value 29 | contents_from_response = AttachmentPublicContentApiObject.list(attachment_uuid).value 30 | 31 | self.assertEqual(self.attachment_contents, contents_from_response) 32 | 33 | @property 34 | def attachment_contents(self) -> AnyStr: 35 | with open(self._PATH_ATTACHMENT + self._ATTACHMENT_PATH_IN, self._READ_BYTES) as f: 36 | return f.read() 37 | -------------------------------------------------------------------------------- /bunq/sdk/json/geolocation_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Dict 2 | 3 | from bunq.sdk.json import converter 4 | from bunq.sdk.model.generated.object_ import GeolocationObject 5 | 6 | 7 | class GeolocationAdapter(converter.JsonAdapter): 8 | # Field constants 9 | _FIELD_LATITUDE = 'latitude' 10 | _FIELD_LONGITUDE = 'longitude' 11 | _FIELD_ALTITUDE = 'altitude' 12 | _FIELD_RADIUS = 'radius' 13 | 14 | @classmethod 15 | def can_deserialize(cls) -> bool: 16 | return False 17 | 18 | @classmethod 19 | def deserialize(cls, 20 | target_class: Type[float], 21 | obj: Dict) -> None: 22 | """ 23 | 24 | :raise: NotImplementedError 25 | """ 26 | 27 | raise NotImplementedError() 28 | 29 | @classmethod 30 | def serialize(cls, geolocation: GeolocationObject) -> Dict: 31 | obj = {} 32 | 33 | cls.add_if_not_none(obj, cls._FIELD_LATITUDE, geolocation.latitude) 34 | cls.add_if_not_none(obj, cls._FIELD_LONGITUDE, geolocation.longitude) 35 | cls.add_if_not_none(obj, cls._FIELD_ALTITUDE, geolocation.altitude) 36 | cls.add_if_not_none(obj, cls._FIELD_RADIUS, geolocation.radius) 37 | 38 | return obj 39 | 40 | @classmethod 41 | def add_if_not_none(cls, 42 | dict_: Dict[str, str], 43 | key: str, 44 | value: float) -> None: 45 | if value is not None: 46 | dict_[key] = str(value) 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### How to contribute to the bunq Python SDK 😎 2 | 3 | #### Want to add a new amazing feature to our SDK? 🚀 4 | - First let’s discuss the feature that you would like to add. [Open a new issue](https://github.com/bunq/sdk_python/issues/new), describe the feature and explain why you think it should be added. 5 | - Once we agree on the new feature, open a new GitHub pull request and include all the relevant information to get your code approved! 6 | 7 | #### Did you find a bug? 🐛 8 | - Before opening a new issue check if the bug hasn't already been reported by searching on GitHub under [issues](https://github.com/bunq/sdk_python/issues). 9 | - If it hasn't already been reported you can [open a new issue](https://github.com/bunq/sdk_python/issues/new). Make sure you include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behaviour that is not occurring. 10 | - If you wrote a patch that fixes a bug, open a new GitHub pull request and make sure to clearly describe the problem and your awesome solution. 11 | 12 | #### Do you have questions about the source code? 13 | - You are welcome to ask whatever you want about the bunq Python SDK on [Together](https://together.bunq.com) 🎤 14 | 15 | #### Are you looking for a challenging new adventure? 16 | - We're always looking for the best developers to come and join us at the Bank of the Free! 🌈 [Check out our vacancies](https://www.bunq.com/en/jobs)! 17 | 18 | Thanks, and have fun! 💪 19 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/notification_filter_url_user_internal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Dict 4 | 5 | from bunq.sdk.http.api_client import ApiClient 6 | from bunq.sdk.http.bunq_response import BunqResponse 7 | from bunq.sdk.json import converter 8 | from bunq.sdk.model.generated.endpoint import NotificationFilterUrlUser 9 | from bunq.sdk.model.generated.object_ import NotificationFilterUrl 10 | 11 | 12 | class NotificationFilterUrlUserInternal(NotificationFilterUrlUser): 13 | @classmethod 14 | def create_with_list_response(cls, 15 | all_notification_filter: List[NotificationFilterUrl] = None, 16 | custom_headers: Dict[str, str] = None 17 | ) -> BunqResponse[List[NotificationFilterUrl]]: 18 | if all_notification_filter is None: 19 | all_notification_filter = [] 20 | 21 | if custom_headers is None: 22 | custom_headers = {} 23 | 24 | request_map = { 25 | cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter 26 | } 27 | request_map_string = converter.class_to_json(request_map) 28 | request_map_string = cls._remove_field_for_request(request_map_string) 29 | 30 | api_client = ApiClient(cls._get_api_context()) 31 | request_bytes = request_map_string.encode() 32 | endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id()) 33 | response_raw = api_client.post(endpoint_url, request_bytes, custom_headers) 34 | 35 | return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET) 36 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/notification_filter_push_user_internal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Dict 4 | 5 | from bunq.sdk.http.api_client import ApiClient 6 | from bunq.sdk.http.bunq_response import BunqResponse 7 | from bunq.sdk.json import converter 8 | from bunq.sdk.model.generated.endpoint import NotificationFilterPushUser 9 | from bunq.sdk.model.generated.object_ import NotificationFilterPush, NotificationFilterUrl 10 | 11 | 12 | class NotificationFilterPushUserInternal(NotificationFilterPushUser): 13 | @classmethod 14 | def create_with_list_response(cls, 15 | all_notification_filter: List[NotificationFilterPush] = None, 16 | custom_headers: Dict[str, str] = None 17 | ) -> BunqResponse[List[NotificationFilterPush]]: 18 | if all_notification_filter is None: 19 | all_notification_filter = [] 20 | 21 | if custom_headers is None: 22 | custom_headers = {} 23 | 24 | request_map = { 25 | cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter 26 | } 27 | request_map_string = converter.class_to_json(request_map) 28 | request_map_string = cls._remove_field_for_request(request_map_string) 29 | 30 | api_client = ApiClient(cls._get_api_context()) 31 | request_bytes = request_map_string.encode() 32 | endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id()) 33 | response_raw = api_client.post(endpoint_url, request_bytes, custom_headers) 34 | 35 | return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET) 36 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_monetary_account_bank.py: -------------------------------------------------------------------------------- 1 | try: 2 | from secrets import token_hex 3 | except ImportError: 4 | from os import urandom 5 | 6 | 7 | def token_hex(): 8 | """ Function to replace import for Python < 3.6. """ 9 | return urandom(16).hex() 10 | 11 | from bunq.sdk.model.generated.endpoint import MonetaryAccountBankApiObject 12 | from tests.bunq_test import BunqSdkTestCase 13 | 14 | 15 | class TestMonetaryAccount(BunqSdkTestCase): 16 | """ 17 | Tests: 18 | MonetaryAccountBank 19 | """ 20 | 21 | _STATUS = 'CANCELLED' 22 | _SUB_STATUS = 'REDEMPTION_VOLUNTARY' 23 | _REASON = 'OTHER' 24 | _REASON_DESCRIPTION = 'Because this is a test' 25 | _CURRENCY = 'EUR' 26 | _MONETARY_ACCOUNT_PREFIX = 'Python_test' 27 | 28 | def test_create_new_monetary_account(self): 29 | """ 30 | Tests the creation of a new monetary account. This account will be 31 | deleted after test exited with code 0. 32 | 33 | This test has no assertion as of its testing to see if the code runs 34 | without errors 35 | """ 36 | 37 | monetary_account_id = MonetaryAccountBankApiObject.create( 38 | self._CURRENCY, 39 | self._MONETARY_ACCOUNT_PREFIX + token_hex() 40 | ).value 41 | 42 | MonetaryAccountBankApiObject.update(monetary_account_id, 43 | status=self._STATUS, 44 | sub_status=self._SUB_STATUS, 45 | reason=self._REASON, 46 | reason_description=self._REASON_DESCRIPTION 47 | ) 48 | -------------------------------------------------------------------------------- /bunq/sdk/context/bunq_context.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.context.api_context import ApiContext 2 | from bunq.sdk.context.user_context import UserContext 3 | from bunq.sdk.exception.bunq_exception import BunqException 4 | 5 | 6 | class BunqContext: 7 | _ERROR_CLASS_SHOULD_NOT_BE_INITIALIZED = 'This class should not be instantiated' 8 | _ERROR_API_CONTEXT_HAS_NOT_BEEN_LOADED = 'ApiContext has not been loaded. Please load ApiContext in BunqContext' 9 | _ERROR_USER_CONTEXT_HAS_NOT_BEEN_LOADED = 'UserContext has not been loaded, please load this by loading ApiContext.' 10 | 11 | _api_context = None 12 | _user_context = None 13 | 14 | def __init__(self) -> None: 15 | raise TypeError(self._ERROR_CLASS_SHOULD_NOT_BE_INITIALIZED) 16 | 17 | @classmethod 18 | def load_api_context(cls, api_context: ApiContext) -> None: 19 | cls._api_context = api_context 20 | cls._user_context = UserContext( 21 | api_context.session_context.user_id, 22 | api_context.session_context.get_user_reference() 23 | ) 24 | cls._user_context.init_main_monetary_account() 25 | 26 | @classmethod 27 | def api_context(cls) -> ApiContext: 28 | if cls._api_context is not None: 29 | return cls._api_context 30 | 31 | raise BunqException(cls._ERROR_API_CONTEXT_HAS_NOT_BEEN_LOADED) 32 | 33 | @classmethod 34 | def user_context(cls) -> UserContext: 35 | if cls._user_context is not None: 36 | return cls._user_context 37 | 38 | raise BunqException(cls._ERROR_USER_CONTEXT_HAS_NOT_BEEN_LOADED) 39 | 40 | @classmethod 41 | def update_api_context(cls, api_context: ApiContext) -> None: 42 | cls._api_context = api_context 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/pycharm 3 | 4 | ### PyCharm ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff: 9 | .idea 10 | 11 | # CMake 12 | cmake-build-debug/ 13 | 14 | ## File-based project format: 15 | *.iws 16 | 17 | ## Plugin-specific files: 18 | 19 | # IntelliJ 20 | /out/ 21 | 22 | # mpeltonen/sbt-idea plugin 23 | .idea_modules/ 24 | 25 | # JIRA plugin 26 | atlassian-ide-plugin.xml 27 | 28 | # Cursive Clojure plugin 29 | .idea/replstate.xml 30 | 31 | # Crashlytics plugin (for Android Studio and IntelliJ) 32 | com_crashlytics_export_strings.xml 33 | crashlytics.properties 34 | crashlytics-build.properties 35 | fabric.properties 36 | 37 | ### PyCharm Patch ### 38 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 39 | 40 | *.iml 41 | modules.xml 42 | .idea/misc.xml 43 | *.ipr 44 | 45 | # Sonarlint plugin 46 | .idea/sonarlint 47 | 48 | # End of https://www.gitignore.io/api/pycharm 49 | 50 | # Python specific 51 | /.eggs 52 | /build 53 | /dist 54 | /MANIFEST 55 | /stripe.egg-info 56 | /bunq_env 57 | /.vscode 58 | .python-version 59 | *.pyc 60 | *.egg 61 | *.class 62 | 63 | # Unit test / coverage reports 64 | .tox/ 65 | .coverage 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | htmlcov/ 70 | 71 | # bunq specific 72 | bunq.conf 73 | bunq-test.conf 74 | **/tmp 75 | config.json 76 | connectQr.png 77 | .DS_Store 78 | bunq_sdk.egg-info 79 | .idea/codeStyles/ 80 | venv 81 | 82 | tests/bunq-oauth-psd2-test.conf 83 | tests/bunq-psd2-test.conf 84 | tests/key.pem 85 | tests/certificate.pem 86 | 87 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_avatar.py: -------------------------------------------------------------------------------- 1 | from typing import AnyStr 2 | 3 | from bunq.sdk.http.api_client import ApiClient 4 | from bunq.sdk.model.generated.endpoint import AttachmentPublicApiObject 5 | from bunq.sdk.model.generated.endpoint import AttachmentPublicContentApiObject 6 | from bunq.sdk.model.generated.endpoint import AvatarApiObject 7 | from tests.bunq_test import BunqSdkTestCase 8 | 9 | 10 | class TestAvatar(BunqSdkTestCase): 11 | """ 12 | Tests: 13 | AvatarApiObject 14 | AttachmentPublicApiObject 15 | AttachmentPublicContentApiObject 16 | """ 17 | 18 | def test_avatar_creation(self): 19 | """ 20 | Tests the creation of an avatar by uploading a picture via 21 | AttachmentPublicApiObject and setting it as avatar via the uuid 22 | """ 23 | 24 | custom_header = { 25 | ApiClient.HEADER_ATTACHMENT_DESCRIPTION: 26 | self._ATTACHMENT_DESCRIPTION, 27 | ApiClient.HEADER_CONTENT_TYPE: self._CONTENT_TYPE 28 | } 29 | attachment_public_uuid = AttachmentPublicApiObject.create(self.attachment_contents, custom_header).value 30 | avatar_uuid = AvatarApiObject.create(attachment_public_uuid).value 31 | attachment_uuid_after = AvatarApiObject.get(avatar_uuid).value.image[self._FIRST_INDEX].attachment_public_uuid 32 | file_contents_received = AttachmentPublicContentApiObject.list(attachment_uuid_after).value 33 | 34 | self.assertEqual(self.attachment_contents, file_contents_received) 35 | 36 | @property 37 | def attachment_contents(self) -> AnyStr: 38 | with open(self._PATH_ATTACHMENT + self._ATTACHMENT_PATH_IN, self._READ_BYTES) as file: 39 | return file.read() 40 | -------------------------------------------------------------------------------- /.github/workflows/slack-on-issue.yml: -------------------------------------------------------------------------------- 1 | name: Notify Slack on New Issue or PR 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | pull_request: 7 | types: [opened] 8 | 9 | jobs: 10 | notify: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Send Slack notification 14 | run: | 15 | if [[ "${{ github.event_name }}" == "issues" ]]; then 16 | TYPE="Issue" 17 | TITLE="${{ github.event.issue.title }}" 18 | URL="${{ github.event.issue.html_url }}" 19 | USER="${{ github.event.issue.user.login }}" 20 | else 21 | TYPE="Pull Request" 22 | TITLE="${{ github.event.pull_request.title }}" 23 | URL="${{ github.event.pull_request.html_url }}" 24 | USER="${{ github.event.pull_request.user.login }}" 25 | fi 26 | 27 | PAYLOAD=$(jq -n \ 28 | --arg type "$TYPE" \ 29 | --arg title "$TITLE" \ 30 | --arg url "$URL" \ 31 | --arg user "$USER" \ 32 | '{ 33 | text: "*New GitHub \($type)* :sparkles:", 34 | attachments: [ 35 | { 36 | color: "#36a64f", 37 | title: $title, 38 | title_link: $url, 39 | fields: [ 40 | { 41 | title: "Author", 42 | value: $user, 43 | short: true 44 | } 45 | ] 46 | } 47 | ] 48 | }') 49 | 50 | curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" $SLACK_WEBHOOK_URL 51 | env: 52 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 53 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 42 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/notification_filter_url_monetary_account_internal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Dict 4 | 5 | from bunq.sdk.http.api_client import ApiClient 6 | from bunq.sdk.http.bunq_response import BunqResponse 7 | from bunq.sdk.json import converter 8 | from bunq.sdk.model.generated.endpoint import NotificationFilterUrlMonetaryAccount 9 | from bunq.sdk.model.generated.object_ import NotificationFilterUrl 10 | 11 | 12 | class NotificationFilterUrlMonetaryAccountInternal(NotificationFilterUrlMonetaryAccount): 13 | @classmethod 14 | def create_with_list_response(cls, 15 | monetary_account_id: int = None, 16 | all_notification_filter: List[NotificationFilterUrl] = None, 17 | custom_headers: Dict[str, str] = None 18 | ) -> BunqResponse[List[NotificationFilterUrl]]: 19 | if all_notification_filter is None: 20 | all_notification_filter = [] 21 | 22 | if custom_headers is None: 23 | custom_headers = {} 24 | 25 | request_map = { 26 | cls.FIELD_NOTIFICATION_FILTERS: all_notification_filter 27 | } 28 | request_map_string = converter.class_to_json(request_map) 29 | request_map_string = cls._remove_field_for_request(request_map_string) 30 | 31 | api_client = ApiClient(cls._get_api_context()) 32 | request_bytes = request_map_string.encode() 33 | endpoint_url = cls._ENDPOINT_URL_CREATE.format(cls._determine_user_id(), 34 | cls._determine_monetary_account_id(monetary_account_id)) 35 | response_raw = api_client.post(endpoint_url, request_bytes, custom_headers) 36 | 37 | return NotificationFilterUrl._from_json_list(response_raw, cls._OBJECT_TYPE_GET) 38 | -------------------------------------------------------------------------------- /bunq/sdk/util/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import socket 5 | 6 | import requests 7 | 8 | from bunq.sdk.context.api_context import ApiContext, ApiEnvironmentType 9 | from bunq.sdk.exception.bunq_exception import BunqException 10 | from bunq.sdk.http.api_client import ApiClient 11 | from bunq.sdk.model.generated.endpoint import SandboxUserPersonApiObject 12 | 13 | __UNIQUE_REQUEST_ID = "uniqueness-is-required" 14 | __FIELD_API_KEY = "ApiKey" 15 | __INDEX_FIRST = 0 16 | __FIELD_RESPONSE = "Response" 17 | __ENDPOINT_SANDBOX_USER_PERSON = "sandbox-user-person" 18 | 19 | # Error constants 20 | _ERROR_COULD_NOT_CREATE_NEW_SANDBOX_USER = "Could not create new sandbox user." 21 | _ERROR_ALL_FIELD_IS_NULL = 'All fields are null' 22 | 23 | 24 | def automatic_sandbox_install() -> ApiContext: 25 | sandbox_user = __generate_new_sandbox_user() 26 | 27 | return ApiContext.create(ApiEnvironmentType.SANDBOX, sandbox_user.api_key, socket.gethostname()) 28 | 29 | 30 | def __generate_new_sandbox_user() -> SandboxUserPersonApiObject: 31 | url = ApiEnvironmentType.SANDBOX.uri_base + __ENDPOINT_SANDBOX_USER_PERSON 32 | 33 | headers = { 34 | ApiClient.HEADER_REQUEST_ID: __UNIQUE_REQUEST_ID, 35 | ApiClient.HEADER_CACHE_CONTROL: ApiClient.CACHE_CONTROL_NONE, 36 | ApiClient.HEADER_GEOLOCATION: ApiClient.GEOLOCATION_ZERO, 37 | ApiClient.HEADER_LANGUAGE: ApiClient.LANGUAGE_EN_US, 38 | ApiClient.HEADER_REGION: ApiClient.REGION_NL_NL, 39 | } 40 | 41 | response = requests.request(ApiClient.METHOD_POST, url, headers=headers) 42 | 43 | if response.status_code is ApiClient.STATUS_CODE_OK: 44 | response_json = json.loads(response.text) 45 | 46 | return SandboxUserPersonApiObject.from_json(json.dumps(response_json[__FIELD_RESPONSE][__INDEX_FIRST][__FIELD_API_KEY])) 47 | 48 | raise BunqException(_ERROR_COULD_NOT_CREATE_NEW_SANDBOX_USER) 49 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_request_inquiry.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.model.generated.endpoint import RequestInquiryApiObject 2 | from bunq.sdk.model.generated.endpoint import RequestResponseApiObject 3 | from bunq.sdk.model.generated.object_ import AmountObject 4 | from tests.bunq_test import BunqSdkTestCase 5 | 6 | 7 | class TestRequestEnquiry(BunqSdkTestCase): 8 | """ 9 | Tests: 10 | RequestInquiryApiObject 11 | RequestResponseApiObject 12 | """ 13 | 14 | _REQUEST_AMOUNT_EUR = '0.01' 15 | _REQUEST_CURRENCY = 'EUR' 16 | _DESCRIPTION = 'Python unit test request' 17 | _FIELD_STATUS = 'status' 18 | _STATUS_ACCEPTED = 'ACCEPTED' 19 | _STATUS_PENDING = 'PENDING' 20 | 21 | def test_sending_and_accepting_request(self): 22 | """ 23 | Tests sending a request from monetary account 1 to monetary account 2 24 | and accepting this request 25 | 26 | This test has no assertion as of its testing to see if the code runs 27 | without errors 28 | """ 29 | 30 | self.send_request() 31 | 32 | url_params_count_only_expected = { 33 | self._FIELD_STATUS: self._STATUS_PENDING 34 | } 35 | 36 | request_response_id = RequestResponseApiObject.list(self._second_monetary_account.id_, url_params_count_only_expected).value[self._FIRST_INDEX].id_ 37 | 38 | self.accept_request(request_response_id) 39 | 40 | def send_request(self) -> None: 41 | RequestInquiryApiObject.create( 42 | AmountObject(self._REQUEST_AMOUNT_EUR, self._REQUEST_CURRENCY), 43 | self._get_alias_second_account(), 44 | self._DESCRIPTION, 45 | False 46 | ) 47 | 48 | def accept_request(self, response_id: int) -> None: 49 | RequestResponseApiObject.update( 50 | response_id, 51 | self._second_monetary_account.id_, 52 | None, 53 | self._STATUS_ACCEPTED 54 | ) -------------------------------------------------------------------------------- /bunq/sdk/model/core/payment_service_provider_credential_internal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | 6 | from bunq.sdk.http.api_client import ApiClient 7 | from bunq.sdk.json import converter 8 | from bunq.sdk.model.generated.endpoint import PaymentServiceProviderCredentialApiObject, UserCredentialPasswordIpApiObject 9 | 10 | if typing.TYPE_CHECKING: 11 | from bunq.sdk.context.api_context import ApiContext 12 | 13 | 14 | class PaymentServiceProviderCredentialInternal(PaymentServiceProviderCredentialApiObject): 15 | @classmethod 16 | def create_with_api_context(cls, 17 | client_payment_service_provider_certificate: str, 18 | client_payment_service_provider_certificate_chain: str, 19 | client_public_key_signature: str, 20 | api_context: ApiContext, 21 | all_custom_header=None) -> UserCredentialPasswordIpApiObject: 22 | request_map = { 23 | cls.FIELD_CLIENT_PAYMENT_SERVICE_PROVIDER_CERTIFICATE: client_payment_service_provider_certificate, 24 | cls.FIELD_CLIENT_PAYMENT_SERVICE_PROVIDER_CERTIFICATE_CHAIN: client_payment_service_provider_certificate_chain, 25 | cls.FIELD_CLIENT_PUBLIC_KEY_SIGNATURE: client_public_key_signature 26 | } 27 | 28 | if all_custom_header is None: 29 | all_custom_header = {} 30 | 31 | api_client = ApiClient(api_context) 32 | request_bytes = converter.class_to_json(request_map).encode() 33 | endpoint_url = cls._ENDPOINT_URL_CREATE 34 | response_raw = api_client.post(endpoint_url, request_bytes, all_custom_header) 35 | 36 | response_body = converter.json_to_class(dict, response_raw.body_bytes.decode()) 37 | response_body_dict = converter.deserialize(cls, response_body[cls._FIELD_RESPONSE])[0] 38 | 39 | return UserCredentialPasswordIpApiObject.from_json(json.dumps(response_body_dict[cls._OBJECT_TYPE_GET])) 40 | -------------------------------------------------------------------------------- /bunq/sdk/json/installation_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Type, List 2 | 3 | from bunq.sdk.json import converter 4 | from bunq.sdk.model.core.id import Id 5 | from bunq.sdk.model.core.installation import Installation 6 | from bunq.sdk.model.core.public_key_server import PublicKeyServer 7 | from bunq.sdk.model.core.session_token import SessionToken 8 | 9 | 10 | class InstallationAdapter(converter.JsonAdapter): 11 | # Id constants 12 | _ATTRIBUTE_ID = '_id_' 13 | _INDEX_ID = 0 14 | _FIELD_ID = 'Id' 15 | 16 | # Token constants 17 | _ATTRIBUTE_TOKEN = '_token' 18 | _INDEX_TOKEN = 1 19 | _FIELD_TOKEN = 'Token' 20 | 21 | # Server Public Key constants 22 | _ATTRIBUTE_SERVER_PUBLIC_KEY = '_server_public_key' 23 | _INDEX_SERVER_PUBLIC_KEY = 2 24 | _FIELD_SERVER_PUBLIC_KEY = 'ServerPublicKey' 25 | 26 | @classmethod 27 | def deserialize(cls, 28 | target_class: Type[Installation], 29 | array: List) -> Installation: 30 | installation = target_class.__new__(target_class) 31 | server_public_key_wrapped = array[cls._INDEX_SERVER_PUBLIC_KEY] 32 | installation.__dict__ = { 33 | cls._ATTRIBUTE_ID: converter.deserialize( 34 | Id, 35 | array[cls._INDEX_ID][cls._FIELD_ID] 36 | ), 37 | cls._ATTRIBUTE_TOKEN: converter.deserialize( 38 | SessionToken, 39 | array[cls._INDEX_TOKEN][cls._FIELD_TOKEN] 40 | ), 41 | cls._ATTRIBUTE_SERVER_PUBLIC_KEY: converter.deserialize( 42 | PublicKeyServer, 43 | server_public_key_wrapped[cls._FIELD_SERVER_PUBLIC_KEY] 44 | ), 45 | } 46 | 47 | return installation 48 | 49 | @classmethod 50 | def serialize(cls, installation: Installation) -> List: 51 | return [ 52 | {cls._FIELD_ID: converter.serialize(installation.id_)}, 53 | {cls._FIELD_TOKEN: converter.serialize(installation.token)}, 54 | {cls._FIELD_SERVER_PUBLIC_KEY: converter.serialize(installation.server_public_key)}, 55 | ] 56 | 57 | @classmethod 58 | def can_serialize(cls) -> bool: 59 | return True 60 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/MonetaryAccountBank.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "DRAFT_PAYMENT", 5 | "event_type": "DRAFT_PAYMENT_ACCEPTED", 6 | "object": { 7 | "MonetaryAccountBank": { 8 | "id": 21, 9 | "created": "2017-07-08 00:42:33.951277", 10 | "updated": "2017-07-08 00:42:33.951277", 11 | "alias": [ 12 | { 13 | "type": "IBAN", 14 | "value": "NL18BUNQ2025104049", 15 | "name": "bunq" 16 | } 17 | ], 18 | "avatar": { 19 | "uuid": "246519c2-1280-4fd8-a864-ed9d313ceeba", 20 | "image": [ 21 | { 22 | "attachment_public_uuid": "52613480-7bd1-4da4-be8a-4a15f78c085d", 23 | "height": 126, 24 | "width": 200, 25 | "content_type|": "image\/jpeg" 26 | } 27 | ], 28 | "anchor_uuid": "45631547-6bdb-479e-859b-192273964ed4" 29 | }, 30 | "balance": null, 31 | "country": "NL", 32 | "currency": "EUR", 33 | "daily_limit": { 34 | "currency": "EUR", 35 | "value": "10000.00" 36 | }, 37 | "daily_spent": { 38 | "currency": "EUR", 39 | "value": "0.00" 40 | }, 41 | "description": "sofort", 42 | "public_uuid": "45631547-6bdb-479e-859b-192273964ed4", 43 | "status": "ACTIVE", 44 | "sub_status": "NONE", 45 | "timezone": "europe\/amsterdam", 46 | "user_id": 214, 47 | "monetary_account_profile": null, 48 | "notification_filters": [], 49 | "setting": null, 50 | "overdraft_limit": { 51 | "currency": "EUR", 52 | "value": "0.00" 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /bunq/sdk/http/anonymous_api_client.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import requests 4 | 5 | from bunq.sdk.context.api_context import ApiContext 6 | from bunq.sdk.http.api_client import ApiClient 7 | from bunq.sdk.http.bunq_response_raw import BunqResponseRaw 8 | from bunq.sdk.security import security 9 | 10 | 11 | class AnonymousApiClient(ApiClient): 12 | 13 | def __init__(self, api_context: ApiContext) -> None: 14 | super().__init__(api_context) 15 | 16 | def post(self, 17 | uri_relative: str, 18 | request_bytes: bytes, 19 | custom_headers: Dict[str, str]) -> BunqResponseRaw: 20 | return self._request( 21 | self.METHOD_POST, 22 | uri_relative, 23 | request_bytes, 24 | {}, 25 | custom_headers 26 | ) 27 | 28 | def _request(self, 29 | method: str, 30 | uri_relative: str, 31 | request_bytes: bytes, 32 | params: Dict[str, str], 33 | custom_headers: Dict[str, str]) -> BunqResponseRaw: 34 | from bunq.sdk.context.bunq_context import BunqContext 35 | 36 | uri_relative_with_params = self._append_params_to_uri(uri_relative, params) 37 | if uri_relative not in self._URIS_NOT_REQUIRING_ACTIVE_SESSION: 38 | if self._api_context.ensure_session_active(): 39 | BunqContext.update_api_context(self._api_context) 40 | 41 | all_headers = self._get_all_headers( 42 | request_bytes, 43 | custom_headers 44 | ) 45 | 46 | response = requests.request( 47 | method, 48 | uri_relative_with_params, 49 | data=request_bytes, 50 | headers=all_headers, 51 | proxies={self.FIELD_PROXY_HTTPS: self._api_context.proxy_url}, 52 | ) 53 | 54 | self._assert_response_success(response) 55 | 56 | if self._api_context.installation_context is not None: 57 | security.validate_response( 58 | self._api_context.installation_context.public_key_server, 59 | response.status_code, 60 | response.content, 61 | response.headers 62 | ) 63 | 64 | return BunqResponseRaw(response.content, response.headers) 65 | -------------------------------------------------------------------------------- /bunq/sdk/json/installation_context_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Dict 2 | 3 | from bunq import InstallationContext 4 | from bunq.sdk.json import converter 5 | from bunq.sdk.security import security 6 | 7 | 8 | class InstallationContextAdapter(converter.JsonAdapter): 9 | # Attribute/Field constants 10 | _ATTRIBUTE_TOKEN = '_token' 11 | _FIELD_TOKEN = 'token' 12 | 13 | _ATTRIBUTE_PRIVATE_KEY_CLIENT = '_private_key_client' 14 | _FIELD_PRIVATE_KEY_CLIENT = 'private_key_client' 15 | 16 | _ATTRIBUTE_PUBLIC_KEY_CLIENT = '_public_key_client' 17 | _FIELD_PUBLIC_KEY_CLIENT = 'public_key_client' 18 | 19 | _ATTRIBUTE_PUBLIC_KEY_SERVER = '_public_key_server' 20 | _FIELD_PUBLIC_KEY_SERVER = 'public_key_server' 21 | 22 | @classmethod 23 | def deserialize(cls, 24 | target_class: Type[InstallationContext], 25 | obj: Dict) -> InstallationContext: 26 | installation_context = target_class.__new__(target_class) 27 | private_key_client = security.rsa_key_from_string( 28 | obj[cls._FIELD_PRIVATE_KEY_CLIENT] 29 | ) 30 | public_key_client = security.rsa_key_from_string( 31 | obj[cls._FIELD_PUBLIC_KEY_CLIENT] 32 | ) 33 | public_key_server = security.rsa_key_from_string( 34 | obj[cls._FIELD_PUBLIC_KEY_SERVER] 35 | ) 36 | installation_context.__dict__ = { 37 | cls._ATTRIBUTE_TOKEN: obj[cls._FIELD_TOKEN], 38 | cls._ATTRIBUTE_PRIVATE_KEY_CLIENT: private_key_client, 39 | cls._ATTRIBUTE_PUBLIC_KEY_CLIENT: public_key_client, 40 | cls._ATTRIBUTE_PUBLIC_KEY_SERVER: public_key_server, 41 | } 42 | 43 | return installation_context 44 | 45 | @classmethod 46 | def serialize(cls, installation_context: InstallationContext) -> Dict: 47 | return { 48 | cls._FIELD_TOKEN: installation_context.token, 49 | cls._FIELD_PUBLIC_KEY_CLIENT: security.public_key_to_string( 50 | installation_context.private_key_client.publickey() 51 | ), 52 | cls._FIELD_PRIVATE_KEY_CLIENT: security.private_key_to_string( 53 | installation_context.private_key_client 54 | ), 55 | cls._FIELD_PUBLIC_KEY_SERVER: security.public_key_to_string( 56 | installation_context.public_key_server 57 | ), 58 | } 59 | -------------------------------------------------------------------------------- /bunq/sdk/json/share_detail_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Type, Optional 2 | 3 | from bunq.sdk.json import converter 4 | from bunq.sdk.model.generated.object_ import ShareDetailObject, ShareDetailPaymentObject, ShareDetailReadOnlyObject, \ 5 | ShareDetailDraftPaymentObject 6 | 7 | 8 | class ShareDetailAdapter(converter.JsonAdapter): 9 | # Attribute/Field constants 10 | _ATTRIBUTE_PAYMENT = 'payment' 11 | _FIELD_PAYMENT = 'ShareDetailPayment' 12 | 13 | _ATTRIBUTE_READ_ONLY = 'read_only' 14 | _FIELD_READ_ONLY = 'ShareDetailReadOnly' 15 | 16 | _ATTRIBUTE_DRAFT_PAYMENT = 'draft_payment' 17 | _FIELD_DRAFT_PAYMENT = 'ShareDetailDraftPayment' 18 | 19 | @classmethod 20 | def deserialize(cls, 21 | target_class: Type[ShareDetailObject], 22 | obj: Dict) -> ShareDetailObject: 23 | """ 24 | :type target_class: ShareDetail|type 25 | :type obj: dict 26 | 27 | :rtype: ShareDetail 28 | """ 29 | 30 | share_detail = target_class.__new__(target_class) 31 | share_detail.__dict__ = { 32 | cls._ATTRIBUTE_PAYMENT: converter.deserialize( 33 | ShareDetailPaymentObject, 34 | cls._get_field_or_none(cls._FIELD_DRAFT_PAYMENT, obj) 35 | ), 36 | cls._ATTRIBUTE_READ_ONLY: converter.deserialize( 37 | ShareDetailReadOnlyObject, 38 | cls._get_field_or_none(cls._FIELD_READ_ONLY, obj) 39 | ), 40 | cls._ATTRIBUTE_DRAFT_PAYMENT: converter.deserialize( 41 | ShareDetailDraftPaymentObject, 42 | cls._get_field_or_none(cls._FIELD_DRAFT_PAYMENT, obj) 43 | ), 44 | } 45 | 46 | return share_detail 47 | 48 | @staticmethod 49 | def _get_field_or_none(field: str, obj: Dict) -> Optional[Dict]: 50 | return obj[field] if field in obj else None 51 | 52 | @classmethod 53 | def serialize(cls, share_detail: ShareDetailObject) -> Dict: 54 | return { 55 | cls._FIELD_PAYMENT: converter.serialize( 56 | share_detail._payment_field_for_request), 57 | cls._FIELD_READ_ONLY: converter.serialize( 58 | share_detail._read_only_field_for_request), 59 | cls._FIELD_DRAFT_PAYMENT: converter.serialize( 60 | share_detail._draft_payment 61 | ), 62 | } 63 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/installation.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.context.api_context import ApiContext 2 | from bunq.sdk.http.api_client import ApiClient 3 | from bunq.sdk.http.bunq_response import BunqResponse 4 | from bunq.sdk.json import converter 5 | from bunq.sdk.model.core.bunq_model import BunqModel 6 | from bunq.sdk.model.core.id import Id 7 | from bunq.sdk.model.core.public_key_server import PublicKeyServer 8 | from bunq.sdk.model.core.session_token import SessionToken 9 | 10 | 11 | class Installation(BunqModel): 12 | """ 13 | :type _id_: Id 14 | :type _token: SessionToken 15 | :type _server_public_key: PublicKeyServer 16 | """ 17 | 18 | # Endpoint name. 19 | _ENDPOINT_URL_POST = "installation" 20 | 21 | # Field constants. 22 | FIELD_CLIENT_PUBLIC_KEY = "client_public_key" 23 | 24 | def __init__(self) -> None: 25 | self._id_ = None 26 | self._token = None 27 | self._server_public_key = None 28 | 29 | @property 30 | def id_(self) -> Id: 31 | return self._id_ 32 | 33 | @property 34 | def token(self) -> SessionToken: 35 | return self._token 36 | 37 | @property 38 | def server_public_key(self) -> PublicKeyServer: 39 | return self._server_public_key 40 | 41 | @classmethod 42 | def create(cls, 43 | api_context: ApiContext, 44 | public_key_string: str) -> BunqResponse: # TODO: Check 45 | """ 46 | :type api_context: ApiContext 47 | :type public_key_string: str 48 | 49 | :rtype: BunqResponse[Installation] 50 | """ 51 | 52 | api_client = ApiClient(api_context) 53 | body_bytes = cls.generate_request_body_bytes(public_key_string) 54 | response_raw = api_client.post(cls._ENDPOINT_URL_POST, body_bytes, {}) 55 | 56 | return cls._from_json_array_nested(response_raw) 57 | 58 | @classmethod 59 | def generate_request_body_bytes(cls, public_key_string: str) -> bytes: 60 | return converter.class_to_json( 61 | { 62 | cls.FIELD_CLIENT_PUBLIC_KEY: public_key_string, 63 | } 64 | ).encode() 65 | 66 | def is_all_field_none(self) -> bool: 67 | if self.id_ is not None: 68 | return False 69 | 70 | if self.token is not None: 71 | return False 72 | 73 | if self.server_public_key is not None: 74 | return False 75 | 76 | return True 77 | -------------------------------------------------------------------------------- /bunq/sdk/exception/EXCEPTIONS.md: -------------------------------------------------------------------------------- 1 | ## Exceptions 2 | When you make a request via the SDK, there is a chance of request failing 3 | due to various reasons. When such a failure happens, an exception 4 | corresponding to the error occurred is raised. 5 | 6 | ---- 7 | #### Possible Exceptions 8 | * `BadRequestException` If the request returns with status code `400` 9 | * `UnauthorizedException` If the request returns with status code `401` 10 | * `ForbiddenException` If the request returns with status code `403` 11 | * `NotFoundException` If the request returns with status code `404` 12 | * `MethodNotAllowedException` If the request returns with status code `405` 13 | * `TooManyRequestsException` If the request returns with status code `429` 14 | * `PleaseContactBunqException` If the request returns with status code `500`. 15 | If you get this exception, please contact us preferably via the support chat in the bunq app. 16 | * `UnknownApiErrorException` If none of the above mentioned exceptions are raised, 17 | this exception will be raised instead. 18 | 19 | For more information regarding these errors, please take a look on the documentation 20 | page here: https://doc.bunq.com/api/1/page/errors 21 | 22 | --- 23 | #### Base exception 24 | All the exceptions have the same base exception which looks like this: 25 | ```python 26 | class ApiException(Exception): 27 | def __init__(self, 28 | message: str, 29 | response_code: int) -> None: 30 | pass 31 | 32 | @property 33 | def message(self) -> str: 34 | return self._message 35 | 36 | @property 37 | def response_code(self) -> int: 38 | return self._response_code 39 | ``` 40 | This means that each exception will have the response code and the error message 41 | related to the specific exception that has been raised. 42 | 43 | --- 44 | #### Exception handling 45 | Because we raise different exceptions for each error, you can catch an error 46 | if you expect it to be raised. 47 | 48 | ```python 49 | from bunq.sdk.exception.bad_request_exception import BadRequestException 50 | from bunq.sdk.context.api_context import ApiEnvironmentType, ApiContext 51 | 52 | API_KEY = "Some invalid API key" 53 | DESCRIPTION = "This will raise a BadRequestException" 54 | 55 | try: 56 | # Make a call that might raise an exception 57 | ApiContext.create(ApiEnvironmentType.SANDBOX, API_KEY, DESCRIPTION) 58 | except BadRequestException as error: 59 | # Do something if exception is raised 60 | print(error.response_code) 61 | print(error.message) # or just print(error) 62 | ``` 63 | -------------------------------------------------------------------------------- /bunq/sdk/http/pagination.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from bunq.sdk.exception.bunq_exception import BunqException 4 | 5 | 6 | class Pagination: 7 | """ 8 | :type older_id: int|None 9 | :type newer_id: int|None 10 | :type future_id: int|None 11 | :type count: int|None 12 | """ 13 | 14 | # Error constants 15 | _ERROR_NO_PREVIOUS_PAGE = 'Could not generate previous page URL params: there is no previous page.' 16 | _ERROR_NO_NEXT_PAGE = 'Could not generate next page URL params: there is no next page.' 17 | 18 | # URL Param constants 19 | PARAM_OLDER_ID = 'older_id' 20 | PARAM_NEWER_ID = 'newer_id' 21 | PARAM_COUNT = 'count' 22 | 23 | def __init__(self) -> None: 24 | self.older_id = None 25 | self.newer_id = None 26 | self.future_id = None 27 | self.count = None 28 | 29 | @property 30 | def url_params_previous_page(self) -> Dict[str, str]: 31 | self.assert_has_previous_page() 32 | 33 | params = {self.PARAM_OLDER_ID: str(self.older_id)} 34 | self._add_count_to_params_if_needed(params) 35 | 36 | return params 37 | 38 | def assert_has_previous_page(self) -> None: 39 | """ 40 | 41 | :raise: BunqException 42 | """ 43 | 44 | if not self.has_previous_page(): 45 | raise BunqException(self._ERROR_NO_PREVIOUS_PAGE) 46 | 47 | def has_previous_page(self) -> bool: 48 | return self.older_id is not None 49 | 50 | @property 51 | def url_params_count_only(self) -> Dict[str, str]: 52 | params = {} 53 | self._add_count_to_params_if_needed(params) 54 | 55 | return params 56 | 57 | def _add_count_to_params_if_needed(self, params: Dict[str, str]) -> None: 58 | if self.count is not None: 59 | params[self.PARAM_COUNT] = str(self.count) 60 | 61 | def has_next_page_assured(self) -> bool: 62 | return self.newer_id is not None 63 | 64 | @property 65 | def url_params_next_page(self) -> Dict[str, str]: 66 | self.assert_has_next_page() 67 | 68 | params = {self.PARAM_NEWER_ID: str(self._next_id)} 69 | self._add_count_to_params_if_needed(params) 70 | 71 | return params 72 | 73 | def assert_has_next_page(self) -> None: 74 | """ 75 | 76 | :raise: BunqException 77 | """ 78 | 79 | if self._next_id is None: 80 | raise BunqException(self._ERROR_NO_NEXT_PAGE) 81 | 82 | @property 83 | def _next_id(self) -> int: 84 | if self.has_next_page_assured(): 85 | return self.newer_id 86 | 87 | return self.future_id 88 | -------------------------------------------------------------------------------- /bunq/sdk/json/anchor_object_adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bunq import AnchorObjectInterface 4 | from bunq.sdk.exception.bunq_exception import BunqException 5 | from bunq.sdk.json import converter 6 | from bunq.sdk.model.core.bunq_model import BunqModel 7 | from bunq.sdk.model.generated import endpoint 8 | from bunq.sdk.model.generated import object_ 9 | from bunq.sdk.util.type_alias import JsonValue 10 | from bunq.sdk.util.type_alias import T 11 | 12 | 13 | class AnchorObjectAdapter(converter.JsonAdapter): 14 | __ERROR_MODEL_NOT_FOUND = '{} is not in endpoint nor object.' 15 | 16 | __STRING_FORMAT_UNDERSCORE = '_' 17 | 18 | _override_field_map = { 19 | 'ScheduledPayment': 'SchedulePayment', 20 | 'ScheduledInstance': 'ScheduleInstance', 21 | 'ShareInviteBankInquiry': 'ShareInviteMonetaryAccountInquiry', 22 | 'ShareInviteBankResponse': 'ShareInviteMonetaryAccountResponse' 23 | } 24 | 25 | @classmethod 26 | def deserialize(cls, 27 | cls_target: Type[T], 28 | obj_raw: JsonValue) -> T: 29 | model_ = super()._deserialize_default(cls_target, obj_raw) 30 | 31 | if isinstance(model_, AnchorObjectInterface) and model_.is_all_field_none(): 32 | for field in model_.__dict__: 33 | object_class = cls._get_object_class(field) 34 | contents = super()._deserialize_default(object_class, obj_raw) 35 | 36 | if contents.is_all_field_none(): 37 | setattr(model_, field, None) 38 | else: 39 | setattr(model_, field, contents) 40 | 41 | return model_ 42 | 43 | @classmethod 44 | def can_serialize(cls) -> bool: 45 | return False 46 | 47 | @classmethod 48 | def _get_object_class(cls, class_name: str) -> BunqModel: 49 | class_name = class_name.lstrip(cls.__STRING_FORMAT_UNDERSCORE) 50 | 51 | if class_name in cls._override_field_map: 52 | class_name = cls._override_field_map[class_name] 53 | 54 | try: 55 | return getattr(endpoint, class_name + "ApiObject") 56 | except AttributeError: 57 | pass 58 | 59 | try: 60 | return getattr(endpoint, class_name) 61 | except AttributeError: 62 | pass 63 | 64 | try: 65 | return getattr(object_, class_name + "Object") 66 | except AttributeError: 67 | pass 68 | 69 | try: 70 | return getattr(object_, class_name) 71 | except AttributeError: 72 | pass 73 | 74 | raise BunqException(cls.__ERROR_MODEL_NOT_FOUND.format(class_name)) 75 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_card_debit.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from bunq.sdk.context.bunq_context import BunqContext 5 | from bunq.sdk.model.generated.endpoint import CardApiObject 6 | from bunq.sdk.model.generated.endpoint import CardDebitApiObject 7 | from bunq.sdk.model.generated.endpoint import CardNameApiObject 8 | from bunq.sdk.model.generated.object_ import CardPinAssignmentObject 9 | from tests.bunq_test import BunqSdkTestCase 10 | 11 | 12 | class TestCardDebit(BunqSdkTestCase): 13 | """ 14 | Tests: 15 | CardApiObject 16 | CardDebitApiObject 17 | CardNameApiObject 18 | """ 19 | 20 | _CARD_PIN_CODE = '4045' 21 | _SECOND_LINE_LENGTH_MAXIMUM = 20 22 | _STRING_EMPTY = '' 23 | _PIN_CODE_ASSIGNMENT_TYPE_PRIMARY = 'PRIMARY' 24 | _CARD_TYPE_MASTERCARD = 'MASTERCARD' 25 | _PRODUCT_TYPE_MASTERCARD_DEBIT = 'MASTERCARD_DEBIT' 26 | _CARD_ROUTING_TYPE = 'MANUAL' 27 | 28 | def test_order_debit_card(self): 29 | """ 30 | Tests ordering a new card and checks if the fields we have entered 31 | are indeed correct by retrieving the card from the card endpoint and 32 | checks this date against the data we have submitted 33 | """ 34 | 35 | second_line = self.second_line_random 36 | pin_code_assignment = CardPinAssignmentObject( 37 | self._PIN_CODE_ASSIGNMENT_TYPE_PRIMARY, 38 | self._CARD_ROUTING_TYPE, 39 | self._CARD_PIN_CODE, 40 | BunqContext.user_context().primary_monetary_account.id_ 41 | ) 42 | 43 | card_debit = CardDebitApiObject.create( 44 | second_line=second_line, 45 | name_on_card=self.card_name_allowed, 46 | type_=self._CARD_TYPE_MASTERCARD, 47 | product_type=self._PRODUCT_TYPE_MASTERCARD_DEBIT, 48 | alias=self.alias_first, 49 | pin_code_assignment=[pin_code_assignment] 50 | ).value 51 | 52 | card = CardApiObject.get(card_debit.id_).value 53 | 54 | self.assertEqual(self.card_name_allowed, card.name_on_card) 55 | self.assertEqual(second_line, card.second_line) 56 | self.assertEqual(card_debit.created, card.created) 57 | 58 | @property 59 | def card_name_allowed(self) -> str: 60 | return CardNameApiObject.list().value[self._FIRST_INDEX].possible_card_name_array[self._FIRST_INDEX] 61 | 62 | @property 63 | def second_line_random(self) -> str: 64 | second_line_characters = [] 65 | 66 | for _ in range(self._SECOND_LINE_LENGTH_MAXIMUM): 67 | next_char = random.choice(string.ascii_uppercase) 68 | second_line_characters.append(next_char) 69 | 70 | return self._STRING_EMPTY.join(second_line_characters) 71 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/oauth_authorization_uri.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bunq import ApiEnvironmentType 4 | from bunq.sdk.context.bunq_context import BunqContext 5 | from bunq.sdk.exception.bunq_exception import BunqException 6 | from bunq.sdk.http.http_util import HttpUtil 7 | from bunq.sdk.model.core.bunq_model import BunqModel 8 | from bunq.sdk.model.core.oauth_response_type import OauthResponseType 9 | from bunq.sdk.model.generated.endpoint import OauthClient 10 | 11 | 12 | class OauthAuthorizationUri(BunqModel): 13 | # Auth constants. 14 | AUTH_URI_FORMAT_SANDBOX = "https://oauth.sandbox.bunq.com/auth?{}" 15 | AUTH_URI_FORMAT_PRODUCTION = "https://oauth.bunq.com/auth?{}" 16 | 17 | # Field constants 18 | FIELD_RESPONSE_TYPE = "response_type" 19 | FIELD_REDIRECT_URI = "redirect_uri" 20 | FIELD_STATE = "state" 21 | FIELD_CLIENT_ID = "client_id" 22 | 23 | # Error constants. 24 | ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED = "You are trying to use an unsupported environment type." 25 | 26 | def __init__(self, authorization_uri: str) -> None: 27 | self._authorization_uri = authorization_uri 28 | 29 | @property 30 | def authorization_uri(self) -> str: 31 | return self._authorization_uri 32 | 33 | @classmethod 34 | def create(cls, 35 | response_type: OauthResponseType, 36 | redirect_uri: str, 37 | client: OauthClient, 38 | state: str = None) -> OauthAuthorizationUri: 39 | all_request_parameter = { 40 | cls.FIELD_REDIRECT_URI: redirect_uri, 41 | cls.FIELD_RESPONSE_TYPE: response_type.name.lower() 42 | } 43 | 44 | if client.client_id is not None: 45 | all_request_parameter[cls.FIELD_CLIENT_ID] = client.client_id 46 | 47 | if state is not None: 48 | all_request_parameter[cls.FIELD_STATE] = state 49 | 50 | return OauthAuthorizationUri( 51 | cls.determine_auth_uri_format().format(HttpUtil.create_query_string(all_request_parameter)) 52 | ) 53 | 54 | def get_authorization_uri(self) -> str: 55 | return self._authorization_uri 56 | 57 | def is_all_field_none(self) -> bool: 58 | if self._authorization_uri is None: 59 | return True 60 | else: 61 | return False 62 | 63 | @classmethod 64 | def determine_auth_uri_format(cls) -> str: 65 | environment_type = BunqContext.api_context().environment_type 66 | 67 | if ApiEnvironmentType.PRODUCTION == environment_type: 68 | return cls.AUTH_URI_FORMAT_PRODUCTION 69 | 70 | if ApiEnvironmentType.SANDBOX == environment_type: 71 | return cls.AUTH_URI_FORMAT_SANDBOX 72 | 73 | raise BunqException(cls.ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED) 74 | -------------------------------------------------------------------------------- /tests/model/generated/endpoint/test_payment.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from bunq.sdk.model.generated.endpoint import PaymentApiObject, PaymentBatchApiObject, BunqResponseInt, BunqResponsePaymentBatch 4 | from bunq.sdk.model.generated.object_ import AmountObject, PointerObject 5 | from tests.bunq_test import BunqSdkTestCase 6 | 7 | 8 | class TestPayment(BunqSdkTestCase): 9 | """ 10 | Tests: 11 | PaymentApiObject 12 | PaymentChat 13 | ChatMessageText 14 | """ 15 | 16 | _PAYMENT_AMOUNT_EUR = '0.01' 17 | _PAYMENT_CURRENCY = 'EUR' 18 | _PAYMENT_DESCRIPTION = 'Python unit test' 19 | _PAYMENT_CHAT_TEXT_MESSAGE = 'send from python test' 20 | 21 | _MAXIMUM_PAYMENT_IN_BATCH = 10 22 | 23 | def test_payment_to_other_user(self): 24 | """ 25 | Tests making a payment to another sandbox user 26 | 27 | This test has no assertion as of its testing to see if the code runs 28 | without errors 29 | """ 30 | 31 | PaymentApiObject.create( 32 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY), 33 | self._get_pointer_bravo(), 34 | self._PAYMENT_DESCRIPTION 35 | ) 36 | 37 | def test_payment_to_other_account(self): 38 | """ 39 | Tests making a payment to another monetary account of the same user 40 | 41 | This test has no assertion as of its testing to see if the code runs 42 | without errors 43 | """ 44 | 45 | PaymentApiObject.create( 46 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY), 47 | self._get_alias_second_account(), 48 | self._PAYMENT_DESCRIPTION 49 | ) 50 | 51 | def test_payment_batch(self): 52 | response_create = PaymentBatchApiObject.create(self.__create_payment_list()) 53 | 54 | self.assertIsInstance(response_create, BunqResponseInt) 55 | self.assertIsNotNone(response_create) 56 | 57 | response_get = PaymentBatchApiObject.get(response_create.value) 58 | 59 | self.assertIsInstance(response_get, BunqResponsePaymentBatch) 60 | self.assertIsNotNone(response_get) 61 | self.assertFalse(response_get.value.is_all_field_none()) 62 | 63 | def __create_payment_list(self) -> List[PaymentApiObject]: 64 | all_payment = [] 65 | 66 | while len(all_payment) < self._MAXIMUM_PAYMENT_IN_BATCH: 67 | all_payment.append( 68 | PaymentApiObject( 69 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY), 70 | PointerObject(self._POINTER_EMAIL, self._EMAIL_BRAVO), 71 | self._PAYMENT_DESCRIPTION 72 | ) 73 | ) 74 | self.assertIsInstance(all_payment, List) 75 | self.assertIsInstance(all_payment[0], PaymentApiObject) 76 | 77 | return all_payment 78 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/device_server_internal.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | 3 | from bunq.sdk.context.api_context import ApiContext 4 | from bunq.sdk.exception.bunq_exception import BunqException 5 | from bunq.sdk.http.api_client import ApiClient 6 | from bunq.sdk.json import converter 7 | from bunq.sdk.model.generated.endpoint import BunqResponseInt 8 | from bunq.sdk.model.generated.endpoint import DeviceServerApiObject 9 | 10 | 11 | class DeviceServerInternal(DeviceServerApiObject): 12 | _ERROR_API_CONTEXT_IS_NULL = 'ApiContext should not be None, use the generated class instead.' 13 | 14 | @classmethod 15 | def create(cls, 16 | description: str, 17 | secret: str, 18 | permitted_ips: List[str] = None, 19 | custom_headers: Dict[str, str] = None, 20 | api_context: ApiContext = None) -> BunqResponseInt: 21 | """ 22 | Create a new DeviceServer providing the installation token in the header 23 | and signing the request with the private part of the key you used to 24 | create the installation. The API Key that you are using will be bound to 25 | the IP address of the DeviceServer which you have 26 | created.

Using a Wildcard API Key gives you the freedom to make 27 | API calls even if the IP address has changed after the POST 28 | device-server.

Find out more at this link https://bunq.com/en/apikey-dynamic-ip. 31 | 32 | :param description: The description of the DeviceServer. This is only 33 | for your own reference when reading the DeviceServer again. 34 | :type description: str 35 | :param secret: The API key. You can request an API key in the bunq app. 36 | :type secret: str 37 | :param permitted_ips: An array of IPs (v4 or v6) this DeviceServer will 38 | be able to do calls from. These will be linked to the API key. 39 | :type permitted_ips: list[str] 40 | :type custom_headers: dict[str, str]|None 41 | :type api_context: ApiContext 42 | 43 | :rtype: BunqResponseInt 44 | """ 45 | 46 | if api_context is None: 47 | raise BunqException(cls._ERROR_API_CONTEXT_IS_NULL) 48 | 49 | if custom_headers is None: 50 | custom_headers = {} 51 | 52 | request_map = { 53 | cls.FIELD_DESCRIPTION: description, 54 | cls.FIELD_SECRET: secret, 55 | cls.FIELD_PERMITTED_IPS: permitted_ips 56 | } 57 | 58 | api_client = ApiClient(api_context) 59 | request_bytes = converter.class_to_json(request_map).encode() 60 | endpoint_url = cls._ENDPOINT_URL_CREATE 61 | response_raw = api_client.post(endpoint_url, request_bytes, 62 | custom_headers) 63 | 64 | return BunqResponseInt.cast_from_bunq_response( 65 | cls._process_for_id(response_raw) 66 | ) 67 | -------------------------------------------------------------------------------- /bunq/sdk/json/pagination_adapter.py: -------------------------------------------------------------------------------- 1 | import urllib.parse as urlparse 2 | from typing import Type, Dict 3 | 4 | from bunq import Pagination 5 | from bunq.sdk.json import converter 6 | 7 | 8 | class PaginationAdapter(converter.JsonAdapter): 9 | # Raw pagination response field constants. 10 | _FIELD_FUTURE_URL = 'future_url' 11 | _FIELD_NEWER_URL = 'newer_url' 12 | _FIELD_OLDER_URL = 'older_url' 13 | 14 | # Processed pagination field constants. 15 | _FIELD_OLDER_ID = 'older_id' 16 | _FIELD_NEWER_ID = 'newer_id' 17 | _FIELD_FUTURE_ID = 'future_id' 18 | _FIELD_COUNT = 'count' 19 | 20 | # Very first index in an array. 21 | _INDEX_FIRST = 0 22 | 23 | @classmethod 24 | def deserialize(cls, 25 | target_class: Type[Pagination], 26 | pagination_response: Dict) -> Pagination: 27 | pagination = Pagination() 28 | pagination.__dict__.update( 29 | cls.parse_pagination_dict(pagination_response) 30 | ) 31 | 32 | return pagination 33 | 34 | @classmethod 35 | def parse_pagination_dict(cls, response_obj: Dict) -> Dict: 36 | pagination_dict = {} 37 | 38 | cls.update_dict_id_field_from_response_field( 39 | pagination_dict, 40 | cls._FIELD_OLDER_ID, 41 | response_obj, 42 | cls._FIELD_OLDER_URL, 43 | Pagination.PARAM_OLDER_ID 44 | ) 45 | cls.update_dict_id_field_from_response_field( 46 | pagination_dict, 47 | cls._FIELD_NEWER_ID, 48 | response_obj, 49 | cls._FIELD_NEWER_URL, 50 | Pagination.PARAM_NEWER_ID 51 | ) 52 | cls.update_dict_id_field_from_response_field( 53 | pagination_dict, 54 | cls._FIELD_FUTURE_ID, 55 | response_obj, 56 | cls._FIELD_FUTURE_URL, 57 | Pagination.PARAM_NEWER_ID 58 | ) 59 | 60 | return pagination_dict 61 | 62 | @classmethod 63 | def update_dict_id_field_from_response_field(cls, dict_: Dict, 64 | dict_id_field: str, 65 | response_obj: Dict, 66 | response_field: str, 67 | response_param: str) -> None: 68 | url = response_obj[response_field] 69 | 70 | if url is not None: 71 | url_parsed = urlparse.urlparse(url) 72 | parameters = urlparse.parse_qs(url_parsed.query) 73 | dict_[dict_id_field] = int( 74 | parameters[response_param][cls._INDEX_FIRST] 75 | ) 76 | 77 | if cls._FIELD_COUNT in parameters and cls._FIELD_COUNT not in dict_: 78 | dict_[cls._FIELD_COUNT] = int( 79 | parameters[Pagination.PARAM_COUNT][cls._INDEX_FIRST] 80 | ) 81 | 82 | @classmethod 83 | def serialize(cls, pagination: Pagination) -> None: 84 | raise NotImplementedError() 85 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/ShareInviteBankResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "SHARE", 5 | "event_type": "SHARE_INVITE_BANK_INQUIRY_ACCEPTED", 6 | "object": { 7 | "ShareInviteBankResponse": { 8 | "id": 2, 9 | "created": "2017-07-20 02:32:32.114297", 10 | "updated": "2017-07-20 02:32:32.114297", 11 | "monetary_account_id": null, 12 | "draft_share_invite_bank_id": null, 13 | "counter_alias": { 14 | "iban": "NL59BUNQ2025104669", 15 | "is_light": false, 16 | "display_name": "Alpha Corp.", 17 | "avatar": { 18 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a", 19 | "image": [ 20 | { 21 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439", 22 | "height": 1024, 23 | "width": 1024, 24 | "content_type": "image\/png" 25 | } 26 | ], 27 | "anchor_uuid": null 28 | }, 29 | "label_user": { 30 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3", 31 | "display_name": "Alpha Corp.", 32 | "country": "NL", 33 | "avatar": { 34 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9", 35 | "image": [ 36 | { 37 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a", 38 | "height": 640, 39 | "width": 640, 40 | "content_type": "image\/png" 41 | } 42 | ], 43 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3" 44 | }, 45 | "public_nick_name": "Alpha Corp." 46 | }, 47 | "country": "NL" 48 | }, 49 | "user_alias_cancelled": null, 50 | "description": "bunq account", 51 | "share_type": "STANDARD", 52 | "status": "PENDING", 53 | "share_detail": { 54 | "ShareDetailPayment": { 55 | "view_balance": true, 56 | "view_old_events": true, 57 | "view_new_events": true, 58 | "budget": null, 59 | "make_payments": true 60 | } 61 | }, 62 | "start_date": "2017-07-20 02:34:05.864029", 63 | "end_date": "2017-11-11 02:34:05.864029" 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | A setuptools based setup module. 3 | 4 | See: 5 | https://packaging.python.org/en/latest/distributing.html 6 | https://github.com/pypa/sampleproject 7 | """ 8 | 9 | # To use a consistent encoding 10 | from codecs import open 11 | from os import path 12 | 13 | # Always prefer setuptools over distutils 14 | from setuptools import setup, find_packages 15 | 16 | here = path.abspath(path.dirname(__file__)) 17 | 18 | # Get the long description from the README file 19 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 20 | long_description = f.read() 21 | 22 | # Get the version from the VERSION file 23 | with open(path.join(here, 'VERSION'), encoding='utf-8') as f: 24 | version = f.read() 25 | 26 | setup( 27 | name='bunq_sdk', 28 | 29 | # Versions should comply with PEP440. For a discussion on single-sourcing 30 | # the version across setup.py and the project code, see 31 | # https://packaging.python.org/en/latest/single_source_version.html 32 | version=version, 33 | 34 | description='bunq Python SDK', 35 | long_description=long_description, 36 | long_description_content_type="text/markdown", 37 | 38 | # The project's main homepage. 39 | url='https://github.com/bunq/sdk_python', 40 | 41 | # Author details 42 | author='bunq', 43 | author_email='support@bunq.com', 44 | 45 | # The project's license 46 | license='MIT', 47 | 48 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 49 | classifiers=[ 50 | # How mature is this project? Common values are 51 | # 3 - Alpha 52 | # 4 - Beta 53 | # 5 - Production/Stable 54 | 'Development Status :: 4 - Beta', 55 | 56 | # Indicate who your project is intended for 57 | 'Intended Audience :: Developers', 58 | 'Topic :: Software Development :: Build Tools', 59 | 60 | # Pick your license as you wish (should match "license" above) 61 | 'License :: OSI Approved :: MIT License', 62 | 63 | # Specify the Python versions you support here. In particular, ensure 64 | # that you indicate whether you support Python 2, Python 3 or both. 65 | 'Programming Language :: Python :: 3.7', 66 | ], 67 | 68 | # Require Python version equal or higher than the requested version. 69 | python_requires='>=3.7.0', 70 | 71 | # Keywords related to the project 72 | keywords='open-banking sepa bunq finance api payment', 73 | 74 | # Packages of the project. "find_packages()" lists all the project packages. 75 | packages=find_packages(exclude=[ 76 | 'contrib', 77 | 'docs', 78 | 'tests', 79 | 'examples', 80 | 'assets', 81 | '.idea', 82 | 'run.py' 83 | ]), 84 | 85 | # Run-time dependencies of the project. These will be installed by pip. 86 | install_requires=[ 87 | 'aenum>=2.2.4,<3.0.0', 88 | 'chardet>=3.0.4,<4.0.0', 89 | 'pycryptodomex>=3.9.8,<4.0.0', 90 | 'requests>=2.24.0,<3.0.0', 91 | 'simplejson>=3.17.2,<4.0.0', 92 | 'urllib3>=1.25.10,<2.0.0' 93 | ], 94 | ) -------------------------------------------------------------------------------- /tests/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from typing import List, Any 5 | 6 | from bunq.sdk.model.generated.object_ import Pointer 7 | 8 | 9 | class Config: 10 | # Delimiter between the IP addresses in the PERMITTED_IPS field. 11 | _DELIMITER_IPS = "," 12 | 13 | # Field constants 14 | _FIELD_PERMITTED_IPS = "PERMITTED_IPS" 15 | _FIELD_COUNTER_PARTY_OTHER = "CounterPartyOther" 16 | _FIELD_COUNTER_PARTY_SELF = "CounterPartySelf" 17 | _FIELD_TYPE = "Type" 18 | _FIELD_ALIAS = "Alias" 19 | _FIELD_TAB_USAGE = "TabUsageSingleTest" 20 | _FIELD_MONETARY_ACCOUNT_ID_1 = "MONETARY_ACCOUNT_ID" 21 | _FIELD_MONETARY_ACCOUNT_ID_2 = "MONETARY_ACCOUNT_ID2" 22 | _FIELD_USER_ID = "USER_ID" 23 | _FIELD_API_KEY = "API_KEY" 24 | _FIELD_ATTACHMENT_PUBLIC = "AttachmentPublicTest" 25 | _FIELD_ATTACHMENT_PATH_IN = "PATH_IN" 26 | _FIELD_ATTACHMENT_DESCRIPTION = "DESCRIPTION" 27 | _FIELD_ATTACHMENT_CONTENT_TYPE = "CONTENT_TYPE" 28 | 29 | @classmethod 30 | def get_attachment_content_type(cls) -> str: 31 | return cls._get_config_file()[cls._FIELD_ATTACHMENT_PUBLIC][cls._FIELD_ATTACHMENT_CONTENT_TYPE] 32 | 33 | @classmethod 34 | def get_attachment_description(cls) -> str: 35 | return cls._get_config_file()[cls._FIELD_ATTACHMENT_PUBLIC][cls._FIELD_ATTACHMENT_DESCRIPTION] 36 | 37 | @classmethod 38 | def get_attachment_path_in(cls) -> str: 39 | return cls._get_config_file()[cls._FIELD_ATTACHMENT_PUBLIC][cls._FIELD_ATTACHMENT_PATH_IN] 40 | 41 | @classmethod 42 | def get_api_key(cls) -> str: 43 | return cls._get_config_file()[cls._FIELD_API_KEY] 44 | 45 | @classmethod 46 | def get_user_id(cls) -> int: 47 | return int(cls._get_config_file()[cls._FIELD_USER_ID]) 48 | 49 | @classmethod 50 | def get_monetary_account_id_2(cls) -> int: 51 | return int(cls._get_config_file()[cls._FIELD_MONETARY_ACCOUNT_ID_2]) 52 | 53 | @classmethod 54 | def get_monetary_account_id_1(cls) -> int: 55 | return int(cls._get_config_file()[cls._FIELD_MONETARY_ACCOUNT_ID_1]) 56 | 57 | @classmethod 58 | def get_pointer_counter_party_self(cls) -> Pointer: 59 | type_ = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_SELF][cls._FIELD_TYPE] 60 | alias = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_SELF][cls._FIELD_ALIAS] 61 | 62 | return Pointer(type_, alias) 63 | 64 | @classmethod 65 | def get_pointer_counter_party_other(cls) -> Pointer: 66 | type_ = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_OTHER][cls._FIELD_TYPE] 67 | alias = cls._get_config_file()[cls._FIELD_COUNTER_PARTY_OTHER][cls._FIELD_ALIAS] 68 | 69 | return Pointer(type_, alias) 70 | 71 | @classmethod 72 | def get_permitted_ips(cls) -> List[str]: 73 | permitted_ips_str = cls._get_config_file()[cls._FIELD_PERMITTED_IPS] 74 | 75 | if not permitted_ips_str: 76 | return [] 77 | 78 | return permitted_ips_str.split(cls._DELIMITER_IPS) 79 | 80 | @classmethod 81 | def _get_config_file(cls) -> Any: 82 | file_path = os.path.dirname(os.path.realpath(__file__)) 83 | with open(file_path + "/assets/config.json", "r") as f: 84 | return json.load(f) 85 | -------------------------------------------------------------------------------- /tests/http/test_pagination_scenario.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import List, Dict 3 | 4 | from bunq import Pagination 5 | from bunq.sdk.context.bunq_context import BunqContext 6 | from bunq.sdk.http.bunq_response import BunqResponse 7 | from bunq.sdk.json import converter 8 | from bunq.sdk.model.generated.endpoint import PaymentApiObject 9 | from bunq.sdk.model.generated.object_ import AmountObject 10 | from tests.bunq_test import BunqSdkTestCase 11 | 12 | 13 | class TestPaginationScenario(BunqSdkTestCase): 14 | """ 15 | Tests: 16 | Pagination 17 | """ 18 | 19 | __TIME_OUT_PREVENT_RATE_LIMIT = 2 20 | 21 | @classmethod 22 | def setUpClass(cls): 23 | cls._PAYMENT_LISTING_PAGE_SIZE = 2 24 | cls._PAYMENT_REQUIRED_COUNT_MINIMUM = cls._PAYMENT_LISTING_PAGE_SIZE * 2 25 | cls._NUMBER_ZERO = 0 26 | cls._PAYMENT_AMOUNT_EUR = '0.01' 27 | cls._PAYMENT_CURRENCY = 'EUR' 28 | cls._PAYMENT_DESCRIPTION = 'Python test Payment' 29 | 30 | BunqContext.load_api_context(cls._get_api_context()) 31 | 32 | def test_api_scenario_payment_listing_with_pagination(self): 33 | self._ensure_enough_payments() 34 | payments_expected = self._payments_required() 35 | pagination = Pagination() 36 | pagination.count = self._PAYMENT_LISTING_PAGE_SIZE 37 | 38 | time.sleep(self.__TIME_OUT_PREVENT_RATE_LIMIT) 39 | response_latest = self._list_payments(pagination.url_params_count_only) 40 | pagination_latest = response_latest.pagination 41 | 42 | time.sleep(self.__TIME_OUT_PREVENT_RATE_LIMIT) 43 | response_previous = self._list_payments(pagination_latest.url_params_previous_page) 44 | pagination_previous = response_previous.pagination 45 | 46 | response_previous_next = self._list_payments(pagination_previous.url_params_next_page) 47 | payments_previous = response_previous.value 48 | payments_previous_next = response_previous_next.value 49 | payments_actual = payments_previous_next + payments_previous 50 | payments_expected_serialized = converter.serialize(payments_expected) 51 | payments_actual_serialized = converter.serialize(payments_actual) 52 | 53 | self.assertEqual(payments_expected_serialized, payments_actual_serialized) 54 | 55 | def _ensure_enough_payments(self) -> None: 56 | for _ in range(self._payment_missing_count): 57 | self._create_payment() 58 | 59 | @property 60 | def _payment_missing_count(self) -> int: 61 | return self._PAYMENT_REQUIRED_COUNT_MINIMUM - len(self._payments_required()) 62 | 63 | def _payments_required(self) -> List[PaymentApiObject]: 64 | pagination = Pagination() 65 | pagination.count = self._PAYMENT_REQUIRED_COUNT_MINIMUM 66 | 67 | return self._list_payments(pagination.url_params_count_only).value 68 | 69 | @staticmethod 70 | def _list_payments(params: Dict[str, str]) -> BunqResponse[List[PaymentApiObject]]: 71 | return PaymentApiObject.list(params=params) 72 | 73 | def _create_payment(self) -> None: 74 | PaymentApiObject.create( 75 | AmountObject(self._PAYMENT_AMOUNT_EUR, self._PAYMENT_CURRENCY), 76 | self._get_pointer_bravo(), 77 | self._PAYMENT_DESCRIPTION 78 | ) 79 | -------------------------------------------------------------------------------- /bunq/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from bunq.sdk.context.api_environment_type import ApiEnvironmentType 4 | from bunq.sdk.context.installation_context import InstallationContext 5 | from bunq.sdk.http.pagination import Pagination 6 | from bunq.sdk.json import converter 7 | from bunq.sdk.model.core.anchor_object_interface import AnchorObjectInterface 8 | from bunq.sdk.model.generated.object_ import GeolocationObject, ShareDetailObject, MonetaryAccountReference 9 | from bunq.sdk.util.type_alias import T 10 | 11 | 12 | def initialize_converter() -> None: 13 | import datetime 14 | import inspect 15 | 16 | from bunq.sdk.http import api_client 17 | from bunq.sdk.context import api_context 18 | from bunq.sdk.json import converter 19 | from bunq.sdk.model.generated import object_ 20 | from bunq.sdk.model.generated import endpoint 21 | from bunq.sdk.model.core.installation import Installation 22 | from bunq.sdk.model.core.session_server import SessionServer 23 | from bunq.sdk.json.installation_adapter import InstallationAdapter 24 | from bunq.sdk.json.session_server_adapter import SessionServerAdapter 25 | from bunq.sdk.json.installation_context_adapter import InstallationContextAdapter 26 | from bunq.sdk.json.api_environment_type_adapter import ApiEnvironmentTypeAdapter 27 | from bunq.sdk.json.float_adapter import FloatAdapter 28 | from bunq.sdk.json.geolocation_adapter import GeolocationAdapter 29 | from bunq.sdk.json.monetary_account_reference_adapter import MonetaryAccountReferenceAdapter 30 | from bunq.sdk.json.share_detail_adapter import ShareDetailAdapter 31 | from bunq.sdk.json.date_time_adapter import DateTimeAdapter 32 | from bunq.sdk.json.pagination_adapter import PaginationAdapter 33 | from bunq.sdk.json.anchor_object_adapter import AnchorObjectAdapter 34 | 35 | converter.register_adapter(Installation, InstallationAdapter) 36 | converter.register_adapter(SessionServer, SessionServerAdapter) 37 | converter.register_adapter(InstallationContext, InstallationContextAdapter) 38 | converter.register_adapter(ApiEnvironmentType, ApiEnvironmentTypeAdapter) 39 | converter.register_adapter(float, FloatAdapter) 40 | converter.register_adapter(GeolocationObject, GeolocationAdapter) 41 | converter.register_adapter(MonetaryAccountReference, MonetaryAccountReferenceAdapter) 42 | converter.register_adapter(ShareDetailObject, ShareDetailAdapter) 43 | converter.register_adapter(datetime.datetime, DateTimeAdapter) 44 | converter.register_adapter(Pagination, PaginationAdapter) 45 | 46 | def register_anchor_adapter(class_to_register: Type[T]) -> None: 47 | if issubclass(class_to_register, AnchorObjectInterface): 48 | converter.register_adapter(class_to_register, AnchorObjectAdapter) 49 | 50 | def get_class(class_string_to_get: str) -> Type[T]: 51 | if hasattr(object_, class_string_to_get): 52 | return getattr(object_, class_string_to_get) 53 | 54 | if hasattr(endpoint, class_string_to_get): 55 | return getattr(endpoint, class_string_to_get) 56 | 57 | for class_string in list(dir(object_) + dir(endpoint)): 58 | class_ = get_class(class_string) 59 | 60 | if not inspect.isclass(class_): 61 | continue 62 | 63 | register_anchor_adapter(class_) 64 | 65 | 66 | converter.set_initializer_function(initialize_converter) 67 | -------------------------------------------------------------------------------- /tests/model/core/test_notification_filter.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.context.bunq_context import BunqContext 2 | from bunq.sdk.model.core.notification_filter_push_user_internal import NotificationFilterPushUserInternal 3 | from bunq.sdk.model.core.notification_filter_url_monetary_account_internal import \ 4 | NotificationFilterUrlMonetaryAccountInternal 5 | from bunq.sdk.model.core.notification_filter_url_user_internal import NotificationFilterUrlUserInternal 6 | from bunq.sdk.model.generated.object_ import NotificationFilterUrl, NotificationFilterPush 7 | from tests.bunq_test import BunqSdkTestCase 8 | 9 | 10 | class TestNotificationFilter(BunqSdkTestCase): 11 | _FILTER_CATEGORY_MUTATION = 'MUTATION' 12 | _FILTER_CALLBACK_URL = 'https://test.com/callback' 13 | 14 | def test_notification_filter_url_monetary_account(self): 15 | notification_filter = self.get_notification_filter_url() 16 | all_notification_filter = [notification_filter] 17 | 18 | all_created_notification_filter = NotificationFilterUrlMonetaryAccountInternal.create_with_list_response( 19 | self.get_primary_monetary_account().id_, 20 | all_notification_filter 21 | ).value 22 | 23 | self.assertEqual(1, len(all_created_notification_filter)) 24 | 25 | def test_notification_filter_url_user(self): 26 | notification_filter = self.get_notification_filter_url() 27 | all_notification_filter = [notification_filter] 28 | 29 | all_created_notification_filter = NotificationFilterUrlUserInternal.create_with_list_response( 30 | all_notification_filter 31 | ).value 32 | 33 | self.assertEqual(1, len(all_created_notification_filter)) 34 | 35 | def test_notification_filter_push_user(self): 36 | notification_filter = self.get_notification_filter_push() 37 | all_notification_filter = [notification_filter] 38 | 39 | all_created_notification_filter = NotificationFilterPushUserInternal.create_with_list_response( 40 | all_notification_filter 41 | ).value 42 | 43 | self.assertEqual(1, len(all_created_notification_filter)) 44 | 45 | def test_notification_filter_clear(self): 46 | all_created_notification_filter_push_user = NotificationFilterPushUserInternal.create_with_list_response().value 47 | all_created_notification_filter_url_user = NotificationFilterUrlUserInternal.create_with_list_response().value 48 | all_created_notification_filter_url_monetary_account = \ 49 | NotificationFilterUrlMonetaryAccountInternal.create_with_list_response().value 50 | 51 | self.assertFalse(all_created_notification_filter_push_user) 52 | self.assertFalse(all_created_notification_filter_url_user) 53 | self.assertFalse(all_created_notification_filter_url_monetary_account) 54 | 55 | self.assertEqual(0, len(NotificationFilterPushUserInternal.list().value)) 56 | self.assertEqual(0, len(NotificationFilterUrlUserInternal.list().value)) 57 | self.assertEqual(0, len(NotificationFilterUrlMonetaryAccountInternal.list().value)) 58 | 59 | def get_notification_filter_url(self): 60 | return NotificationFilterUrl(self._FILTER_CATEGORY_MUTATION, self._FILTER_CALLBACK_URL) 61 | 62 | def get_notification_filter_push(self): 63 | return NotificationFilterPush(self._FILTER_CATEGORY_MUTATION) 64 | 65 | @staticmethod 66 | def get_primary_monetary_account(): 67 | return BunqContext.user_context().primary_monetary_account 68 | -------------------------------------------------------------------------------- /tests/assets/ResponseJsons/MonetaryAccountJoint.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8948, 3 | "created": "2018-04-03 14:12:31.512599", 4 | "updated": "2018-04-03 14:12:31.512599", 5 | "alias": [ 6 | { 7 | "type": "IBAN", 8 | "value": "NL19BUNQ2025192010", 9 | "name": "New User" 10 | } 11 | ], 12 | "avatar": { 13 | "uuid": "063e74a4-d40b-4961-9395-0eba2d68ee01", 14 | "image": [ 15 | { 16 | "attachment_public_uuid": "8a9ef272-db4a-4c1c-aac0-cc6ee2a4ebcc", 17 | "height": 1023, 18 | "width": 1024, 19 | "content_type": "image\/png" 20 | } 21 | ], 22 | "anchor_uuid": "c747c366-27b7-48bd-97e6-7311dd8bcd6e" 23 | }, 24 | "balance": { 25 | "currency": "EUR", 26 | "value": "0.00" 27 | }, 28 | "country": "NL", 29 | "currency": "EUR", 30 | "daily_limit": { 31 | "currency": "EUR", 32 | "value": "1000.00" 33 | }, 34 | "daily_spent": { 35 | "currency": "EUR", 36 | "value": "0.00" 37 | }, 38 | "description": "H", 39 | "public_uuid": "c747c366-27b7-48bd-97e6-7311dd8bcd6e", 40 | "status": "PENDING_ACCEPTANCE", 41 | "sub_status": "NONE", 42 | "timezone": "europe\/amsterdam", 43 | "user_id": 7809, 44 | "monetary_account_profile": null, 45 | "notification_filters": [], 46 | "setting": { 47 | "color": "#FE2851", 48 | "default_avatar_status": "AVATAR_DEFAULT", 49 | "restriction_chat": "ALLOW_INCOMING" 50 | }, 51 | "overdraft_limit": { 52 | "currency": "EUR", 53 | "value": "0.00" 54 | }, 55 | "all_co_owner": [ 56 | { 57 | "alias": { 58 | "uuid": "d2d59347-b5b6-4db0-8c18-9897e23a7a49", 59 | "display_name": "New", 60 | "country": "000", 61 | "avatar": { 62 | "uuid": "cb302add-2dff-4ec9-8a83-e3698809a340", 63 | "image": [ 64 | { 65 | "attachment_public_uuid": "a07e6696-4800-4c5d-a30b-671cb1f3c198", 66 | "height": 128, 67 | "width": 128, 68 | "content_type": "image\/jpeg" 69 | } 70 | ], 71 | "anchor_uuid": "d2d59347-b5b6-4db0-8c18-9897e23a7a49" 72 | }, 73 | "public_nick_name": "New" 74 | }, 75 | "status": "ACCEPTED" 76 | }, 77 | { 78 | "alias": { 79 | "uuid": "fba128d6-0819-424f-b5bf-b7cd556a6438", 80 | "display_name": "Drake (Person b)", 81 | "country": "000", 82 | "avatar": { 83 | "uuid": "6e9e8e6b-b6d6-44b9-83c3-ab7150921ab1", 84 | "image": [ 85 | { 86 | "attachment_public_uuid": "f3f3c3de-09f3-4523-9bfb-ab33a664610e", 87 | "height": 1024, 88 | "width": 1024, 89 | "content_type": "image\/jpeg" 90 | } 91 | ], 92 | "anchor_uuid": "fba128d6-0819-424f-b5bf-b7cd556a6438" 93 | }, 94 | "public_nick_name": "Drake (Person b)" 95 | }, 96 | "status": "PENDING" 97 | } 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/BunqMeTab.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "BUNQME_TAB", 5 | "event_type": "BUNQME_TAB_CREATED", 6 | "object": { 7 | "BunqMeTab": { 8 | "id": 8, 9 | "created": "2017-11-09 11:05:42.364451", 10 | "updated": "2017-11-09 11:05:42.364451", 11 | "time_expiry": "2017-12-09 11:05:42.361442", 12 | "monetary_account_id": 32, 13 | "status": "WAITING_FOR_PAYMENT", 14 | "bunqme_tab_share_url": "https:\/\/bunq.me\/t\/4da1167c-01bc-4a91-a1e1-44a83e4fef2e", 15 | "bunqme_tab_entry": { 16 | "uuid": "4da1167c-01bc-4a91-a1e1-44a83e4fef2e", 17 | "created": "2017-11-09 11:05:42.450462", 18 | "updated": "2017-11-09 11:05:42.450462", 19 | "amount_inquired": { 20 | "currency": "EUR", 21 | "value": "5.00" 22 | }, 23 | "status": "WAITING_FOR_PAYMENT", 24 | "description": "Thank you for building this awesome feature!", 25 | "alias": { 26 | "iban": "NL18BUNQ2025104340", 27 | "is_light": false, 28 | "display_name": "B.O. Varb", 29 | "avatar": { 30 | "uuid": "b0005448-e385-4d6c-baef-244c295b524f", 31 | "image": [ 32 | { 33 | "attachment_public_uuid": "0c872c0e-1c98-4eea-ba75-a36c6d0a40c7", 34 | "height": 1024, 35 | "width": 1024, 36 | "content_type": "image\/png" 37 | } 38 | ], 39 | "anchor_uuid": null 40 | }, 41 | "label_user": { 42 | "uuid": "353f4064-96bd-49d7-ac69-f87513726c80", 43 | "display_name": "B.O. Varb", 44 | "country": "NL", 45 | "avatar": { 46 | "uuid": "ec2dc709-c8dd-4868-b3e7-601338d88881", 47 | "image": [ 48 | { 49 | "attachment_public_uuid": "7444998c-ff1d-4708-afe3-7b56e33c6f89", 50 | "height": 480, 51 | "width": 480, 52 | "content_type": "image\/jpeg" 53 | } 54 | ], 55 | "anchor_uuid": "353f4064-96bd-49d7-ac69-f87513726c80" 56 | }, 57 | "public_nick_name": "Bravo O (nickname)" 58 | }, 59 | "country": "NL" 60 | }, 61 | "redirect_url": null, 62 | "merchant_available": [ 63 | { 64 | "merchant_type": "IDEAL", 65 | "available": true 66 | }, 67 | { 68 | "merchant_type": "SOFORT", 69 | "available": true 70 | } 71 | ] 72 | }, 73 | "result_inquiries": [] 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/Mutation.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "MUTATION", 5 | "event_type": "MUTATION_CREATED", 6 | "object": { 7 | "Payment": { 8 | "id": 108864, 9 | "created": "2017-11-08 11:20:03.950184", 10 | "updated": "2017-11-08 11:20:03.950184", 11 | "monetary_account_id": 213, 12 | "amount": { 13 | "currency": "EUR", 14 | "value": "-24.95" 15 | }, 16 | "description": "73d6df4a 680498 Your order #1234567", 17 | "type": "IDEAL", 18 | "merchant_reference": null, 19 | "maturity_date": "2017-11-08", 20 | "alias": { 21 | "iban": "NL86BUNQ2025105541", 22 | "is_light": false, 23 | "display_name": "D. Howard", 24 | "avatar": { 25 | "uuid": "cd028f6d-52f3-4b55-be76-56223b7adeec", 26 | "image": [ 27 | { 28 | "attachment_public_uuid": "54b3fbaa-a427-4115-a04d-08372f706a42", 29 | "height": 1023, 30 | "width": 1024, 31 | "content_type": "image\/png" 32 | } 33 | ], 34 | "anchor_uuid": null 35 | }, 36 | "label_user": { 37 | "uuid": "0f4540c4-e81d-4aca-ad12-144fb73635a1", 38 | "display_name": "D. Howard", 39 | "country": "NL", 40 | "avatar": { 41 | "uuid": "514aaa28-c2da-42ce-aa76-7b6fd6f528cc", 42 | "image": [ 43 | { 44 | "attachment_public_uuid": "14847000-40da-4f63-9137-817683acd980", 45 | "height": 456, 46 | "width": 456, 47 | "content_type": "image\/jpeg" 48 | } 49 | ], 50 | "anchor_uuid": "0f4540c4-e81d-4aca-ad12-144fb73635a1" 51 | }, 52 | "public_nick_name": "Dominic (nick) premium \u2728" 53 | }, 54 | "country": "NL" 55 | }, 56 | "counterparty_alias": { 57 | "iban": null, 58 | "is_light": null, 59 | "display_name": "BoekenGigant", 60 | "label_user": { 61 | "uuid": null, 62 | "display_name": "BoekenGigant", 63 | "country": "NL", 64 | "avatar": null, 65 | "public_nick_name": "BoekenGigant" 66 | }, 67 | "avatar": null, 68 | "country": "NL" 69 | }, 70 | "attachment": [], 71 | "geolocation": { 72 | "latitude": 52.387862883215, 73 | "longitude": 4.8333778222355, 74 | "altitude": 0, 75 | "radius": 65 76 | }, 77 | "batch_id": null, 78 | "conversation": null, 79 | "allow_chat": true, 80 | "scheduled_id": null, 81 | "address_billing": null, 82 | "address_shipping": null 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /bunq/sdk/context/user_context.py: -------------------------------------------------------------------------------- 1 | from bunq.sdk.exception.bunq_exception import BunqException 2 | from bunq.sdk.model.core.bunq_model import BunqModel 3 | from bunq.sdk.model.generated.endpoint import UserPersonApiObject, UserCompanyApiObject, UserApiKeyApiObject, MonetaryAccountBankApiObject, UserApiObject, \ 4 | UserPaymentServiceProviderApiObject 5 | 6 | 7 | class UserContext: 8 | _ERROR_UNEXPECTED_USER_INSTANCE = '"{}" is unexpected user instance.' 9 | _ERROR_NO_ACTIVE_MONETARY_ACCOUNT_FOUND = 'No active monetary account found.' 10 | _STATUS_ACTIVE = 'ACTIVE' 11 | 12 | def __init__(self, user_id: int, user: BunqModel) -> None: 13 | self._user_id = user_id 14 | self._user_person = None 15 | self._user_company = None 16 | self._user_api_key = None 17 | self._user_payment_service_provider = None 18 | self._primary_monetary_account = None 19 | 20 | self._set_user(user) 21 | 22 | @staticmethod 23 | def __get_user_object() -> BunqModel: 24 | return UserApiObject.list().value[0].get_referenced_object() 25 | 26 | def _set_user(self, user: BunqModel) -> None: 27 | if isinstance(user, UserPersonApiObject): 28 | self._user_person = user 29 | 30 | elif isinstance(user, UserCompanyApiObject): 31 | self._user_company = user 32 | 33 | elif isinstance(user, UserApiKeyApiObject): 34 | self._user_api_key = user 35 | 36 | elif isinstance(user, UserPaymentServiceProviderApiObject): 37 | self._user_payment_service_provider = user 38 | 39 | else: 40 | raise BunqException( 41 | self._ERROR_UNEXPECTED_USER_INSTANCE.format(user.__class__)) 42 | 43 | def init_main_monetary_account(self) -> None: 44 | if self._user_payment_service_provider is not None: 45 | return 46 | 47 | all_monetary_account = MonetaryAccountBankApiObject.list().value 48 | 49 | for account in all_monetary_account: 50 | if account.status == self._STATUS_ACTIVE: 51 | self._primary_monetary_account = account 52 | 53 | return 54 | 55 | raise BunqException(self._ERROR_NO_ACTIVE_MONETARY_ACCOUNT_FOUND) 56 | 57 | @property 58 | def user_id(self) -> int: 59 | return self._user_id 60 | 61 | def is_only_user_person_set(self) -> bool: 62 | return self._user_person is not None \ 63 | and self._user_company is None \ 64 | and self._user_api_key is None 65 | 66 | def is_only_user_company_set(self) -> bool: 67 | return self._user_company is not None \ 68 | and self._user_person is None \ 69 | and self._user_api_key is None 70 | 71 | def is_only_user_api_key_set(self) -> bool: 72 | return self._user_api_key is not None \ 73 | and self._user_company is None \ 74 | and self._user_person is None 75 | 76 | def is_all_user_type_set(self) -> bool: 77 | return self._user_company is not None \ 78 | and self._user_person is not None \ 79 | and self._user_api_key is not None 80 | 81 | def refresh_user_context(self) -> None: 82 | self._set_user(self.__get_user_object()) 83 | 84 | if self._user_payment_service_provider is not None: 85 | return 86 | 87 | self.init_main_monetary_account() 88 | 89 | @property 90 | def user_company(self) -> UserCompanyApiObject: 91 | return self._user_company 92 | 93 | @property 94 | def user_person(self) -> UserPersonApiObject: 95 | return self._user_person 96 | 97 | @property 98 | def user_api_key(self) -> UserApiKeyApiObject: 99 | return self._user_api_key 100 | 101 | @property 102 | def primary_monetary_account(self) -> MonetaryAccountBankApiObject: 103 | return self._primary_monetary_account 104 | -------------------------------------------------------------------------------- /bunq/sdk/context/session_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from typing import Optional 5 | 6 | from bunq.sdk.exception.bunq_exception import BunqException 7 | from bunq.sdk.model.core.bunq_model import BunqModel 8 | from bunq.sdk.model.core.session_token import SessionToken 9 | from bunq.sdk.model.generated.endpoint import UserPersonApiObject, UserCompanyApiObject, UserApiKeyApiObject, UserPaymentServiceProviderApiObject 10 | 11 | 12 | class SessionContext: 13 | """ 14 | :type _token: str 15 | :type _expiry_time: datetime.datetime 16 | :type _user_id: int 17 | :type _user_person: UserPersonApiObject|None 18 | :type _user_company: UserCompanyApiObject|None 19 | :type _user_api_key: UserApiKeyApiObject|None 20 | :type _user_payment_service_provider: UserPaymentServiceProviderApiObject|None 21 | """ 22 | 23 | # Error constants 24 | _ERROR_ALL_FIELD_IS_NULL = 'All fields are null' 25 | _ERROR_UNEXPECTED_USER_INSTANCE = '"{}" is unexpected user instance.' 26 | 27 | @property 28 | def token(self) -> str: 29 | return self._token 30 | 31 | @property 32 | def expiry_time(self) -> datetime.datetime: 33 | return self._expiry_time 34 | 35 | @property 36 | def user_id(self) -> int: 37 | return self._user_id 38 | 39 | @property 40 | def user_person(self) -> Optional[UserPersonApiObject]: 41 | return self._user_person 42 | 43 | @property 44 | def user_company(self) -> Optional[UserCompanyApiObject]: 45 | return self._user_company 46 | 47 | @property 48 | def user_api_key(self) -> Optional[UserApiKeyApiObject]: 49 | return self._user_api_key 50 | 51 | @property 52 | def user_payment_service_provider(self) -> Optional[UserPaymentServiceProviderApiObject]: 53 | return self._user_payment_service_provider 54 | 55 | def __init__(self, token: SessionToken, expiry_time: datetime.datetime, user: BunqModel) -> None: 56 | self._user_person = None 57 | self._user_company = None 58 | self._user_api_key = None 59 | self._user_payment_service_provider = None 60 | self._token = token.token 61 | self._expiry_time = expiry_time 62 | self._user_id = self.__get_user_id(user) 63 | self.__set_user(user) 64 | 65 | def __get_user_id(self, user: BunqModel) -> int: 66 | if isinstance(user, UserPersonApiObject): 67 | return user.id_ 68 | 69 | if isinstance(user, UserCompanyApiObject): 70 | return user.id_ 71 | 72 | if isinstance(user, UserApiKeyApiObject): 73 | return user.id_ 74 | 75 | if isinstance(user, UserPaymentServiceProviderApiObject): 76 | return user.id_ 77 | 78 | raise BunqException(self._ERROR_UNEXPECTED_USER_INSTANCE) 79 | 80 | def __set_user(self, user: BunqModel): 81 | if isinstance(user, UserPersonApiObject): 82 | self._user_person = user 83 | elif isinstance(user, UserCompanyApiObject): 84 | self._user_company = user 85 | elif isinstance(user, UserApiKeyApiObject): 86 | self._user_api_key = user 87 | elif isinstance(user, UserPaymentServiceProviderApiObject): 88 | self._user_payment_service_provider = user 89 | else: 90 | raise BunqException(self._ERROR_UNEXPECTED_USER_INSTANCE) 91 | 92 | def get_user_reference(self) -> BunqModel: 93 | if self.user_person is not None: 94 | return self.user_person 95 | elif self.user_company is not None: 96 | return self.user_company 97 | elif self.user_api_key is not None: 98 | return self.user_api_key 99 | elif self.user_payment_service_provider is not None: 100 | return self.user_payment_service_provider 101 | else: 102 | raise BunqException(self._ERROR_ALL_FIELD_IS_NULL) 103 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/session_server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | from bunq.sdk.context.api_context import ApiContext 6 | from bunq.sdk.exception.bunq_exception import BunqException 7 | from bunq.sdk.http.api_client import ApiClient 8 | from bunq.sdk.http.bunq_response import BunqResponse 9 | from bunq.sdk.json import converter 10 | from bunq.sdk.model.core.bunq_model import BunqModel 11 | from bunq.sdk.model.core.id import Id 12 | from bunq.sdk.model.core.session_token import SessionToken 13 | from bunq.sdk.model.generated.endpoint import UserPersonApiObject, UserCompanyApiObject, UserApiKeyApiObject, UserPaymentServiceProviderApiObject 14 | 15 | 16 | class SessionServer(BunqModel): 17 | """ 18 | :type _id_: Id|None 19 | :type _token: SessionToken|None 20 | :type _user_person: UserPersonApiObject|None 21 | :type _user_company: UserCompanyApiObject|None 22 | :type _user_api_key: UserApiKeyApiObject|None 23 | :type _user_payment_service_provider: UserPaymentServiceProviderApiObject|None 24 | """ 25 | 26 | # Endpoint name. 27 | _ENDPOINT_URL_POST = "session-server" 28 | 29 | # Field constants 30 | FIELD_SECRET = "secret" 31 | 32 | # Error constants 33 | _ERROR_ALL_FIELD_IS_NULL = 'All fields are null' 34 | 35 | @property 36 | def id_(self) -> Optional[Id]: 37 | return self._id_ 38 | 39 | @property 40 | def token(self) -> Optional[SessionToken]: 41 | return self._token 42 | 43 | @property 44 | def user_person(self) -> Optional[UserPersonApiObject]: 45 | return self._user_person 46 | 47 | @property 48 | def user_company(self) -> Optional[UserCompanyApiObject]: 49 | return self._user_company 50 | 51 | @property 52 | def user_api_key(self) -> Optional[UserApiKeyApiObject]: 53 | return self._user_api_key 54 | 55 | @property 56 | def user_payment_service_provider(self) -> Optional[UserPaymentServiceProviderApiObject]: 57 | return self._user_payment_service_provider 58 | 59 | def __init__(self) -> None: 60 | self._user_person = None 61 | self._user_company = None 62 | self._user_api_key = None 63 | self._user_payment_service_provider = None 64 | self._token = None 65 | self._id_ = None 66 | 67 | @classmethod 68 | def create(cls, api_context: ApiContext) -> BunqResponse[SessionServer]: 69 | cls.__init__(cls) 70 | api_client = ApiClient(api_context) 71 | body_bytes = cls.generate_request_body_bytes(api_context.api_key) 72 | response_raw = api_client.post(cls._ENDPOINT_URL_POST, body_bytes, {}) 73 | 74 | return cls._from_json_array_nested(response_raw) 75 | 76 | @classmethod 77 | def generate_request_body_bytes(cls, secret: str) -> bytes: 78 | return converter.class_to_json({cls.FIELD_SECRET: secret}).encode() 79 | 80 | def is_all_field_none(self) -> bool: 81 | if self.id_ is not None: 82 | return False 83 | elif self.token is not None: 84 | return False 85 | elif self.user_person is not None: 86 | return False 87 | elif self.user_company is not None: 88 | return False 89 | elif self.user_api_key is not None: 90 | return False 91 | elif self.user_payment_service_provider is not None: 92 | return False 93 | else: 94 | return True 95 | 96 | def get_user_reference(self) -> BunqModel: 97 | if self.user_person is not None: 98 | return self.user_person 99 | elif self.user_company is not None: 100 | return self.user_company 101 | elif self.user_api_key is not None: 102 | return self.user_api_key 103 | elif self.user_payment_service_provider is not None: 104 | return self.user_payment_service_provider 105 | else: 106 | raise BunqException(self._ERROR_ALL_FIELD_IS_NULL) 107 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/oauth_access_token.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional, Type 4 | 5 | from bunq import ApiEnvironmentType 6 | from bunq.sdk.context.bunq_context import BunqContext 7 | from bunq.sdk.exception.bunq_exception import BunqException 8 | from bunq.sdk.http.anonymous_api_client import AnonymousApiClient 9 | from bunq.sdk.http.bunq_response import BunqResponse 10 | from bunq.sdk.http.bunq_response_raw import BunqResponseRaw 11 | from bunq.sdk.http.http_util import HttpUtil 12 | from bunq.sdk.json import converter 13 | from bunq.sdk.model.core.bunq_model import BunqModel 14 | from bunq.sdk.model.core.oauth_grant_type import OauthGrantType 15 | from bunq.sdk.model.generated.endpoint import OauthClient 16 | from bunq.sdk.util.type_alias import T 17 | 18 | 19 | class OauthAccessToken(BunqModel): 20 | # Field constants. 21 | FIELD_GRANT_TYPE = "grant_type" 22 | FIELD_CODE = "code" 23 | FIELD_REDIRECT_URI = "redirect_uri" 24 | FIELD_CLIENT_ID = "client_id" 25 | FIELD_CLIENT_SECRET = "client_secret" 26 | 27 | # Token constants. 28 | TOKEN_URI_FORMAT_SANDBOX = "https://api-oauth.sandbox.bunq.com/v1/token?%s" 29 | TOKEN_URI_FORMAT_PRODUCTION = "https://api.oauth.bunq.com/v1/token?%s" 30 | 31 | # Error constants. 32 | ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED = "You are trying to use an unsupported environment type." 33 | 34 | def __init__(self, token: str, token_type: str, state: str = None) -> None: 35 | self._token = token 36 | self._token_type = token_type 37 | self._state = state 38 | 39 | @property 40 | def token(self) -> str: 41 | return self._token 42 | 43 | @property 44 | def token_type(self) -> str: 45 | return self._token_type 46 | 47 | @property 48 | def state(self) -> Optional[str]: 49 | return self._state 50 | 51 | @classmethod 52 | def create(cls, 53 | grant_type: OauthGrantType, 54 | oauth_code: str, 55 | redirect_uri: str, 56 | client: OauthClient) -> OauthAccessToken: 57 | api_client = AnonymousApiClient(BunqContext.api_context()) 58 | response_raw = api_client.post( 59 | cls.create_token_uri(grant_type.value, oauth_code, redirect_uri, client), 60 | bytearray(), 61 | {} 62 | ) 63 | 64 | return cls.from_json(OauthAccessToken, response_raw).value 65 | 66 | @classmethod 67 | def create_token_uri(cls, grant_type: str, auth_code: str, redirect_uri: str, client: OauthClient) -> str: 68 | all_token_parameter = { 69 | cls.FIELD_GRANT_TYPE: grant_type, 70 | cls.FIELD_CODE: auth_code, 71 | cls.FIELD_REDIRECT_URI: redirect_uri, 72 | cls.FIELD_CLIENT_ID: client.id_, 73 | cls.FIELD_CLIENT_SECRET: client.secret, 74 | } 75 | 76 | return cls.determine_auth_uri_format().format(HttpUtil.create_query_string(all_token_parameter)) 77 | 78 | def is_all_field_none(self) -> bool: 79 | if self._token is not None: 80 | return False 81 | elif self._token_type is not None: 82 | return False 83 | elif self._state is not None: 84 | return False 85 | 86 | return True 87 | 88 | @classmethod 89 | def from_json(cls, class_of_object: Type[T], response_raw: BunqResponseRaw): 90 | response_item_object = converter.deserialize(class_of_object, response_raw) 91 | response_value = converter.json_to_class(class_of_object, response_item_object) 92 | 93 | return BunqResponse(response_value, response_raw.headers) 94 | 95 | @classmethod 96 | def determine_auth_uri_format(cls) -> str: 97 | environment_type = BunqContext.api_context().environment_type 98 | 99 | if ApiEnvironmentType.PRODUCTION == environment_type: 100 | return cls.TOKEN_URI_FORMAT_PRODUCTION 101 | 102 | if ApiEnvironmentType.SANDBOX == environment_type: 103 | return cls.TOKEN_URI_FORMAT_SANDBOX 104 | 105 | raise BunqException(cls.ERROR_ENVIRONMENT_TYPE_NOT_SUPPORTED) 106 | -------------------------------------------------------------------------------- /tests/context/test_psd2_context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from bunq import ApiEnvironmentType 5 | from bunq.sdk.context.api_context import ApiContext 6 | from bunq.sdk.context.bunq_context import BunqContext 7 | from bunq.sdk.json import converter 8 | from bunq.sdk.model.generated.endpoint import OauthClientApiObject 9 | from tests.bunq_test import BunqSdkTestCase 10 | 11 | class TestPsd2Context(unittest.TestCase): 12 | """ 13 | Tests: 14 | Psd2Context 15 | """ 16 | 17 | _FILE_TEST_CONFIGURATION = '/bunq-psd2-test.conf' 18 | _FILE_TEST_OAUTH = '/bunq-oauth-psd2-test.conf' 19 | 20 | _FILE_TEST_CERTIFICATE = '/certificate.pem' 21 | _FILE_TEST_CERTIFICATE_CHAIN = '/certificate.pem' 22 | _FILE_TEST_PRIVATE_KEY = '/key.pem' 23 | 24 | _TEST_DEVICE_DESCRIPTION = 'PSD2TestDevice' 25 | 26 | @classmethod 27 | def setUpClass(cls) -> None: 28 | cls._FILE_MODE_READ = ApiContext._FILE_MODE_READ 29 | cls._FILE_TEST_CONFIGURATION_PATH_FULL = ( 30 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_CONFIGURATION 31 | ) 32 | cls._FILE_TEST_OAUTH_PATH_FULL = ( 33 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_OAUTH 34 | ) 35 | cls._FILE_TEST_CERTIFICATE_PATH_FULL = ( 36 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_CERTIFICATE 37 | ) 38 | cls._FILE_TEST_CERTIFICATE_CHAIN_PATH_FULL = ( 39 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_CERTIFICATE_CHAIN 40 | ) 41 | cls._FILE_TEST_PRIVATE_KEY_PATH_FULL = ( 42 | BunqSdkTestCase._get_directory_test_root() + cls._FILE_TEST_PRIVATE_KEY 43 | ) 44 | cls.setup_test_data() 45 | 46 | @classmethod 47 | def setup_test_data(cls) -> None: 48 | if not os.path.isfile(cls._FILE_TEST_CONFIGURATION_PATH_FULL): 49 | try: 50 | BunqContext.load_api_context(cls._create_api_context()) 51 | except FileNotFoundError: 52 | return 53 | 54 | api_context = ApiContext.restore(cls._FILE_TEST_CONFIGURATION_PATH_FULL) 55 | BunqContext.load_api_context(api_context) 56 | 57 | def test_create_psd2_context(self) -> None: 58 | if os.path.isfile(self._FILE_TEST_CONFIGURATION_PATH_FULL): 59 | return 60 | 61 | try: 62 | api_context = self._create_api_context() 63 | BunqContext.load_api_context(api_context) 64 | 65 | self.assertTrue(os.path.isfile(self._FILE_TEST_CONFIGURATION_PATH_FULL)) 66 | 67 | except AssertionError: 68 | raise AssertionError 69 | 70 | def test_create_oauth_client(self) -> None: 71 | if os.path.isfile(self._FILE_TEST_OAUTH_PATH_FULL): 72 | return 73 | 74 | try: 75 | client_id = OauthClientApiObject.create().value 76 | oauth_client = OauthClientApiObject.get(client_id).value 77 | 78 | self.assertIsNotNone(oauth_client) 79 | 80 | serialized_client = converter.class_to_json(oauth_client) 81 | 82 | file = open(self._FILE_TEST_OAUTH_PATH_FULL, ApiContext._FILE_MODE_WRITE) 83 | file.write(serialized_client) 84 | file.close() 85 | 86 | self.assertTrue(os.path.isfile(self._FILE_TEST_OAUTH_PATH_FULL)) 87 | 88 | except AssertionError: 89 | raise AssertionError 90 | 91 | @classmethod 92 | def _create_api_context(cls) -> ApiContext: 93 | with open(cls._FILE_TEST_CERTIFICATE_PATH_FULL, cls._FILE_MODE_READ) as file_: 94 | certificate = file_.read() 95 | 96 | with open(cls._FILE_TEST_PRIVATE_KEY_PATH_FULL, cls._FILE_MODE_READ) as file_: 97 | private_key = file_.read() 98 | 99 | with open(cls._FILE_TEST_CERTIFICATE_PATH_FULL, cls._FILE_MODE_READ) as file_: 100 | all_certificate_chain = file_.read() 101 | 102 | api_context = ApiContext.create_for_psd2( 103 | ApiEnvironmentType.SANDBOX, 104 | certificate, 105 | private_key, 106 | [all_certificate_chain], 107 | cls._TEST_DEVICE_DESCRIPTION 108 | ) 109 | 110 | api_context.save(cls._FILE_TEST_CONFIGURATION_PATH_FULL) 111 | 112 | return api_context 113 | -------------------------------------------------------------------------------- /tests/bunq_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import unittest 4 | from typing import AnyStr 5 | 6 | from bunq.sdk.context.api_context import ApiContext 7 | from bunq.sdk.context.bunq_context import BunqContext 8 | from bunq.sdk.exception.bunq_exception import BunqException 9 | from bunq.sdk.http.api_client import ApiClient 10 | from bunq.sdk.model.generated.endpoint import MonetaryAccountBankApiObject, RequestInquiryApiObject, AttachmentPublicApiObject, AvatarApiObject 11 | from bunq.sdk.model.generated.object_ import AmountObject, PointerObject 12 | from bunq.sdk.util import util 13 | 14 | 15 | class BunqSdkTestCase(unittest.TestCase): 16 | """ 17 | :type _second_monetary_account: MonetaryAccountBankApiObject 18 | """ 19 | 20 | # Error constants. 21 | __ERROR_COULD_NOT_DETERMINE_USER = 'Could not determine user alias.' 22 | 23 | # Name of bunq config file 24 | _FILENAME_BUNQ_CONFIG = "/bunq-test.conf" 25 | 26 | # Device description used for python tests 27 | _DEVICE_DESCRIPTION = 'Python test device' 28 | 29 | _PATH_ATTACHMENT = 'tests/assets/' 30 | _READ_BYTES = "rb" 31 | _ATTACHMENT_PATH_IN = 'vader.png' 32 | _CONTENT_TYPE = 'image/png' 33 | _ATTACHMENT_DESCRIPTION = 'SDK python test' 34 | _FIRST_INDEX = 0 35 | 36 | __SPENDING_MONEY_AMOUNT = '500' 37 | __CURRENCY_EUR = 'EUR' 38 | _POINTER_EMAIL = 'EMAIL' 39 | __SPENDING_MONEY_RECIPIENT = 'sugardaddy@bunq.com' 40 | __REQUEST_SPENDING_DESCRIPTION = 'sdk python test, thanks daddy <3' 41 | 42 | __SECOND_MONETARY_ACCOUNT_DESCRIPTION = 'test account python' 43 | 44 | _EMAIL_BRAVO = 'bravo@bunq.com' 45 | 46 | __TIME_OUT_AUTO_ACCEPT_SPENDING_MONEY = 2 47 | 48 | _second_monetary_account = None 49 | 50 | @classmethod 51 | def setUpClass(cls): 52 | BunqContext.load_api_context(cls._get_api_context()) 53 | 54 | def setUp(self): 55 | self.__set_second_monetary_account() 56 | self.__request_spending_money() 57 | time.sleep(self.__TIME_OUT_AUTO_ACCEPT_SPENDING_MONEY) 58 | BunqContext.user_context().refresh_user_context() 59 | 60 | def __set_second_monetary_account(self): 61 | response = MonetaryAccountBankApiObject.create( 62 | self.__CURRENCY_EUR, 63 | self.__SECOND_MONETARY_ACCOUNT_DESCRIPTION 64 | ) 65 | 66 | self._second_monetary_account = MonetaryAccountBankApiObject.get( 67 | response.value 68 | ).value 69 | 70 | def __request_spending_money(self): 71 | RequestInquiryApiObject.create( 72 | AmountObject(self.__SPENDING_MONEY_AMOUNT, self.__CURRENCY_EUR), 73 | PointerObject(self._POINTER_EMAIL, self.__SPENDING_MONEY_RECIPIENT), 74 | self.__REQUEST_SPENDING_DESCRIPTION, 75 | False 76 | ) 77 | RequestInquiryApiObject.create( 78 | AmountObject(self.__SPENDING_MONEY_AMOUNT, self.__CURRENCY_EUR), 79 | PointerObject(self._POINTER_EMAIL, self.__SPENDING_MONEY_RECIPIENT), 80 | self.__REQUEST_SPENDING_DESCRIPTION, 81 | False, 82 | self._second_monetary_account.id_ 83 | ) 84 | 85 | @classmethod 86 | def _get_api_context(cls) -> ApiContext: 87 | return util.automatic_sandbox_install() 88 | 89 | def _get_pointer_bravo(self) -> PointerObject: 90 | return PointerObject(self._POINTER_EMAIL, self._EMAIL_BRAVO) 91 | 92 | def _get_alias_second_account(self) -> PointerObject: 93 | return self._second_monetary_account.alias[self._FIRST_INDEX] 94 | 95 | @staticmethod 96 | def _get_directory_test_root(): 97 | return os.path.dirname(os.path.abspath(__file__)) 98 | 99 | @property 100 | def _attachment_contents(self) -> AnyStr: 101 | with open( 102 | self._get_directory_test_root() + 103 | self._PATH_ATTACHMENT + 104 | self._ATTACHMENT_PATH_IN, 105 | self._READ_BYTES 106 | ) as file: 107 | return file.read() 108 | 109 | @property 110 | def alias_first(self) -> PointerObject: 111 | if BunqContext.user_context().is_only_user_company_set(): 112 | return BunqContext.user_context().user_company.alias[self._FIRST_INDEX] 113 | 114 | if BunqContext.user_context().is_only_user_person_set(): 115 | return BunqContext.user_context().user_person.alias[self._FIRST_INDEX] 116 | 117 | raise BunqException(self.__ERROR_COULD_NOT_DETERMINE_USER) 118 | -------------------------------------------------------------------------------- /bunq/sdk/exception/exception_factory.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from bunq.sdk.exception.api_exception import ApiException 4 | from bunq.sdk.exception.bad_request_exception import BadRequestException 5 | from bunq.sdk.exception.forbidden_exception import ForbiddenException 6 | from bunq.sdk.exception.method_not_allowed_exception import MethodNotAllowedException 7 | from bunq.sdk.exception.not_found_exception import NotFoundException 8 | from bunq.sdk.exception.please_contact_bunq_exception import PleaseContactBunqException 9 | from bunq.sdk.exception.too_many_requests_exception import TooManyRequestsException 10 | from bunq.sdk.exception.unauthorized_exception import UnauthorizedException 11 | from bunq.sdk.exception.unknown_api_error_exception import UnknownApiErrorException 12 | 13 | 14 | class ExceptionFactory: 15 | # Error response code constants 16 | _HTTP_RESPONSE_CODE_BAD_REQUEST = 400 17 | _HTTP_RESPONSE_CODE_UNAUTHORIZED = 401 18 | _HTTP_RESPONSE_CODE_FORBIDDEN = 403 19 | _HTTP_RESPONSE_CODE_NOT_FOUND = 404 20 | _HTTP_RESPONSE_CODE_METHOD_NOT_ALLOWED = 405 21 | _HTTP_RESPONSE_CODE_TOO_MANY_REQUESTS = 429 22 | _HTTP_RESPONSE_CODE_INTERNAL_SERVER_ERROR = 500 23 | 24 | # Constants for formatting messages 25 | _FORMAT_RESPONSE_CODE_LINE = 'HTTP Response Code: {}' 26 | _FORMAT_RESPONSE_ID_LINE = 'The response id to help bunq debug: {}' 27 | _FORMAT_ERROR_MESSAGE_LINE = 'Error message: {}' 28 | _GLUE_ERROR_MESSAGE_NEW_LINE = '\n' 29 | _GLUE_ERROR_MESSAGE_STRING_EMPTY = '' 30 | 31 | @classmethod 32 | def create_exception_for_response( 33 | cls, 34 | response_code: int, 35 | messages: List[str], 36 | response_id: str 37 | ) -> ApiException: 38 | """ 39 | 40 | :return: The exception according to the status code. 41 | """ 42 | 43 | error_message = cls._generate_message_error( 44 | response_code, 45 | messages, 46 | response_id 47 | ) 48 | 49 | if response_code == cls._HTTP_RESPONSE_CODE_BAD_REQUEST: 50 | return BadRequestException( 51 | error_message, 52 | response_code, 53 | response_id 54 | ) 55 | if response_code == cls._HTTP_RESPONSE_CODE_UNAUTHORIZED: 56 | return UnauthorizedException( 57 | error_message, 58 | response_code, 59 | response_id 60 | ) 61 | if response_code == cls._HTTP_RESPONSE_CODE_FORBIDDEN: 62 | return ForbiddenException( 63 | error_message, 64 | response_code, 65 | response_id 66 | ) 67 | if response_code == cls._HTTP_RESPONSE_CODE_NOT_FOUND: 68 | return NotFoundException( 69 | error_message, 70 | response_code, 71 | response_id 72 | ) 73 | if response_code == cls._HTTP_RESPONSE_CODE_METHOD_NOT_ALLOWED: 74 | return MethodNotAllowedException( 75 | error_message, 76 | response_code, 77 | response_id 78 | ) 79 | if response_code == cls._HTTP_RESPONSE_CODE_TOO_MANY_REQUESTS: 80 | return TooManyRequestsException( 81 | error_message, 82 | response_code, 83 | response_id 84 | ) 85 | if response_code == cls._HTTP_RESPONSE_CODE_INTERNAL_SERVER_ERROR: 86 | return PleaseContactBunqException( 87 | error_message, 88 | response_code, 89 | response_id 90 | ) 91 | 92 | return UnknownApiErrorException( 93 | error_message, 94 | response_code, 95 | response_id 96 | ) 97 | 98 | @classmethod 99 | def _generate_message_error(cls, 100 | response_code: int, 101 | messages: List[str], 102 | response_id: str) -> str: 103 | line_response_code = cls._FORMAT_RESPONSE_CODE_LINE.format(response_code) 104 | line_response_id = cls._FORMAT_RESPONSE_ID_LINE.format(response_id) 105 | line_error_message = cls._FORMAT_ERROR_MESSAGE_LINE.format(cls._GLUE_ERROR_MESSAGE_STRING_EMPTY.join(messages)) 106 | 107 | return cls._glue_all_error_message([line_response_code, line_response_id, line_error_message]) 108 | 109 | @classmethod 110 | def _glue_all_error_message(cls, messages: List[str]) -> str: 111 | return cls._GLUE_ERROR_MESSAGE_NEW_LINE.join(messages) 112 | -------------------------------------------------------------------------------- /tests/context/test_api_context.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from bunq.sdk.context.api_context import ApiContext 5 | from bunq.sdk.context.bunq_context import BunqContext 6 | from bunq.sdk.json import converter 7 | from tests.bunq_test import BunqSdkTestCase 8 | 9 | class TestApiContext(BunqSdkTestCase): 10 | """ 11 | Tests: 12 | ApiContext 13 | """ 14 | 15 | _TMP_FILE_PATH = '/assets/context-save-test.conf' 16 | 17 | __FIELD_SESSION_CONTEXT = 'session_context' 18 | __FIELD_EXPIRE_TIME = 'expiry_time' 19 | __TIME_STAMP_IN_PAST = '2000-04-07 19:50:43.839717' 20 | 21 | @classmethod 22 | def setUpClass(cls) -> None: 23 | super().setUpClass() 24 | cls._FILE_MODE_READ = ApiContext._FILE_MODE_READ 25 | cls._API_CONTEXT: ApiContext = cls._get_api_context() 26 | cls._TMP_FILE_PATH_FULL = (cls._get_directory_test_root() + cls._TMP_FILE_PATH) 27 | 28 | def test_api_context_save(self) -> None: 29 | """ 30 | Converts an ApiContext to JSON data, saves the same ApiContext to a 31 | temporary file, and compares whether the JSON data is equal to the 32 | data in the file. 33 | 34 | Removes the temporary file before assertion. 35 | """ 36 | 37 | context_json = converter.class_to_json(self._API_CONTEXT) 38 | 39 | self._API_CONTEXT.save(self._TMP_FILE_PATH_FULL) 40 | 41 | with open(self._TMP_FILE_PATH_FULL, self._FILE_MODE_READ) as file_: 42 | context_retrieved = file_.read() 43 | 44 | os.remove(self._TMP_FILE_PATH_FULL) 45 | 46 | self.assertEqual(context_retrieved, context_json) 47 | 48 | def test_api_context_restore(self) -> None: 49 | """ 50 | Saves an ApiContext to a temporary file, restores an ApiContext from 51 | that file, and compares whether the api_keys, tokens, and environment 52 | types are equal in the ApiContext and the restored ApiContext. 53 | 54 | Removes the temporary file before assertion. 55 | """ 56 | 57 | self._API_CONTEXT.save(self._TMP_FILE_PATH_FULL) 58 | api_context_restored = ApiContext.restore(self._TMP_FILE_PATH_FULL) 59 | 60 | os.remove(self._TMP_FILE_PATH_FULL) 61 | 62 | self.assertEqual(api_context_restored, self._API_CONTEXT) 63 | 64 | def test_api_context_save_json(self): 65 | """ 66 | Converts an ApiContext to JSON data, saves the ApiContext using the 67 | ApiContext.save() function with the to_JSON flag set to True, and 68 | compares whether the JSON data equals the returned JSON data from the 69 | ApiContext.save() function. 70 | """ 71 | 72 | context_json = converter.class_to_json(self._API_CONTEXT) 73 | context_saved = self._API_CONTEXT.to_json() 74 | 75 | self.assertEqual(context_saved, context_json) 76 | 77 | def test_api_context_restore_json(self): 78 | """ 79 | Saves an ApiContext with the ApiContext.save() function with the 80 | to_JSON flag set to True, restores an ApiContext from the JSON data 81 | returned from the ApiContext.save() function, and checks that the 82 | api_key, token, and environment type variables of the restored 83 | ApiContext are equal to the respective variables in the original 84 | ApiContext. 85 | """ 86 | 87 | context_json = self._API_CONTEXT.to_json() 88 | api_context_restored = self._API_CONTEXT.from_json(context_json) 89 | 90 | self.assertEqual(api_context_restored, self._API_CONTEXT) 91 | 92 | def test_auto_bunq_context_update(self): 93 | """ 94 | Tests the auto update of BunqContext. 95 | """ 96 | 97 | api_context = BunqContext.api_context() 98 | api_context_json = json.loads(api_context.to_json()) 99 | 100 | api_context_json[self.__FIELD_SESSION_CONTEXT][self.__FIELD_EXPIRE_TIME] = self.__TIME_STAMP_IN_PAST 101 | 102 | expired_api_context = ApiContext.from_json(json.dumps(api_context_json)) 103 | 104 | self.assertNotEqual(api_context.session_context.expiry_time, expired_api_context.session_context.expiry_time) 105 | self.assertEqual(BunqContext.api_context().session_context.expiry_time, api_context.session_context.expiry_time) 106 | 107 | BunqContext.update_api_context(expired_api_context) 108 | BunqContext.user_context().refresh_user_context() 109 | 110 | self.assertNotEqual( 111 | BunqContext.api_context().session_context.expiry_time, 112 | api_context.session_context.expiry_time 113 | ) 114 | self.assertFalse(BunqContext.api_context().ensure_session_active()) 115 | -------------------------------------------------------------------------------- /bunq/sdk/json/session_server_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Type, List 2 | 3 | from bunq.sdk.exception.bunq_exception import BunqException 4 | from bunq.sdk.json import converter 5 | from bunq.sdk.model.core.id import Id 6 | from bunq.sdk.model.core.session_server import SessionServer 7 | from bunq.sdk.model.core.session_token import SessionToken 8 | from bunq.sdk.model.generated.endpoint import UserCompanyApiObject, UserPersonApiObject, UserApiKeyApiObject, UserPaymentServiceProviderApiObject 9 | 10 | 11 | class SessionServerAdapter(converter.JsonAdapter): 12 | # Error constants. 13 | _ERROR_COULD_NOT_DETERMINE_USER = 'Could not determine user.' 14 | 15 | # Id constants 16 | _ATTRIBUTE_ID = '_id_' 17 | _INDEX_ID = 0 18 | _FIELD_ID = 'Id' 19 | 20 | # Token constants 21 | _ATTRIBUTE_TOKEN = '_token' 22 | _INDEX_TOKEN = 1 23 | _FIELD_TOKEN = 'Token' 24 | 25 | # User constants 26 | _INDEX_USER = 2 27 | 28 | # UserCompany constants 29 | _ATTRIBUTE_USER_COMPANY = '_user_company' 30 | _FIELD_USER_COMPANY = 'UserCompany' 31 | 32 | # UserPerson constants 33 | _ATTRIBUTE_USER_PERSON = '_user_person' 34 | _FIELD_USER_PERSON = 'UserPerson' 35 | 36 | # UserApiKey constants 37 | _ATTRIBUTE_USER_API_KEY = '_user_api_key' 38 | _FIELD_USER_API_KEY = 'UserApiKey' 39 | 40 | # UserPaymentServiceProvider constants 41 | _ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER = '_user_payment_service_provider' 42 | _FIELD_USER_PAYMENT_SERVER_PROVIDER = 'UserPaymentServiceProvider' 43 | 44 | @classmethod 45 | def deserialize(cls, 46 | target_class: Type[SessionServer], 47 | array: List) -> SessionServer: 48 | session_server = target_class.__new__(target_class) 49 | session_server.__dict__ = { 50 | cls._ATTRIBUTE_ID: converter.deserialize( 51 | Id, 52 | array[cls._INDEX_ID][cls._FIELD_ID] 53 | ), 54 | cls._ATTRIBUTE_TOKEN: converter.deserialize( 55 | SessionToken, 56 | array[cls._INDEX_TOKEN][cls._FIELD_TOKEN] 57 | ), 58 | cls._ATTRIBUTE_USER_COMPANY: None, 59 | cls._ATTRIBUTE_USER_PERSON: None, 60 | cls._ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER: None, 61 | } 62 | 63 | user_dict_wrapped = array[cls._INDEX_USER] 64 | 65 | if cls._FIELD_USER_COMPANY in user_dict_wrapped: 66 | session_server.__dict__[cls._ATTRIBUTE_USER_COMPANY] = \ 67 | converter.deserialize( 68 | UserCompanyApiObject, 69 | user_dict_wrapped[cls._FIELD_USER_COMPANY] 70 | ) 71 | elif cls._FIELD_USER_PERSON in user_dict_wrapped: 72 | session_server.__dict__[cls._ATTRIBUTE_USER_PERSON] = \ 73 | converter.deserialize( 74 | UserPersonApiObject, 75 | user_dict_wrapped[cls._FIELD_USER_PERSON] 76 | ) 77 | elif cls._FIELD_USER_API_KEY in user_dict_wrapped: 78 | session_server.__dict__[cls._ATTRIBUTE_USER_API_KEY] = \ 79 | converter.deserialize( 80 | UserApiKeyApiObject, 81 | user_dict_wrapped[cls._FIELD_USER_API_KEY] 82 | ) 83 | elif cls._FIELD_USER_PAYMENT_SERVER_PROVIDER in user_dict_wrapped: 84 | session_server.__dict__[cls._ATTRIBUTE_USER_PAYMENT_SERVER_PROVIDER] = \ 85 | converter.deserialize( 86 | UserPaymentServiceProviderApiObject, 87 | user_dict_wrapped[cls._FIELD_USER_PAYMENT_SERVER_PROVIDER] 88 | ) 89 | else: 90 | raise BunqException(cls._ERROR_COULD_NOT_DETERMINE_USER) 91 | 92 | return session_server 93 | 94 | @classmethod 95 | def serialize(cls, session_server: SessionServer) -> List: 96 | return [ 97 | {cls._FIELD_ID: converter.serialize(session_server.id_)}, 98 | {cls._FIELD_TOKEN: converter.serialize(session_server.token)}, 99 | { 100 | cls._FIELD_USER_COMPANY: 101 | converter.serialize(session_server.user_company), 102 | }, 103 | { 104 | cls._FIELD_USER_PERSON: 105 | converter.serialize(session_server.user_person), 106 | }, 107 | { 108 | cls._FIELD_USER_API_KEY: 109 | converter.serialize(session_server.user_api_key), 110 | }, 111 | { 112 | cls._FIELD_USER_PAYMENT_SERVER_PROVIDER: 113 | converter.serialize(session_server.user_payment_service_provider), 114 | }, 115 | ] 116 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/ShareInviteBankInquiry.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "SHARE", 5 | "event_type": "SHARE_INVITE_BANK_INQUIRY_ACCEPTED", 6 | "object": { 7 | "ShareInviteBankInquiry": { 8 | "id": 1, 9 | "created": "2017-07-20 02:32:32.074527", 10 | "updated": "2017-07-20 02:32:32.074527", 11 | "alias": { 12 | "iban": "NL59BUNQ2025104669", 13 | "is_light": false, 14 | "display_name": "Alpha Corp.", 15 | "avatar": { 16 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a", 17 | "image": [ 18 | { 19 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439", 20 | "height": 1024, 21 | "width": 1024, 22 | "content_type": "image\/png" 23 | } 24 | ], 25 | "anchor_uuid": null 26 | }, 27 | "label_user": { 28 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3", 29 | "display_name": "Alpha Corp.", 30 | "country": "NL", 31 | "avatar": { 32 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9", 33 | "image": [ 34 | { 35 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a", 36 | "height": 640, 37 | "width": 640, 38 | "content_type": "image\/png" 39 | } 40 | ], 41 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3" 42 | }, 43 | "public_nick_name": "Alpha Corp." 44 | }, 45 | "country": "NL" 46 | }, 47 | "user_alias_created": { 48 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3", 49 | "display_name": "Alpha Corp.", 50 | "country": "NL", 51 | "avatar": { 52 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9", 53 | "image": [ 54 | { 55 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a", 56 | "height": 640, 57 | "width": 640, 58 | "content_type": "image\/png" 59 | } 60 | ], 61 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3" 62 | }, 63 | "public_nick_name": "Alpha Corp." 64 | }, 65 | "user_alias_revoked": null, 66 | "counter_user_alias": { 67 | "uuid": "688e4bdc-ec27-4d8e-942b-3aee7b5d15e9", 68 | "display_name": "Echo (nickname)", 69 | "country": "000", 70 | "avatar": { 71 | "uuid": "3da21898-1535-43c9-9023-83333e776b8b", 72 | "image": [ 73 | { 74 | "attachment_public_uuid": "6bb2b5d6-faee-4d5d-8284-af026bde3640", 75 | "height": 480, 76 | "width": 480, 77 | "content_type": "image\/jpeg" 78 | } 79 | ], 80 | "anchor_uuid": "688e4bdc-ec27-4d8e-942b-3aee7b5d15e9" 81 | }, 82 | "public_nick_name": "Echo (nickname)" 83 | }, 84 | "monetary_account_id": 46, 85 | "draft_share_invite_bank_id": null, 86 | "share_type": "STANDARD", 87 | "status": "PENDING", 88 | "share_detail": { 89 | "ShareDetailPayment": { 90 | "view_balance": true, 91 | "view_old_events": true, 92 | "view_new_events": true, 93 | "budget": null, 94 | "make_payments": true 95 | } 96 | }, 97 | "start_date": "2017-07-20 02:34:05.864029", 98 | "end_date": "2017-11-11 02:34:05.864029" 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/RequestResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "REQUEST", 5 | "event_type": "REQUEST_INQUIRY_ACCEPTED", 6 | "object": { 7 | "RequestResponse": { 8 | "id": 2, 9 | "created": "2017-07-13 21:44:31.840439", 10 | "updated": "2017-07-13 21:44:31.840439", 11 | "time_responded": null, 12 | "time_expiry": null, 13 | "monetary_account_id": 36, 14 | "amount_inquired": { 15 | "currency": "EUR", 16 | "value": "3.31" 17 | }, 18 | "amount_responded": null, 19 | "status": "PENDING", 20 | "description": "Test request inquiry to NL15BUNQ2025104200", 21 | "alias": { 22 | "iban": "NL15BUNQ2025104200", 23 | "is_light": false, 24 | "display_name": "Delta \u0644\u0623\u0628\u062c\u062f\u064a\u0629 \u0627 \u0639\u0631\u0628\u064a\u0629 (nickname)", 25 | "avatar": { 26 | "uuid": "11ab9e94-a90e-4334-9e70-b81ad7c4dc0a", 27 | "image": [ 28 | { 29 | "attachment_public_uuid": "423b6870-a938-41d4-812b-f9b090e03d07", 30 | "height": 1024, 31 | "width": 1024, 32 | "content_type": "image\/png" 33 | } 34 | ], 35 | "anchor_uuid": null 36 | }, 37 | "label_user": { 38 | "uuid": "e3281b2c-d552-49b4-b575-33b87cfee463", 39 | "display_name": "Delta \u0644\u0623\u0628\u062c\u062f\u064a\u0629 \u0627 \u0639\u0631\u0628\u064a\u0629 (nickname)", 40 | "country": "NL", 41 | "avatar": { 42 | "uuid": "a94240e9-360f-4097-9caa-7974946de533", 43 | "image": [ 44 | { 45 | "attachment_public_uuid": "4f25125b-ce8b-43e9-b726-317c9eb0fd46", 46 | "height": 480, 47 | "width": 480, 48 | "content_type": "image\/jpeg" 49 | } 50 | ], 51 | "anchor_uuid": "e3281b2c-d552-49b4-b575-33b87cfee463" 52 | }, 53 | "public_nick_name": "Delta \u0644\u0623\u0628\u062c\u062f\u064a\u0629 \u0627 \u0639\u0631\u0628\u064a\u0629 (nickname)" 54 | }, 55 | "country": "NL" 56 | }, 57 | "counterparty_alias": { 58 | "iban": "NL59BUNQ2025104669", 59 | "is_light": false, 60 | "display_name": "Alpha Corp.", 61 | "avatar": { 62 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a", 63 | "image": [ 64 | { 65 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439", 66 | "height": 1024, 67 | "width": 1024, 68 | "content_type": "image\/png" 69 | } 70 | ], 71 | "anchor_uuid": null 72 | }, 73 | "label_user": { 74 | "uuid|": "2711664e-885e-4b5c-bf2c-581ba39061d3", 75 | "display_name": "Alpha Corp.", 76 | "country": "NL", 77 | "avatar": { 78 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9", 79 | "image": [ 80 | { 81 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a", 82 | "height": 640, 83 | "width": 640, 84 | "content_type": "image\/png" 85 | } 86 | ], 87 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3" 88 | }, 89 | "public_nick_name": "Alpha Corp." 90 | }, 91 | "country": "NL" 92 | }, 93 | "attachment": null, 94 | "minimum_age": null, 95 | "require_address": null, 96 | "geolocation": null, 97 | "type": "INTERNAL", 98 | "sub_type": "NONE", 99 | "redirect_url": null, 100 | "address_billing": null, 101 | "address_shipping": null, 102 | "conversation": null, 103 | "allow_chat": true, 104 | "eligible_whitelist_id": null 105 | } 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/http/test_pagination.py: -------------------------------------------------------------------------------- 1 | from bunq import Pagination 2 | from bunq.sdk.exception.bunq_exception import BunqException 3 | from tests.bunq_test import BunqSdkTestCase 4 | 5 | 6 | class TestPagination(BunqSdkTestCase): 7 | """ 8 | Tests: 9 | Pagination 10 | """ 11 | 12 | _PAGINATION_OLDER_ID_CUSTOM = 1 13 | _PAGINATION_NEWER_ID_CUSTOM = 2 14 | _PAGINATION_FUTURE_ID_CUSTOM = 3 15 | _PAGINATION_COUNT_CUSTOM = 5 16 | 17 | def test_get_url_params_count_only(self): 18 | pagination = self._create_pagination_with_all_properties_set() 19 | url_params_count_only_expected = { 20 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM), 21 | } 22 | 23 | self.assertEqual(url_params_count_only_expected, 24 | pagination.url_params_count_only) 25 | 26 | def _create_pagination_with_all_properties_set(self) -> Pagination: 27 | pagination = Pagination() 28 | pagination.older_id = self._PAGINATION_OLDER_ID_CUSTOM 29 | pagination.newer_id = self._PAGINATION_NEWER_ID_CUSTOM 30 | pagination.future_id = self._PAGINATION_FUTURE_ID_CUSTOM 31 | pagination.count = self._PAGINATION_COUNT_CUSTOM 32 | 33 | return pagination 34 | 35 | def test_get_url_params_previous_page(self): 36 | pagination = self._create_pagination_with_all_properties_set() 37 | url_params_previous_page_expected = { 38 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM), 39 | Pagination.PARAM_OLDER_ID: str(self._PAGINATION_OLDER_ID_CUSTOM), 40 | } 41 | 42 | self.assertTrue(pagination.has_previous_page()) 43 | self.assertEqual(url_params_previous_page_expected, pagination.url_params_previous_page) 44 | 45 | def test_get_url_params_previous_page_no_count(self): 46 | pagination = self._create_pagination_with_all_properties_set() 47 | pagination.count = None 48 | url_params_previous_page_expected = { 49 | Pagination.PARAM_OLDER_ID: str(self._PAGINATION_OLDER_ID_CUSTOM), 50 | } 51 | 52 | self.assertTrue(pagination.has_previous_page()) 53 | self.assertEqual(url_params_previous_page_expected, pagination.url_params_previous_page) 54 | 55 | def test_get_url_params_next_page_newer(self): 56 | pagination = self._create_pagination_with_all_properties_set() 57 | url_params_next_page_expected = { 58 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM), 59 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_NEWER_ID_CUSTOM), 60 | } 61 | 62 | self.assertTrue(pagination.has_next_page_assured()) 63 | self.assertEqual(url_params_next_page_expected, 64 | pagination.url_params_next_page) 65 | 66 | def test_get_url_params_next_page_newer_no_count(self): 67 | pagination = self._create_pagination_with_all_properties_set() 68 | pagination.count = None 69 | url_params_next_page_expected = { 70 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_NEWER_ID_CUSTOM), 71 | } 72 | 73 | self.assertTrue(pagination.has_next_page_assured()) 74 | self.assertEqual(url_params_next_page_expected, 75 | pagination.url_params_next_page) 76 | 77 | def test_get_url_params_next_page_future(self): 78 | pagination = self._create_pagination_with_all_properties_set() 79 | pagination.newer_id = None 80 | url_params_next_page_expected = { 81 | Pagination.PARAM_COUNT: str(self._PAGINATION_COUNT_CUSTOM), 82 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_FUTURE_ID_CUSTOM), 83 | } 84 | 85 | self.assertFalse(pagination.has_next_page_assured()) 86 | self.assertEqual(url_params_next_page_expected, 87 | pagination.url_params_next_page) 88 | 89 | def test_get_url_params_next_page_future_no_count(self): 90 | pagination = self._create_pagination_with_all_properties_set() 91 | pagination.newer_id = None 92 | pagination.count = None 93 | url_params_next_page_expected = { 94 | Pagination.PARAM_NEWER_ID: str(self._PAGINATION_FUTURE_ID_CUSTOM), 95 | } 96 | 97 | self.assertFalse(pagination.has_next_page_assured()) 98 | self.assertEqual(url_params_next_page_expected, 99 | pagination.url_params_next_page) 100 | 101 | def test_get_url_params_prev_page_from_pagination_with_no_prev_page(self): 102 | pagination = self._create_pagination_with_all_properties_set() 103 | pagination.older_id = None 104 | 105 | def access_url_params_previous_page(): 106 | _ = pagination.url_params_previous_page 107 | 108 | self.assertFalse(pagination.has_previous_page()) 109 | self.assertRaises(BunqException, 110 | access_url_params_previous_page) 111 | 112 | def test_get_url_params_next_page_from_pagination_with_no_next_page(self): 113 | pagination = self._create_pagination_with_all_properties_set() 114 | pagination.newer_id = None 115 | pagination.future_id = None 116 | 117 | def access_url_params_next_page(): 118 | _ = pagination.url_params_next_page 119 | 120 | self.assertRaises(BunqException, access_url_params_next_page) 121 | -------------------------------------------------------------------------------- /bunq/sdk/model/core/bunq_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from typing import Dict, List 5 | 6 | from bunq.sdk.util.type_alias import T 7 | from bunq.sdk.http.bunq_response import BunqResponse 8 | from bunq.sdk.http.bunq_response_raw import BunqResponseRaw 9 | from bunq.sdk.json import converter 10 | 11 | if typing.TYPE_CHECKING: 12 | from bunq.sdk.context.api_context import ApiContext 13 | 14 | 15 | class BunqModel: 16 | # Field constants 17 | _FIELD_RESPONSE = 'Response' 18 | _FIELD_PAGINATION = 'Pagination' 19 | _FIELD_ID = 'Id' 20 | _FIELD_UUID = 'Uuid' 21 | 22 | # The very first index of an array 23 | _INDEX_FIRST = 0 24 | 25 | __STRING_FORMAT_EMPTY = '' 26 | __STRING_FORMAT_FIELD_FOR_REQUEST_ONE_UNDERSCORE = '_field_for_request' 27 | __STRING_FORMAT_FIELD_FOR_REQUEST_TWO_UNDERSCORE = '__field_for_request' 28 | 29 | def is_all_field_none(self) -> None: 30 | raise NotImplementedError 31 | 32 | def to_json(self) -> str: 33 | return converter.class_to_json(self) 34 | 35 | @classmethod 36 | def _from_json_array_nested(cls, response_raw: BunqResponseRaw) -> BunqResponse[BunqModel]: 37 | json = response_raw.body_bytes.decode() 38 | obj = converter.json_to_class(dict, json) 39 | value = converter.deserialize(cls, obj[cls._FIELD_RESPONSE]) 40 | 41 | return BunqResponse(value, response_raw.headers) 42 | 43 | @classmethod 44 | def _from_json(cls, 45 | response_raw: BunqResponseRaw, 46 | wrapper: str = None) -> BunqResponse[BunqModel]: 47 | json = response_raw.body_bytes.decode() 48 | obj = converter.json_to_class(dict, json) 49 | value = converter.deserialize( 50 | cls, 51 | cls._unwrap_response_single(obj, wrapper) 52 | ) 53 | 54 | return BunqResponse(value, response_raw.headers) 55 | 56 | @classmethod 57 | def _unwrap_response_single(cls, obj: Dict, wrapper: str = None) -> Dict: 58 | if wrapper is None: 59 | return obj[cls._FIELD_RESPONSE][cls._INDEX_FIRST] 60 | 61 | response_obj = obj[cls._FIELD_RESPONSE][cls._INDEX_FIRST] 62 | 63 | if wrapper in response_obj: 64 | return response_obj[wrapper] 65 | 66 | for key in response_obj.keys(): 67 | if key.startswith(wrapper): 68 | return response_obj[key] 69 | 70 | raise KeyError(f"Could not find '{wrapper}' or any subclass in response: {list(response_obj.keys())}") 71 | 72 | @classmethod 73 | def _process_for_id(cls, response_raw: BunqResponseRaw) -> BunqResponse[int]: 74 | from bunq.sdk.model.core.id import Id 75 | 76 | json = response_raw.body_bytes.decode() 77 | obj = converter.json_to_class(dict, json) 78 | id_ = converter.deserialize( 79 | Id, 80 | cls._unwrap_response_single(obj, cls._FIELD_ID) 81 | ) 82 | 83 | return BunqResponse(id_.id_, response_raw.headers) 84 | 85 | @classmethod 86 | def _process_for_uuid(cls, response_raw: BunqResponseRaw) -> BunqResponse[str]: 87 | from bunq.sdk.model.core.uuid import Uuid 88 | 89 | json = response_raw.body_bytes.decode() 90 | obj = converter.json_to_class(dict, json) 91 | uuid = converter.deserialize( 92 | Uuid, 93 | cls._unwrap_response_single(obj, cls._FIELD_UUID) 94 | ) 95 | 96 | return BunqResponse(uuid.uuid, response_raw.headers) 97 | 98 | @classmethod 99 | def _from_json_list(cls, 100 | response_raw: BunqResponseRaw, 101 | wrapper: str = None) -> BunqResponse[List[T]]: 102 | from bunq import Pagination 103 | 104 | json = response_raw.body_bytes.decode() 105 | obj = converter.json_to_class(dict, json) 106 | array = obj[cls._FIELD_RESPONSE] 107 | array_deserialized = [] 108 | 109 | for item in array: 110 | item_unwrapped = item if wrapper is None else item[wrapper] 111 | item_deserialized = converter.deserialize(cls, item_unwrapped) 112 | array_deserialized.append(item_deserialized) 113 | 114 | pagination = None 115 | 116 | if cls._FIELD_PAGINATION in obj: 117 | pagination = converter.deserialize(Pagination, obj[cls._FIELD_PAGINATION]) 118 | 119 | return BunqResponse(array_deserialized, response_raw.headers, pagination) 120 | 121 | @classmethod 122 | def _get_api_context(cls) -> ApiContext: 123 | from bunq.sdk.context.bunq_context import BunqContext 124 | 125 | return BunqContext.api_context() 126 | 127 | @classmethod 128 | def _determine_user_id(cls) -> int: 129 | from bunq.sdk.context.bunq_context import BunqContext 130 | 131 | return BunqContext.user_context().user_id 132 | 133 | @classmethod 134 | def _determine_monetary_account_id(cls, monetary_account_id: int = None) -> int: 135 | from bunq.sdk.context.bunq_context import BunqContext 136 | 137 | if monetary_account_id is None: 138 | return BunqContext.user_context().primary_monetary_account.id_ 139 | 140 | return monetary_account_id 141 | 142 | @classmethod 143 | def _remove_field_for_request(cls, json_str: str) -> str: 144 | return json_str.replace( 145 | cls.__STRING_FORMAT_FIELD_FOR_REQUEST_TWO_UNDERSCORE, 146 | cls.__STRING_FORMAT_EMPTY 147 | ).replace( 148 | cls.__STRING_FORMAT_FIELD_FOR_REQUEST_ONE_UNDERSCORE, 149 | cls.__STRING_FORMAT_EMPTY 150 | ) 151 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/RequestInquiry.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "REQUEST", 5 | "event_type": "REQUEST_INQUIRY_ACCEPTED", 6 | "object": { 7 | "RequestInquiry": { 8 | "id": 21, 9 | "created": "2017-07-16 01:58:10.359035", 10 | "updated": "2017-07-22 23:37:35.925263", 11 | "time_responded": "2017-07-22 23:37:35.889673", 12 | "time_expiry": null, 13 | "monetary_account_id": 43, 14 | "amount_inquired": { 15 | "currency": "EUR", 16 | "value": "8.77" 17 | }, 18 | "amount_responded": null, 19 | "status": "REVOKED", 20 | "description": "Test request inquiry to NL60BUNQ2025103972", 21 | "merchant_reference": null, 22 | "user_alias_created": { 23 | "iban": "NL32BUNQ2025103506", 24 | "is_light": false, 25 | "display_name": "N. Garland", 26 | "avatar": { 27 | "uuid": "e47f5214-2198-4605-886c-9950714476f3", 28 | "image": [ 29 | { 30 | "attachment_public_uuid": "3c7d4d11-d203-4ed4-b471-5b5e24079bf6", 31 | "height": 1024, 32 | "width": 1024, 33 | "content_type": "image\/png" 34 | } 35 | ], 36 | "anchor_uuid": null 37 | }, 38 | "label_user": { 39 | "uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da", 40 | "display_name": "N. Garland", 41 | "country": "NL", 42 | "avatar": { 43 | "uuid": "ad92c001-a4ec-4344-89ad-bf6a6b0e5628", 44 | "image": [ 45 | { 46 | "attachment_public_uuid": "2b7bc30e-d6af-4440-90ba-a801cba3d8d7", 47 | "height": 480, 48 | "width": 480, 49 | "content_type": "image\/jpeg" 50 | } 51 | ], 52 | "anchor_uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da" 53 | }, 54 | "public_nick_name": "Niels (nickname)" 55 | }, 56 | "country": "NL" 57 | }, 58 | "user_alias_revoked": { 59 | "uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da", 60 | "display_name": "N. Garland", 61 | "country": "NL", 62 | "avatar": { 63 | "uuid": "ad92c001-a4ec-4344-89ad-bf6a6b0e5628", 64 | "image": [ 65 | { 66 | "attachment_public_uuid": "2b7bc30e-d6af-4440-90ba-a801cba3d8d7", 67 | "height": 480, 68 | "width": 480, 69 | "content_type": "image\/jpeg" 70 | } 71 | ], 72 | "anchor_uuid": "398e4411-6d98-40b9-bb48-9b33a4cb82da" 73 | }, 74 | "public_nick_name": "Niels (nickname)" 75 | }, 76 | "counterparty_alias": { 77 | "iban": "NL60BUNQ2025103972", 78 | "is_light": false, 79 | "display_name": "Charlie CH Arl (nickname)", 80 | "avatar": { 81 | "uuid": "5188b2d7-5cf9-49ca-ba47-625fee317e79", 82 | "image": [ 83 | { 84 | "attachment_public_uuid": "f427ccd8-81e7-46a7-a9bc-9c52e66f15ae", 85 | "height": 1024, 86 | "width": 1024, 87 | "content_type": "image\/png" 88 | } 89 | ], 90 | "anchor_uuid": null 91 | }, 92 | "label_user": { 93 | "uuid": "3f4973b4-7503-4317-bf4c-bc63e4f6a858", 94 | "display_name": "Charlie CH Arl (nickname)", 95 | "country": "NL", 96 | "avatar": { 97 | "uuid": "cf4eedd8-afea-4374-9505-7ea5af898091", 98 | "image": [ 99 | { 100 | "attachment_public_uuid": "e6f69b18-0aba-4001-98d6-968d464b706c", 101 | "height": 480, 102 | "width": 480, 103 | "content_type": "image\/jpeg" 104 | } 105 | ], 106 | "anchor_uuid": "3f4973b4-7503-4317-bf4c-bc63e4f6a858" 107 | }, 108 | "public_nick_name": "Charlie CH Arl (nickname)" 109 | }, 110 | "country": "NL" 111 | }, 112 | "attachment": [], 113 | "minimum_age": null, 114 | "require_address": null, 115 | "geolocation": null, 116 | "type": "INTERNAL", 117 | "sub_type": "NONE", 118 | "bunqme_share_url": null, 119 | "batch_id": null, 120 | "scheduled_id": null, 121 | "address_billing": null, 122 | "address_shipping": null, 123 | "conversation": null, 124 | "allow_chat": true 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/ScheduledPayment.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "SCHEDULE_RESULT", 5 | "event_type": "SCHEDULE_DEFINITION_PAYMENT_BATCH_CANCELLED", 6 | "object": { 7 | "ScheduledPayment": { 8 | "id": 2, 9 | "created": "2017-07-11 22:56:20.737255", 10 | "updated": "2017-07-11 22:56:20.737255", 11 | "monetary_account_id": 44, 12 | "schedule": { 13 | "time_start": "2017-07-25 22:56:20.571271", 14 | "time_next": "2018-07-25 22:56:20.000000", 15 | "time_end": "2018-07-25 22:56:20.571271", 16 | "recurrence_unit": "YEARLY", 17 | "recurrence_size": 1 18 | }, 19 | "status": "ACTIVE", 20 | "label_schedule_user_created": { 21 | "uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f", 22 | "display_name": "J. Barrett", 23 | "country": "NL", 24 | "avatar": { 25 | "uuid": "3872244f-82c5-4131-82d9-ba878f7e4cb6", 26 | "image": [ 27 | { 28 | "attachment_public_uuid": "abca51a8-af0f-4ab5-b329-645809781615", 29 | "height": 480, 30 | "width": 480, 31 | "content_type": "image\/jpeg" 32 | } 33 | ], 34 | "anchor_uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f" 35 | }, 36 | "public_nick_name": "Jodi (nickname)" 37 | }, 38 | "label_schedule_user_canceled": null, 39 | "payment": { 40 | "amount": { 41 | "currency": "EUR", 42 | "value": "-8.89" 43 | }, 44 | "alias": { 45 | "iban": "NL71BUNQ2025104162", 46 | "is_light": false, 47 | "display_name": "J. Barrett", 48 | "avatar": { 49 | "uuid": "8418f60a-2f89-443e-94ab-5f4cc10a1a1f", 50 | "image": [ 51 | { 52 | "attachment_public_uuid": "c00867c4-8bbe-4dd7-ac72-85798a2969cb", 53 | "height": 1024, 54 | "width": 1024, 55 | "content_type": "image\/png" 56 | } 57 | ], 58 | "anchor_uuid": null 59 | }, 60 | "label_user": { 61 | "uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f", 62 | "display_name": "J. Barrett", 63 | "country": "NL", 64 | "avatar": { 65 | "uuid": "3872244f-82c5-4131-82d9-ba878f7e4cb6", 66 | "image": [ 67 | { 68 | "attachment_public_uuid": "abca51a8-af0f-4ab5-b329-645809781615", 69 | "height": 480, 70 | "width": 480, 71 | "content_type": "image\/jpeg" 72 | } 73 | ], 74 | "anchor_uuid": "407b2959-3df2-4c72-8c44-c90d8f00855f" 75 | }, 76 | "public_nick_name": "Jodi (nickname)" 77 | }, 78 | "country": "NL" 79 | }, 80 | "counterparty_alias": { 81 | "iban": "NL59BUNQ2025104669", 82 | "is_light": false, 83 | "display_name": "Alpha Corp.", 84 | "avatar": { 85 | "uuid": "26789d97-ec34-4fe8-9a71-ca145a23ba7a", 86 | "image": [ 87 | { 88 | "attachment_public_uuid": "3965b302-7a77-4813-8b00-ad3f9b84f439", 89 | "height": 1024, 90 | "width|": 1024, 91 | "content_type": "image\/png" 92 | } 93 | ], 94 | "anchor_uuid": null 95 | }, 96 | "label_user": { 97 | "uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3", 98 | "display_name": "Alpha Corp.", 99 | "country": "NL", 100 | "avatar": { 101 | "uuid": "829fee85-ffa6-473b-ac5b-b464c7b0a3a9", 102 | "image": [ 103 | { 104 | "attachment_public_uuid": "89347f1e-fd96-4c28-b9f5-bc9ec1f4443a", 105 | "height": 640, 106 | "width": 640, 107 | "content_type": "image\/png" 108 | } 109 | ], 110 | "anchor_uuid": "2711664e-885e-4b5c-bf2c-581ba39061d3" 111 | }, 112 | "public_nick_name": "Alpha Corp." 113 | }, 114 | "country": "NL" 115 | }, 116 | "description": "Test payment to NL59BUNQ2025104669", 117 | "merchant_reference": null, 118 | "type": "BUNQ", 119 | "sub_type": "PAYMENT" 120 | } 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/assets/NotificationUrlJsons/MasterCardAction.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotificationUrl": { 3 | "target_url": "nope", 4 | "category": "DRAFT_PAYMENT", 5 | "event_type": "DRAFT_PAYMENT_ACCEPTED", 6 | "object": { 7 | "MasterCardAction": { 8 | "id": 1, 9 | "created": "2017-07-10 06:33:10.497530", 10 | "updated": "2017-07-10 06:33:10.676651", 11 | "monetary_account_id": 31, 12 | "card_id": 2, 13 | "amount_local": { 14 | "currency": "EUR", 15 | "value": "2.00" 16 | }, 17 | "amount_billing": { 18 | "currency": "EUR", 19 | "value": "2.00" 20 | }, 21 | "amount_original_local": { 22 | "currency": "EUR", 23 | "value": "2.00" 24 | }, 25 | "amount_original_billing": { 26 | "currency": "EUR", 27 | "value": "2.00" 28 | }, 29 | "amount_fee": { 30 | "currency": "EUR", 31 | "value": "0.00" 32 | }, 33 | "decision": "COUNTRY_NOT_PERMITTED", 34 | "decision_description": "Card transaction was denied, because the country was not permitted in card settings.", 35 | "decision_description_translated": "Card transaction was denied, because the country was not permitted in card settings.", 36 | "description": "Spar city Sloterdijk Amsterdam, NL", 37 | "authorisation_status": "BLOCKED", 38 | "authorisation_type": "NORMAL_AUTHORISATION", 39 | "city": "Amsterdam", 40 | "alias": { 41 | "iban": "NL88BUNQ2025103565", 42 | "is_light": false, 43 | "display_name": "B.O. Varb", 44 | "avatar": { 45 | "uuid": "0c245e21-e3bc-4bab-8880-7ca6d09691b5", 46 | "image": [ 47 | { 48 | "attachment_public_uuid": "1441cea4-3f24-43d4-9f35-2dfd8ff7bc8c", 49 | "height": 1024, 50 | "width": 1024, 51 | "content_type": "image\/png" 52 | } 53 | ], 54 | "anchor_uuid": null 55 | }, 56 | "label_user": { 57 | "uuid": "353f4064-96bd-49d7-ac69-f87513726c80", 58 | "display_name": "B.O. Varb", 59 | "country": "NL", 60 | "avatar": { 61 | "uuid": "ec2dc709-c8dd-4868-b3e7-601338d88881", 62 | "image": [ 63 | { 64 | "attachment_public_uuid": "7444998c-ff1d-4708-afe3-7b56e33c6f89", 65 | "height": 480, 66 | "width": 480, 67 | "content_type": "image\/jpeg" 68 | } 69 | ], 70 | "anchor_uuid": "353f4064-96bd-49d7-ac69-f87513726c80" 71 | }, 72 | "public_nick_name": "Bravo O (nickname)" 73 | }, 74 | "country": "NL" 75 | }, 76 | "counterparty_alias": { 77 | "iban": null, 78 | "is_light": null, 79 | "display_name": "Spar city Sloterdijk", 80 | "avatar": { 81 | "uuid": "ed91da5e-6100-42ab-a5b1-bcbeab62cb96", 82 | "image": [ 83 | { 84 | "attachment_public_uuid": "58b3df6c-bfa4-4a42-91b3-db0e7f2ac64e", 85 | "height": 640, 86 | "width": 640, 87 | "content_type": "image\/jpeg" 88 | } 89 | ], 90 | "anchor_uuid": null 91 | }, 92 | "label_user": { 93 | "uuid": null, 94 | "display_name": "Spar city Sloterdijk", 95 | "country": "NL", 96 | "avatar": null, 97 | "public_nick_name": "Spar city Sloterdijk" 98 | }, 99 | "country": "NL", 100 | "merchant_category_code": "5411" 101 | }, 102 | "label_card": { 103 | "uuid": "c3b99cac-3677-4311-841c-4a7bba7397f2", 104 | "type": "MAESTRO", 105 | "second_line": "Pets", 106 | "expiry_date": "2021-07-31", 107 | "status": "ACTIVE", 108 | "label_user": { 109 | "uuid": "353f4064-96bd-49d7-ac69-f87513726c80", 110 | "display_name": "B.O. Varb", 111 | "country": "NL", 112 | "avatar": { 113 | "uuid": "ec2dc709-c8dd-4868-b3e7-601338d88881", 114 | "image": [ 115 | { 116 | "attachment_public_uuid": "7444998c-ff1d-4708-afe3-7b56e33c6f89", 117 | "height": 480, 118 | "width": 480, 119 | "content_type": "image\/jpeg" 120 | } 121 | ], 122 | "anchor_uuid": "353f4064-96bd-49d7-ac69-f87513726c80" 123 | }, 124 | "public_nick_name": "Bravo O (nickname)" 125 | } 126 | }, 127 | "token_status": null, 128 | "reservation_expiry_time": null, 129 | "applied_limit": null, 130 | "conversation": null, 131 | "allow_chat": true, 132 | "pan_entry_mode_user": "ICC", 133 | "eligible_whitelist_id": null 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/model/generated/object/test_notification_url.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Optional 4 | 5 | from bunq.sdk.model.core.bunq_model import BunqModel 6 | from bunq.sdk.model.generated import endpoint 7 | from bunq.sdk.model.generated import object_ 8 | from bunq.sdk.model.generated.endpoint import PaymentApiObject, BunqMeTabApiObject, DraftPaymentApiObject, MasterCardActionApiObject, MonetaryAccountApiObject, \ 9 | MonetaryAccountBankApiObject, PaymentBatchApiObject, RequestInquiryApiObject, RequestResponseApiObject, SchedulePaymentApiObject, ScheduleInstanceApiObject, \ 10 | ShareInviteMonetaryAccountInquiryApiObject, ShareInviteMonetaryAccountResponseApiObject 11 | from bunq.sdk.model.generated.object_ import NotificationUrlObject 12 | from tests import bunq_test 13 | 14 | 15 | class TestNotificationUrl(bunq_test.BunqSdkTestCase): 16 | # Getter string constants 17 | _GETTER_PAYMENT = 'Payment' 18 | _GETTER_BUNQ_ME_TAB = 'BunqMeTab' 19 | _GETTER_CHAT_MESSAGE_ANNOUNCEMENT = 'ChatMessageAnnouncement' 20 | _GETTER_CHAT_MESSAGE = 'ChatMessage' 21 | _GETTER_DRAFT_PAYMENT = 'DraftPayment' 22 | _GETTER_MASTER_CARD_ACTION = 'MasterCardAction' 23 | _GETTER_MONETARY_ACCOUNT_BANK = 'MonetaryAccountBank' 24 | _GETTER_MONETARY_ACCOUNT = 'MonetaryAccount' 25 | _GETTER_PAYMENT_BATCH = 'PaymentBatch' 26 | _GETTER_REQUEST_INQUIRY = 'RequestInquiry' 27 | _GETTER_REQUEST_RESPONSE = 'RequestResponse' 28 | _GETTER_SCHEDULE_PAYMENT = 'ScheduledPayment' 29 | _GETTER_SCHEDULE_INSTANCE = 'ScheduledInstance' 30 | _GETTER_SHARE_INVITE_BANK_INQUIRY = 'ShareInviteBankInquiry' 31 | _GETTER_SHARE_INVITE_BANK_RESPONSE = 'ShareInviteBankResponse' 32 | 33 | # Model json paths constants. 34 | BASE_PATH_JSON_MODEL = '../../../assets/NotificationUrlJsons' 35 | JSON_PATH_MUTATION_MODEL = BASE_PATH_JSON_MODEL + '/Mutation.json' 36 | JSON_PATH_BUNQ_ME_TAB_MODEL = BASE_PATH_JSON_MODEL + '/BunqMeTab.json' 37 | JSON_PATH_CHAT_MESSAGE_ANNOUNCEMENT_MODEL = BASE_PATH_JSON_MODEL + '/ChatMessageAnnouncement.json' 38 | JSON_PATH_DRAFT_PAYMENT_MODEL = BASE_PATH_JSON_MODEL + '/DraftPayment.json' 39 | JSON_PATH_MASTER_CARD_ACTION_MODEL = BASE_PATH_JSON_MODEL + '/MasterCardAction.json' 40 | JSON_PATH_MONETARY_ACCOUNT_BANK_MODEL = BASE_PATH_JSON_MODEL + '/MonetaryAccountBank.json' 41 | JSON_PATH_PAYMENT_BATCH_MODEL = BASE_PATH_JSON_MODEL + '/PaymentBatch.json' 42 | JSON_PATH_REQUEST_INQUIRY_MODEL = BASE_PATH_JSON_MODEL + '/RequestInquiry.json' 43 | JSON_PATH_REQUEST_RESPONSE_MODEL = BASE_PATH_JSON_MODEL + '/RequestResponse.json' 44 | JSON_PATH_SCHEDULE_PAYMENT_MODEL = BASE_PATH_JSON_MODEL + '/ScheduledPayment.json' 45 | JSON_PATH_SCHEDULE_INSTANCE_MODEL = BASE_PATH_JSON_MODEL + '/ScheduledInstance.json' 46 | JSON_PATH_SHARE_INVITE_BANK_INQUIRY_MODEL = BASE_PATH_JSON_MODEL + '/ShareInviteBankInquiry.json' 47 | JSON_PATH_SHARE_INVITE_BANK_RESPONSE_MODEL = BASE_PATH_JSON_MODEL + '/ShareInviteBankResponse.json' 48 | 49 | # Model root key. 50 | _KEY_NOTIFICATION_URL_MODEL = 'NotificationUrl' 51 | 52 | # Model modules constants. 53 | _MODEL_MODULES = [ 54 | object_, 55 | endpoint, 56 | ] 57 | 58 | @classmethod 59 | def setUpClass(cls): 60 | pass 61 | 62 | def setUp(self): 63 | pass 64 | 65 | # File mode constants. 66 | _FILE_MODE_READ = 'r' 67 | 68 | def execute_notification_url_test(self, 69 | file_path: str, 70 | class_name: str, 71 | getter_name: str, 72 | sub_class_expected_object_name: str = None, 73 | sub_class_getter_name: str = None) -> None: 74 | notification_url = self.get_notification_url(file_path) 75 | self.assertIsNotNone(notification_url) 76 | self.assertIsNotNone(notification_url.object_) 77 | 78 | expected_model = getattr(notification_url.object_, getter_name) 79 | referenced_model = notification_url.object_.get_referenced_object() 80 | 81 | self.assertIsNotNone(expected_model) 82 | self.assertIsNotNone(referenced_model) 83 | self.assertTrue(self.is_model_reference(referenced_model, class_name)) 84 | 85 | if sub_class_expected_object_name is not None: 86 | sub_class_model = getattr(referenced_model, sub_class_getter_name) 87 | 88 | self.assertIsNotNone(sub_class_model) 89 | self.assertTrue(isinstance(sub_class_model, self.get_model_type_or_none(sub_class_expected_object_name))) 90 | 91 | @classmethod 92 | def is_model_reference(cls, referenced_model: BunqModel, class_name: str) -> bool: 93 | model_type = cls.get_model_type_or_none(class_name) 94 | 95 | if model_type is None: 96 | return False 97 | 98 | return isinstance(referenced_model, model_type) 99 | 100 | @classmethod 101 | def get_model_type_or_none(cls, class_name: str) -> Optional[type]: 102 | for module_ in cls._MODEL_MODULES: 103 | if hasattr(module_, class_name): 104 | return getattr(module_, class_name) 105 | 106 | return None 107 | 108 | def get_notification_url(self, file_path: str) -> NotificationUrlObject: 109 | base_path = os.path.dirname(__file__) 110 | file_path = os.path.abspath(os.path.join(base_path, file_path)) 111 | 112 | with open(file_path, self._FILE_MODE_READ) as f: 113 | json_string = f.read() 114 | json_object = json.loads(json_string) 115 | json_string = json.dumps(json_object[self._KEY_NOTIFICATION_URL_MODEL]) 116 | 117 | self.assertTrue(self._KEY_NOTIFICATION_URL_MODEL in json_object) 118 | 119 | return NotificationUrlObject.from_json(json_string) 120 | 121 | def test_mutation_model(self): 122 | self.execute_notification_url_test( 123 | self.JSON_PATH_MUTATION_MODEL, 124 | PaymentApiObject.__name__, 125 | self._GETTER_PAYMENT 126 | ) 127 | 128 | def test_bunq_me_tab_model(self): 129 | self.execute_notification_url_test( 130 | self.JSON_PATH_BUNQ_ME_TAB_MODEL, 131 | BunqMeTabApiObject.__name__, 132 | self._GETTER_BUNQ_ME_TAB 133 | ) 134 | 135 | def test_draft_payment_model(self): 136 | self.execute_notification_url_test( 137 | self.JSON_PATH_DRAFT_PAYMENT_MODEL, 138 | DraftPaymentApiObject.__name__, 139 | self._GETTER_DRAFT_PAYMENT 140 | ) 141 | 142 | def test_mastercard_action(self): 143 | self.execute_notification_url_test( 144 | self.JSON_PATH_MASTER_CARD_ACTION_MODEL, 145 | MasterCardActionApiObject.__name__, 146 | self._GETTER_MASTER_CARD_ACTION 147 | ) 148 | 149 | def test_monetary_account_bank_model(self): 150 | self.execute_notification_url_test( 151 | self.JSON_PATH_MONETARY_ACCOUNT_BANK_MODEL, 152 | MonetaryAccountApiObject.__name__, 153 | self._GETTER_MONETARY_ACCOUNT, 154 | MonetaryAccountBankApiObject.__name__, 155 | self._GETTER_MONETARY_ACCOUNT_BANK 156 | ) 157 | 158 | def test_payment_batch_model(self): 159 | self.execute_notification_url_test( 160 | self.JSON_PATH_PAYMENT_BATCH_MODEL, 161 | PaymentBatchApiObject.__name__, 162 | self._GETTER_PAYMENT_BATCH 163 | ) 164 | 165 | def test_request_inquiry_model(self): 166 | self.execute_notification_url_test( 167 | self.JSON_PATH_REQUEST_INQUIRY_MODEL, 168 | RequestInquiryApiObject.__name__, 169 | self._GETTER_REQUEST_INQUIRY 170 | ) 171 | 172 | def test_request_response_model(self): 173 | self.execute_notification_url_test( 174 | self.JSON_PATH_REQUEST_RESPONSE_MODEL, 175 | RequestResponseApiObject.__name__, 176 | self._GETTER_REQUEST_RESPONSE 177 | ) 178 | 179 | def test_scheduled_payment_model(self): 180 | self.execute_notification_url_test( 181 | self.JSON_PATH_SCHEDULE_PAYMENT_MODEL, 182 | SchedulePaymentApiObject.__name__, 183 | self._GETTER_SCHEDULE_PAYMENT 184 | ) 185 | 186 | def test_scheduled_instance_model(self): 187 | self.execute_notification_url_test( 188 | self.JSON_PATH_SCHEDULE_INSTANCE_MODEL, 189 | ScheduleInstanceApiObject.__name__, 190 | self._GETTER_SCHEDULE_INSTANCE 191 | ) 192 | 193 | def test_share_invite_bank_inquiry(self): 194 | self.execute_notification_url_test( 195 | self.JSON_PATH_SHARE_INVITE_BANK_INQUIRY_MODEL, 196 | ShareInviteMonetaryAccountInquiryApiObject.__name__, 197 | self._GETTER_SHARE_INVITE_BANK_INQUIRY 198 | ) 199 | 200 | def test_share_invite_bank_response(self): 201 | self.execute_notification_url_test( 202 | self.JSON_PATH_SHARE_INVITE_BANK_RESPONSE_MODEL, 203 | ShareInviteMonetaryAccountResponseApiObject.__name__, 204 | self._GETTER_SHARE_INVITE_BANK_RESPONSE 205 | ) 206 | -------------------------------------------------------------------------------- /bunq/sdk/security/security.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import hmac 5 | import re 6 | import typing 7 | from base64 import b64encode 8 | from hashlib import sha1 9 | from typing import Dict, List 10 | 11 | from Cryptodome import Cipher 12 | from Cryptodome import Random 13 | from Cryptodome.Cipher import AES 14 | from Cryptodome.Cipher import PKCS1_v1_5 as PKCS1_v1_5_Cipher 15 | from Cryptodome.Hash import SHA256 16 | from Cryptodome.PublicKey import RSA 17 | from Cryptodome.PublicKey.RSA import RsaKey 18 | from Cryptodome.Signature import PKCS1_v1_5 19 | from requests.structures import CaseInsensitiveDict 20 | 21 | from bunq.sdk.exception.bunq_exception import BunqException 22 | 23 | if typing.TYPE_CHECKING: 24 | from bunq.sdk.context.api_context import ApiContext 25 | 26 | # Error constants. 27 | _ERROR_INVALID_SIGNATURE = 'Could not validate response signature.' 28 | 29 | # Size of private RSA key to generate 30 | _RSA_KEY_SIZE = 2048 31 | 32 | # Constants to perform re-encoding of the public key 33 | _PATTERN_RSA = r' RSA ' 34 | _REPLACEMENT_RSA = ' ' 35 | 36 | # Number of PKCS to use for exporting the private key 37 | _PKCS_NUMBER_PRIVATE_KEY = 8 38 | 39 | # Constants to generate request head string 40 | _FORMAT_METHOD_AND_ENDPOINT = '{} /v1/{}\n' 41 | _FORMAT_HEADER_STRING = '{}: {}\n' 42 | _DELIMITER_NEWLINE = '\n' 43 | 44 | # Constants for building header string to sign 45 | _PATTERN_HEADER_PREFIX_BUNQ = r'X-Bunq-' 46 | _HEADER_CACHE_CONTROL = 'Cache-Control' 47 | _HEADER_USER_AGENT = 'User-Agent' 48 | 49 | # Constants for AES encryption 50 | _AES_KEY_SIZE = 32 51 | _BLOCK_SIZE = 16 52 | 53 | # Regex constants 54 | _REGEX_FOR_LOWERCASE_HEADERS = r'(-[a-z])' 55 | 56 | # Encryption-specific headers 57 | _HEADER_CLIENT_ENCRYPTION_KEY = 'X-Bunq-Client-Encryption-Key' 58 | _HEADER_CLIENT_ENCRYPTION_IV = 'X-Bunq-Client-Encryption-Iv' 59 | _HEADER_CLIENT_ENCRYPTION_HMAC = 'X-Bunq-Client-Encryption-Hmac' 60 | _HEADER_SERVER_SIGNATURE = 'X-Bunq-Server-Signature' 61 | 62 | 63 | def generate_rsa_private_key() -> RsaKey: 64 | return RSA.generate(_RSA_KEY_SIZE) 65 | 66 | 67 | def public_key_to_string(public_key: RsaKey) -> str: 68 | return re.sub( 69 | _PATTERN_RSA, 70 | _REPLACEMENT_RSA, 71 | public_key.exportKey().decode() 72 | ) 73 | 74 | 75 | def private_key_to_string(private_key: RsaKey) -> str: 76 | return private_key.exportKey(pkcs=_PKCS_NUMBER_PRIVATE_KEY).decode() 77 | 78 | 79 | def rsa_key_from_string(string: str) -> RsaKey: 80 | return RSA.import_key(string) 81 | 82 | 83 | def sign_request(private_key: RsaKey, 84 | body_bytes: bytes) -> str: 85 | signer = PKCS1_v1_5.new(private_key) 86 | digest = SHA256.new() 87 | digest.update(body_bytes) 88 | sign = signer.sign(digest) 89 | 90 | return b64encode(sign) 91 | 92 | 93 | def _generate_request_head_bytes(method: str, 94 | endpoint: str, 95 | headers: Dict[str, str]) -> bytes: 96 | head_string = _FORMAT_METHOD_AND_ENDPOINT.format(method, endpoint) 97 | header_tuples = sorted((k, headers[k]) for k in headers) 98 | 99 | for name, value in header_tuples: 100 | if _should_sign_request_header(name): 101 | head_string += _FORMAT_HEADER_STRING.format(name, value) 102 | 103 | return (head_string + _DELIMITER_NEWLINE).encode() 104 | 105 | 106 | def _should_sign_request_header(header_name: str) -> bool: 107 | if header_name in {_HEADER_USER_AGENT, _HEADER_CACHE_CONTROL}: 108 | return True 109 | 110 | if re.match(_PATTERN_HEADER_PREFIX_BUNQ, header_name): 111 | return True 112 | 113 | return False 114 | 115 | 116 | def generate_signature(string_to_sign: str, key_pair: RsaKey) -> str: 117 | bytes_to_sign = string_to_sign.encode() 118 | signer = PKCS1_v1_5.new(key_pair) 119 | digest = SHA256.new() 120 | digest.update(bytes_to_sign) 121 | sign = signer.sign(digest) 122 | 123 | return b64encode(sign) 124 | 125 | 126 | def encrypt(api_context: ApiContext, 127 | request_bytes: bytes, 128 | custom_headers: Dict[str, str]) -> bytes: 129 | key = Random.get_random_bytes(_AES_KEY_SIZE) 130 | iv = Random.get_random_bytes(_BLOCK_SIZE) 131 | _add_header_client_encryption_key(api_context, key, custom_headers) 132 | _add_header_client_encryption_iv(iv, custom_headers) 133 | request_bytes = _encrypt_request_bytes(request_bytes, key, iv) 134 | _add_header_client_encryption_hmac(request_bytes, key, iv, custom_headers) 135 | 136 | return request_bytes 137 | 138 | 139 | def _add_header_client_encryption_key(api_context: ApiContext, 140 | key: bytes, 141 | custom_headers: Dict[str, str]) -> None: 142 | public_key_server = api_context.installation_context.public_key_server 143 | key_cipher = PKCS1_v1_5_Cipher.new(public_key_server) 144 | key_encrypted = key_cipher.encrypt(key) 145 | key_encrypted_base64 = base64.b64encode(key_encrypted).decode() 146 | custom_headers[_HEADER_CLIENT_ENCRYPTION_KEY] = key_encrypted_base64 147 | 148 | 149 | def _add_header_client_encryption_iv(iv: bytes, 150 | custom_headers: Dict[str, str]) -> None: 151 | custom_headers[_HEADER_CLIENT_ENCRYPTION_IV] = base64.b64encode(iv).decode() 152 | 153 | 154 | def _encrypt_request_bytes(request_bytes: bytes, 155 | key: bytes, 156 | iv: bytes) -> bytes: 157 | cipher = Cipher.AES.new(key, Cipher.AES.MODE_CBC, iv) 158 | request_bytes_padded = _pad_bytes(request_bytes) 159 | 160 | return cipher.encrypt(request_bytes_padded) 161 | 162 | 163 | def _pad_bytes(request_bytes: bytes) -> bytes: 164 | padding_length = (_BLOCK_SIZE - len(request_bytes) % _BLOCK_SIZE) 165 | padding_character = bytes(bytearray([padding_length])) 166 | 167 | return request_bytes + padding_character * padding_length 168 | 169 | 170 | def _add_header_client_encryption_hmac(request_bytes: bytes, 171 | key: bytes, 172 | iv: bytes, 173 | custom_headers: Dict[str, str]) -> None: 174 | hashed = hmac.new(key, iv + request_bytes, sha1) 175 | hashed_base64 = base64.b64encode(hashed.digest()).decode() 176 | custom_headers[_HEADER_CLIENT_ENCRYPTION_HMAC] = hashed_base64 177 | 178 | 179 | def validate_response(public_key_server: RsaKey, 180 | status_code: int, 181 | body_bytes: bytes, 182 | headers: CaseInsensitiveDict[str, str]) -> None: 183 | if is_valid_response_header_with_body(public_key_server, status_code, body_bytes, headers): 184 | return 185 | elif is_valid_response_body(public_key_server, body_bytes, headers): 186 | return 187 | else: 188 | raise BunqException(_ERROR_INVALID_SIGNATURE) 189 | 190 | 191 | def is_valid_response_header_with_body(public_key_server: RsaKey, 192 | status_code: int, 193 | body_bytes: bytes, 194 | headers: CaseInsensitiveDict[str, str]) -> bool: 195 | head_bytes = _generate_response_head_bytes(status_code, headers) 196 | bytes_signed = head_bytes + body_bytes 197 | signer = PKCS1_v1_5.pkcs1_15.new(public_key_server) 198 | digest = SHA256.new() 199 | digest.update(bytes_signed) 200 | 201 | try: 202 | signer.verify(digest, base64.b64decode(headers[_HEADER_SERVER_SIGNATURE])) 203 | 204 | return True 205 | except ValueError: 206 | return False 207 | 208 | 209 | def is_valid_response_body(public_key_server: RsaKey, 210 | body_bytes: bytes, 211 | headers: CaseInsensitiveDict[str, str]) -> bool: 212 | signer = PKCS1_v1_5.pkcs1_15.new(public_key_server) 213 | digest = SHA256.new() 214 | digest.update(body_bytes) 215 | 216 | try: 217 | signer.verify(digest, base64.b64decode(headers[_HEADER_SERVER_SIGNATURE])) 218 | 219 | return True 220 | except ValueError: 221 | return False 222 | 223 | 224 | def _generate_response_head_bytes(status_code: int, 225 | headers: CaseInsensitiveDict[str, str]) -> bytes: 226 | head_string = str(status_code) + _DELIMITER_NEWLINE 227 | header_tuples = sorted((k, headers[k]) for k in headers) 228 | 229 | for name, value in header_tuples: 230 | name = _get_header_correctly_cased(name) 231 | 232 | if _should_sign_response_header(name): 233 | head_string += _FORMAT_HEADER_STRING.format(name, value) 234 | 235 | return (head_string + _DELIMITER_NEWLINE).encode() 236 | 237 | 238 | def _get_header_correctly_cased(header_name: str) -> str: 239 | header_name = header_name.capitalize() 240 | 241 | matches = re.findall(_REGEX_FOR_LOWERCASE_HEADERS, header_name) 242 | 243 | for match in matches: 244 | header_name = (re.sub(match, match.upper(), header_name)) 245 | 246 | return header_name 247 | 248 | 249 | def _should_sign_response_header(header_name: str) -> bool: 250 | if header_name == _HEADER_SERVER_SIGNATURE: 251 | return False 252 | 253 | if re.match(_PATTERN_HEADER_PREFIX_BUNQ, header_name): 254 | return True 255 | 256 | return False 257 | 258 | 259 | def get_certificate_chain_string(all_chain_certificate: List[str]): 260 | return _DELIMITER_NEWLINE.join(all_chain_certificate) 261 | --------------------------------------------------------------------------------