diff --git a/README.md b/README.md index 9b21172..71add60 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ print(api_client.get_balance()) This is going to use the same mechanism to load configuration as the CLI tool, to specify your own configuration you can use it as: ```python +import uuid + from n26.api import Api from n26.config import Config @@ -135,6 +137,8 @@ conf.USERNAME.value = "john.doe@example.com" conf.PASSWORD.value = "$upersecret" conf.LOGIN_DATA_STORE_PATH.value = None conf.MFA_TYPE.value = "app" +conf.DEVICE_TOKEN.value = uuid.uuid4() + conf.validate() api_client = Api(conf) diff --git a/example.py b/example.py new file mode 100644 index 0000000..ea99500 --- /dev/null +++ b/example.py @@ -0,0 +1,25 @@ +# logger = logging.getLogger("n26") +# logger.setLevel(logging.DEBUG) +# ch = logging.StreamHandler() +# ch.setLevel(logging.DEBUG) +# formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +# ch.setFormatter(formatter) +# logger.addHandler(ch) + +import json +import uuid + +from n26.api import Api +from n26.config import Config + +config = Config(validate=False) +config.USERNAME.value = "john.doe@example.com" +config.PASSWORD.value = "$upersecret" +config.LOGIN_DATA_STORE_PATH.value = None +config.DEVICE_TOKEN.value = uuid.uuid4() +config.validate() + +api = Api() +statuses = api.get_balance() +json_data = json.dumps(statuses) +print(statuses) diff --git a/n26/api.py b/n26/api.py index 213ad31..b373cee 100644 --- a/n26/api.py +++ b/n26/api.py @@ -106,13 +106,18 @@ def _write_token_file(token_data: dict, path: str): file.write(json.dumps(token_data, indent=2)) file.truncate() - # IDEA: @get_token decorator - def get_account_info(self) -> dict: + def get_account_info_old(self) -> dict: """ Retrieves basic account information """ return self._do_request(GET, BASE_URL_DE + '/api/me') + def get_account_info(self) -> dict: + """ + Retrieves basic account information + """ + return self._do_request(GET, BASE_URL_DE + f'/api/account/primary') + def get_account_statuses(self) -> dict: """ Retrieves additional account information @@ -183,45 +188,128 @@ def get_standing_orders(self) -> dict: """ return self._do_request(GET, BASE_URL_DE + '/api/transactions/so') - def get_transactions(self, from_time: int = None, to_time: int = None, limit: int = 20, pending: bool = None, - categories: str = None, text_filter: str = None, last_id: str = None) -> dict: + def get_transactions( + self, + from_time: int = None, to_time: int = None, + direction: str = None, + limit: int = 20, + text_filter: str = None, + pagination_key: str = None + ) -> dict: """ Get a list of transactions. - Note that some parameters can not be combined in a single request (like text_filter and pending) and - will result in a bad request (400) error. - - :param from_time: earliest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET - :param to_time: latest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET - :param limit: Limit the number of transactions to return to the given amount - default 20 as the n26 API returns - only the last 20 transactions by default - :param pending: show only pending transactions - :param categories: Comma separated list of category IDs - :param text_filter: Query string to search for - :param last_id: ?? + :param from_time: the earliest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET + :param to_time: the latest transaction time as a Timestamp > 0 - milliseconds since 1970 in CET + :param direction: INCOMING or OUTGOING + :param limit: limits the number of transactions to return to the given amount - default 20 as the n26 API returns + only the last 20 transactions by default, can be 800 at max + :param text_filter: Query string to search for, can also contain tags, merchants and categoriess + :param pagination_key: pass this from the last response to get the next badge of results :return: list of transactions """ - if pending and limit: - # pending does not support limit - limit = None - - return self._do_request(GET, BASE_URL_DE + '/api/smrt/transactions', { - 'from': from_time, - 'to': to_time, - 'limit': limit, - 'pending': pending, - 'categories': categories, - 'textFilter': text_filter, - 'lastId': last_id - }) + filters = [] + if from_time is not None or to_time is not None: + filters.append( + { + "criteria": { + "from": from_time, + "to": to_time, + }, + "type": "DATE_RANGE" + } + ) + + if direction is not None: + filters.append( + { + "criteria": { + "value": direction + }, + "type": "DIRECTION" + } + ) + + # Suggestions + # => Bad Request + # result = self._do_request( + # method=POST, + # url=BASE_URL_DE + f'/api/feed/accounts/{account_id}/transactions/search/suggestions', + # headers={ + # "device-token": self.config.DEVICE_TOKEN.value + # } + # ) + + account_info = self.get_account_info() + account_id = account_info["accountId"] + + result = self._do_request( + method=POST, + url=BASE_URL_DE + f'/api/feed/accounts/{account_id}/transactions/search', + json={ + "filterCriteria": { + "filters": filters + }, + "searchText": text_filter, + "paginationKey": pagination_key, + }, + params={ + "limit": limit, + } + ) + + return result - def get_transactions_limited(self, limit: int = 5) -> dict: - import warnings - warnings.warn( - "get_transactions_limited is deprecated, use get_transactions(limit=5) instead", - DeprecationWarning + # TODO: + def get_insights(self) -> dict: + """ + Retrieves the Insights dashboard data + """ + return self._do_request( + method=GET, + url=BASE_URL_DE + f'/api/insights/dashboard', + ) + + # TODO: + def get_balance_overview(self) -> dict: + """ + Retrieves balance data + """ + return self._do_request( + method=GET, + url=BASE_URL_DE + f'/api/insights/balance-overview?page=1', + ) + + # TODO: + def get_recurring_payments(self) -> dict: + """ + Retrieves recurring payments + """ + return self._do_request( + method=GET, + url=BASE_URL_DE + f'/api/insights/recurring-payments', + ) + + # TODO: + def get_scheduled_payments(self) -> dict: + """ + TODO + """ + return self._do_request( + method=GET, + url=BASE_URL_DE + f'/api/scheduled-payments/overview', + ) + + # TODO: + def get_expenses_categories(self, category: str, period: str) -> dict: + """ + :param category: id of the category, f.ex. "bars_and_restaurants" + :param period: time period to fetch, f.ex. 2023-02 + """ + return self._do_request( + method=GET, + url=BASE_URL_DE + f'/api/insights/balance-overview/expenses/{category}?period=2023-02', ) - return self.get_transactions(limit=limit) def get_balance_statement(self, statement_url: str): """ @@ -294,7 +382,15 @@ def _do_request(self, method: str = GET, url: str = "/", params: dict = None, :return: the response parsed as a json """ access_token = self.get_token() - _headers = {'Authorization': 'Bearer {}'.format(access_token)} + _headers = { + 'Authorization': 'Bearer {}'.format(access_token), + "n26-timezone-identifier": "Europe/Paris", + "x-n26-platform": "android", + "x-n26-app-version": "3.73", + "n26-app-build-number": "202203000", + "content-type": "application/json; charset=utf-8", + "device-token": self.config.DEVICE_TOKEN.value, + } if headers is not None: _headers.update(headers) diff --git a/tests/api_responses/transactions2.json b/tests/api_responses/transactions2.json new file mode 100644 index 0000000..707313b --- /dev/null +++ b/tests/api_responses/transactions2.json @@ -0,0 +1,91 @@ +{ + "tags": [], + "results": [ + { + "id": "12345678-1234-abcd-abcd-1234567890ab", + "accountId": "12345678-1234-abcd-abcd-1234567890ab", + "title": "Transaction Title", + "tintedSubtitle": { + "elements": [ + { + "value": "1 Feb" + } + ] + }, + "amount": -100.0, + "amountStyle": "NONE", + "currency": "EUR", + "timestamp": 1675212875923, + "type": "TRANSACTION", + "category": "micro-v2-household-utilities", + "icon": { + "type": "CATEGORY", + "url": "https://cdn.number26.de/feed/transaction-details/categories/light/household_and_utilities.png", + "dark-url": "https://cdn.number26.de/feed/transaction-details/categories/dark/household_and_utilities.png", + "darkUrl": "https://cdn.number26.de/feed/transaction-details/categories/dark/household_and_utilities.png" + }, + "balance": { + "title": "", + "amount": 0, + "currency": "EUR" + }, + "deeplink": "number26://main/detail/12345678-1234-abcd-abcd-1234567890ab?accountId=12345678-1234-abcd-abcd-1234567890ab&externalId=12345678-1234-abcd-abcd-1234567890ab&source=TX_SEARCH", + "gestures": { + "swipeLeft": { + "description": "Pay back from a Space", + "deeplink": "number26://spaces/transfer?destinationId=12345678-1234-abcd-abcd-1234567890ab&amount=100.00&initialReferenceText=Iris%20%26%20Markus%20Gemeinschaftskonto%20Strom&sourceTxId=12345678-1234-abcd-abcd-1234567890ab", + "tracking": { + "action": "feed.gesture_swipe.left", + "category": "engagement", + "property": "payback" + } + } + }, + "externalId": "12345678-1234-abcd-abcd-1234567890ab", + "linkId": "12345678-1234-abcd-abcd-1234567890ab", + "subtitle": { + "template": "" + } + } + ], + "paginationKey": "abJlbmREYXRlIjoxNjc1MjczMTM5Ljc3MjAwMDAwMCwidG90YWxBbW91bnQiOi0xMzIuNzAsInRyYW5zYWN0aW9uQ291bnQiOjIsInRyYW5zYWN0aW9uc1BhZ2luYXRpb25LZXkiOiJleUpzWVhOMFJYaDBaWEp1WVd4SlpDSTZJbVptTm1VeVpUazVMV0V4WTJFdE1URmxaQzA1WTJZMkxUUmtOR05tTkdNd05UZzVOU0o5In0=", + "summary": { + "totalAmount": { + "value": "-€132.70", + "appearance": "STANDARD" + }, + "subtitle": "2/1/23 - 2/1/23", + "title": "2 Transactions" + }, + "filters": { + "filterList": [ + { + "type": "DATE_RANGE", + "title": "Date range", + "maxRangeInMonths": 6, + "violationMessage": "Please select a date within a 6 month range" + }, + { + "type": "DIRECTION", + "title": "Transaction type", + "value": [ + { + "title": "Both incoming & outgoing", + "type": "INCOMING_AND_OUTGOING" + }, + { + "title": "Incoming only", + "type": "INCOMING" + }, + { + "title": "Outgoing only", + "type": "OUTGOING" + } + ] + } + ] + }, + "searchSuggestions": { + "suggestions": [] + } +}