From 9f8b2cd9b0e7bd45b2478b7570b7ca4c43a2a293 Mon Sep 17 00:00:00 2001 From: James Addison <55152140+jayaddison@users.noreply.github.com> Date: Thu, 21 Mar 2024 21:43:07 +0000 Subject: [PATCH] Migration: deprecate borderless-accounts endpoint, and provide account-detail and balance-statement endpoints. (#25) --- pywisetransfer/__init__.py | 6 ++ pywisetransfer/account_details.py | 19 ++++ pywisetransfer/balance_statements.py | 45 +++++++++ pywisetransfer/balances.py | 34 +++++++ pywisetransfer/borderless_account.py | 7 ++ pywisetransfer/deprecation.py | 32 ++++++ test/test_deprecation.py | 59 ++++++++++++ test/test_sca.py | 139 ++++++++++++++++++--------- 8 files changed, 293 insertions(+), 48 deletions(-) create mode 100644 pywisetransfer/account_details.py create mode 100644 pywisetransfer/balance_statements.py create mode 100644 pywisetransfer/balances.py create mode 100644 pywisetransfer/deprecation.py create mode 100644 test/test_deprecation.py diff --git a/pywisetransfer/__init__.py b/pywisetransfer/__init__.py index 1e5e641..8d83961 100644 --- a/pywisetransfer/__init__.py +++ b/pywisetransfer/__init__.py @@ -3,11 +3,17 @@ class Client: def add_resources(self) -> None: + from pywisetransfer.account_details import AccountDetails + from pywisetransfer.balance_statements import BalanceStatements + from pywisetransfer.balances import Balances from pywisetransfer.borderless_account import BorderlessAccount from pywisetransfer.profile import Profile from pywisetransfer.subscription import Subscription from pywisetransfer.user import User + self.account_details = AccountDetails(client=self) + self.balance_statements = BalanceStatements(client=self) + self.balances = Balances(client=self) self.borderless_accounts = BorderlessAccount(client=self) self.profiles = Profile(client=self) self.subscriptions = Subscription(client=self) diff --git a/pywisetransfer/account_details.py b/pywisetransfer/account_details.py new file mode 100644 index 0000000..5f6aa26 --- /dev/null +++ b/pywisetransfer/account_details.py @@ -0,0 +1,19 @@ +from typing import Any + +from apiron import JsonEndpoint +from munch import munchify + +from pywisetransfer import Client +from pywisetransfer.base import Base + + +class AccountDetailsService(Base): + list = JsonEndpoint(path="/v1/profiles/{profile_id}/account-details") + + +class AccountDetails: + def __init__(self, client: Client): + self.service = AccountDetailsService(client=client) + + def list(self, profile_id: str) -> list[Any]: + return munchify(self.service.list(profile_id=profile_id)) diff --git a/pywisetransfer/balance_statements.py b/pywisetransfer/balance_statements.py new file mode 100644 index 0000000..e3c8eed --- /dev/null +++ b/pywisetransfer/balance_statements.py @@ -0,0 +1,45 @@ +from typing import Any + +from munch import munchify + +from pywisetransfer import Client +from pywisetransfer.base import Base +from pywisetransfer.endpoint import JsonEndpointWithSCA + + +class BalanceStatementsService(Base): + statement = JsonEndpointWithSCA( + path="/v1/profiles/{profile_id}/balance-statements/{balance_id}/statement.json", + required_params=["currency", "intervalStart", "intervalEnd"], + ) + + +class BalanceStatements: + def __init__(self, client: Client): + self.service = BalanceStatementsService(client=client) + + def statement( + self, + profile_id: str, + balance_id: str, + currency: str, + interval_start: str, + interval_end: str, + type: str = "COMPACT", + ) -> Any: + valid_types = ["COMPACT", "FLAT"] + if type not in valid_types: + raise ValueError(f"Invalid type '{type}'; value values are: {valid_types}") + + return munchify( + self.service.statement( + profile_id=profile_id, + balance_id=balance_id, + params={ + "currency": currency, + "intervalStart": interval_start, + "intervalEnd": interval_end, + "type": type, + }, + ) + ) diff --git a/pywisetransfer/balances.py b/pywisetransfer/balances.py new file mode 100644 index 0000000..f99e0a5 --- /dev/null +++ b/pywisetransfer/balances.py @@ -0,0 +1,34 @@ +from typing import Any + +from apiron import JsonEndpoint +from munch import munchify + +from pywisetransfer import Client +from pywisetransfer.base import Base + + +class BalancesService(Base): + list = JsonEndpoint(path="/v4/profiles/{profile_id}/balances", required_params=["types"]) + get = JsonEndpoint(path="/v4/profiles/{profile_id}/balances/{balance_id}") + + +class Balances: + def __init__(self, client: Client): + self.service = BalancesService(client=client) + + def list(self, profile_id: str, types: str | list[str] = "STANDARD") -> Any: + if not isinstance(types, list): + assert isinstance(types, str) + types = [types] + + valid_types = ["STANDARD", "SAVINGS"] + for value in types: + assert isinstance(value, str) + if value not in valid_types: + raise ValueError(f"Invalid type '{type}'; value values are: {valid_types}") + + params = {"types": ",".join(types)} + return munchify(self.service.list(profile_id=profile_id, params=params)) + + def get(self, profile_id: str, balance_id: str) -> Any: + return munchify(self.service.get(profile_id=profile_id, balance_id=balance_id)) diff --git a/pywisetransfer/borderless_account.py b/pywisetransfer/borderless_account.py index 24b8a81..a43bf21 100644 --- a/pywisetransfer/borderless_account.py +++ b/pywisetransfer/borderless_account.py @@ -5,6 +5,7 @@ from pywisetransfer import Client from pywisetransfer.base import Base +from pywisetransfer.deprecation import deprecated from pywisetransfer.endpoint import JsonEndpointWithSCA @@ -20,10 +21,16 @@ class BorderlessAccount: def __init__(self, client: Client): self.service = BorderlessAccountService(client=client) + @deprecated( + message="The borderless-accounts endpoint is deprecated; please use account-details instead" + ) def list(self, profile_id: str) -> list[Any]: accounts: list[Any] = self.service.list(params={"profileId": profile_id}) return munchify(accounts) + @deprecated( + message="The borderless-accounts statement endpoint is deprecated; please use balance-statements instead" + ) def statement( self, profile_id: str, diff --git a/pywisetransfer/deprecation.py b/pywisetransfer/deprecation.py new file mode 100644 index 0000000..427c048 --- /dev/null +++ b/pywisetransfer/deprecation.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import warnings + + +class deprecated: + """Decorator to indicate that a method is deprecated, with an optional + message to emit in the warning. + + Written following guidance from: + https://blog.miguelgrinberg.com/post/the-ultimate-guide-to-python-decorators-part-iii-decorators-with-arguments + """ + + def __init__(self, *args, **kwargs): + if len(args) == 1 and callable(args[0]): + self.f = args[0] + message = args[1] if len(args) > 1 else None + else: + self.f = None + message = args[0] if len(args) == 1 else None + self.message = kwargs.get("message", message) + + def __call__(self, *args, **kwargs): + if self.f is None and len(args) == 1 and callable(args[0]): + self.f = args[0] + return self + + warnings.warn(self.message, DeprecationWarning, stacklevel=2) + return self.f(*args, **kwargs) + + def __repr__(self): + return f"" diff --git a/test/test_deprecation.py b/test/test_deprecation.py new file mode 100644 index 0000000..bc2808d --- /dev/null +++ b/test/test_deprecation.py @@ -0,0 +1,59 @@ +from warnings import catch_warnings + +import pytest + +from pywisetransfer.deprecation import deprecated + +record_warnings = lambda: catch_warnings(record=True) + + +def undecorated(): + return 1 + + +@deprecated +def bare_decorator(): + return 1 + + +@deprecated() +def zero_args_decorator(): + return 1 + + +@deprecated("positional") +def posarg_decorator(): + return 1 + + +@deprecated(message="keyword") +def kwarg_decorator(): + return 1 + + +@pytest.mark.parametrize( + "func, name, deprecated, message", + [ + (undecorated, "undecorated", False, None), + (bare_decorator, "bare_decorator", True, None), + (zero_args_decorator, "zero_args_decorator", True, None), + (posarg_decorator, "posarg_decorator", True, "positional"), + (kwarg_decorator, "kwarg_decorator", True, "keyword"), + ], +) +def test_no_decorator(func, name, deprecated, message): + actual_repr = repr(func) + with record_warnings() as ws: + result = func() + + # Check the Python repr of the function + assert name in actual_repr + actual_deprecated = "deprecated" in actual_repr + assert deprecated == actual_deprecated + + # Check the behaviour of the function + assert result == 1 + + # Check the warnings emitted by the function + assert ws if deprecated else not ws + assert any([message in str(w.message) for w in ws] if message else [True]) diff --git a/test/test_sca.py b/test/test_sca.py index 8742611..2e43c0e 100644 --- a/test/test_sca.py +++ b/test/test_sca.py @@ -31,8 +31,9 @@ def sca_challenge_signature(): @pytest.fixture def statement_url(): return ( - "https://api.sandbox.transferwise.tech/v3/profiles/0/borderless-accounts/231/statement.json?" - "currency=GBP&intervalStart=2021-12-28T00%3A00%3A00Z&intervalEnd=2021-12-29T00%3A00%3A00Z" + "https://api.sandbox.transferwise.tech/v1/profiles/0/balance-statements/231/statement.json?" + "currency=GBP&intervalStart=2018-03-01T00%3A00%3A00Z&intervalEnd=2018-04-30T23%3A59%3A59.999Z&" + "type=FLAT" ) @@ -51,7 +52,7 @@ def statement_forbidden(statement_url, sca_challenge, mocked_responses): "status": 403, "error": "Forbidden", "message": "You are forbidden to send this request", - "path": "/v3/profiles/0/borderless-accounts/1001/statement.json", + "path": "/v1/profiles/0/balance-statements/1001/statement.json", }, headers={ "X-2FA-Approval-Result": "REJECTED", @@ -65,6 +66,13 @@ def statement_success(): return { "accountHolder": { "type": "PERSONAL", + "address": { + "addressFirstLine": "Veerenni 24", + "city": "Tallinn", + "postCode": "12112", + "stateCode": "", + "countryName": "Estonia", + }, "firstName": "Oliver", "lastName": "Wilson", }, @@ -73,53 +81,78 @@ def statement_success(): "firstLine": "56 Shoreditch High Street", "city": "London", "postCode": "E1 6JJ", - "stateCode": None, + "stateCode": "", "country": "United Kingdom", }, - "bankDetails": [ + "bankDetails": None, + "transactions": [ { - "address": { - "firstLine": "Wise Payments Limited", - "secondLine": "56 Shoreditch High Street", - "postCode": "E1 6JJ", - "stateCode": None, - "city": "London", - "country": "United Kingdom", - }, - "accountNumbers": [ - { - "accountType": "Account number", - "accountNumber": "31926819", - }, - { - "accountType": "IBAN", - "accountNumber": "GB29 NWBK 6016 1331 9268 19", + "type": "DEBIT", + "date": "2018-04-30T08:47:05.832Z", + "amount": {"value": -7.76, "currency": "EUR"}, + "totalFees": {"value": 0.04, "currency": "EUR"}, + "details": { + "type": "CARD", + "description": "Card transaction of 6.80 GBP issued by Tfl.gov.uk/cp TFL TRAVEL CH", + "amount": {"value": 6.8, "currency": "GBP"}, + "category": "Transportation Suburban and Loca", + "merchant": { + "name": "Tfl.gov.uk/cp", + "firstLine": None, + "postCode": "SW1H 0TL ", + "city": "TFL TRAVEL CH", + "state": " ", + "country": "GB", + "category": "Transportation Suburban and Loca", }, - ], - "bankCodes": [{"scheme": "UK sort code", "value": "60-16-13"}], - "deprecated": False, - } + }, + "exchangeDetails": { + "forAmount": {"value": 6.8, "currency": "GBP"}, + "rate": None, + }, + "runningBalance": {"value": 16.01, "currency": "EUR"}, + "referenceNumber": "CARD-249281", + }, + { + "type": "CREDIT", + "date": "2018-04-17T07:47:00.227Z", + "amount": {"value": 200, "currency": "EUR"}, + "totalFees": {"value": 0, "currency": "EUR"}, + "details": { + "type": "DEPOSIT", + "description": "Received money from HEIN LAURI with reference SVWZ+topup card", + "senderName": "HEIN LAURI", + "senderAccount": "EE76 1700 0170 0049 6704 ", + "paymentReference": "SVWZ+topup card", + }, + "exchangeDetails": None, + "runningBalance": {"value": 207.69, "currency": "EUR"}, + "referenceNumber": "TRANSFER-34188888", + }, + { + "type": "CREDIT", + "date": "2018-04-10T05:58:34.681Z", + "amount": {"value": 9.94, "currency": "EUR"}, + "totalFees": {"value": 0, "currency": "EUR"}, + "details": { + "type": "CONVERSION", + "description": "Converted 8.69 GBP to 9.94 EUR", + "sourceAmount": {"value": 8.69, "currency": "GBP"}, + "targetAmount": {"value": 9.94, "currency": "EUR"}, + "fee": {"value": 0.03, "currency": "GBP"}, + "rate": 1.147806, + }, + "exchangeDetails": None, + "runningBalance": {"value": 9.94, "currency": "EUR"}, + "referenceNumber": "CONVERSION-1511237", + }, ], - "transactions": [], - "endOfStatementBalance": {"value": 100.00, "currency": "GBP", "zero": False}, - "endOfStatementUnrealisedGainLoss": None, + "endOfStatementBalance": {"value": 9.94, "currency": "EUR"}, "query": { - "intervalStart": "2021-12-28T00:00:00Z", - "intervalEnd": "2021-12-29T00:00:00Z", - "type": "COMPACT", - "currency": "GBP", - "profileId": 0, - "timezone": "Z", - }, - "request": { - "id": "f847c6f9-691b-4213-b84d-2d60e018dc24", - "creationTime": "2021-12-31T19:22:42.650161Z", - "profileId": 0, - "currency": "GBP", - "balanceId": 121, - "balanceName": None, - "intervalStart": "2021-12-28T00:00:00Z", - "intervalEnd": "2021-12-29T00:00:00Z", + "intervalStart": "2018-03-01T00:00:00Z", + "intervalEnd": "2018-04-30T23:59:59.999Z", + "currency": "EUR", + "accountId": 64, }, } @@ -156,14 +189,24 @@ def statement_authorised( def test_sca_statement_without_private_key(statement_forbidden): client = Client(api_key="test-key") with pytest.raises(Exception, match="Please provide.*private_key.*"): - client.borderless_accounts.statement( - 0, 231, "GBP", "2021-12-28T00:00:00Z", "2021-12-29T00:00:00Z" + client.balance_statements.statement( + profile_id=0, + balance_id=231, + currency="GBP", + interval_start="2018-03-01T00:00:00Z", + interval_end="2018-04-30T23:59:59.999Z", + type="FLAT", ) def test_sca_statement_with_private_key(statement_forbidden, statement_authorised): client = Client(api_key="test-key", private_key_file="test/test-sca.pem") - statement = client.borderless_accounts.statement( - 0, 231, "GBP", "2021-12-28T00:00:00Z", "2021-12-29T00:00:00Z" + statement = client.balance_statements.statement( + profile_id=0, + balance_id=231, + currency="GBP", + interval_start="2018-03-01T00:00:00Z", + interval_end="2018-04-30T23:59:59.999Z", + type="FLAT", ) assert "endOfStatementBalance" in statement