diff --git a/paypal_rest/client.py b/paypal_rest/client.py index baceb3d..4046bdc 100644 --- a/paypal_rest/client.py +++ b/paypal_rest/client.py @@ -19,6 +19,7 @@ import datetime import enum import logging import math +import operator import urllib.parse as urlparse import requests @@ -285,6 +286,47 @@ class PayPalAPIClient: ] self.logger.error(" — ".join(parts)) + def _iter_date_params( + self, + start_date: datetime.datetime, + end_date: datetime.datetime, + params: Optional[Params]=None, + ) -> Iterator[Params]: + """Generate parameters with date windows to cover a wider span + + The PayPal transaction search API only allows the ``start_date`` and + ``end_date`` to be a month apart. Given a user's desired date range and + other API parameters, this method generates parameters with different + date pairs to cover the entire date range. + + Normally the method keeps incrementing ``start_date`` until the user's + desired ``end_date`` is reached. If the ``start_date`` if later than the + ``end_date``, instead it works backwards: it uses the ``start_date`` + argument as the first ``end_date`` parameter, and keeps decrementing it + until the ``start_date`` parameter reaches the other end of the range. + """ + if start_date > end_date: + key1 = 'end_date' + key2 = 'start_date' + days_sign = operator.neg + pred = operator.gt + limit_func = max + else: + key1 = 'start_date' + key2 = 'end_date' + days_sign = operator.pos + pred = operator.lt + limit_func = min + retval = collections.ChainMap(params or {}).new_child() + retval[key1] = start_date.isoformat(timespec='seconds') + next_date = start_date + date_diff = datetime.timedelta(days=days_sign(30)) + while pred(next_date, end_date): + next_date = limit_func(next_date + date_diff, end_date) + retval[key2] = next_date.isoformat(timespec='seconds') + yield retval + retval[key1] = retval[key2] + def get_subscription( self, subscription_id: str, @@ -323,21 +365,15 @@ class PayPalAPIClient: if start_date is None: # The API only goes back three years start_date = now - datetime.timedelta(days=365 * 3) - date_diff = datetime.timedelta(days=30) response: APIResponse = {'transaction_details': None} - while end_date > start_date and not response['transaction_details']: - search_start = max(end_date - date_diff, start_date) - response = self._get_json('/v1/reporting/transactions', { + for params in self._iter_date_params(end_date, start_date, { 'transaction_id': transaction_id, 'fields': fields.param_value(), - 'start_date': search_start.isoformat(timespec='seconds'), - 'end_date': end_date.isoformat(timespec='seconds'), - }) - end_date = search_start - if response['transaction_details']: - return Transaction(response['transaction_details'][0]) - else: - raise ValueError(f"transaction {transaction_id!r} not found") + }): + response = self._get_json('/v1/reporting/transactions', params) + if response['transaction_details']: + return Transaction(response['transaction_details'][0]) + raise ValueError(f"transaction {transaction_id!r} not found") def iter_transactions( self, @@ -351,10 +387,9 @@ class PayPalAPIClient: ``fields`` is a TransactionsFields object that flags the information to include in returned Transactions. """ - for page in self._iter_pages('/v1/reporting/transactions', { + for params in self._iter_date_params(start_date, end_date, { 'fields': fields.param_value(), - 'start_date': start_date.isoformat(timespec='seconds'), - 'end_date': end_date.isoformat(timespec='seconds'), }): - for txn_source in page['transaction_details']: - yield Transaction(txn_source) + for page in self._iter_pages('/v1/reporting/transactions', params): + for txn_source in page['transaction_details']: + yield Transaction(txn_source) diff --git a/setup.py b/setup.py index 88888c1..ffc519f 100755 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ with README_PATH.open() as readme_file: setup( name='paypal_rest', - version='1.0.0', + version='1.0.1', author='Software Freedom Conservancy', author_email='info@sfconservancy.org', license='GNU AGPLv3+', diff --git a/tests/test_client.py b/tests/test_client.py index e6a96bd..2224235 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -26,7 +26,7 @@ from paypal_rest import client as client_mod from paypal_rest import transaction as txn_mod START_DATE = datetime.datetime(2020, 10, 1, 12, tzinfo=datetime.timezone.utc) -END_DATE = START_DATE.replace(month=START_DATE.month + 1) +END_DATE = START_DATE.replace(day=25) def test_transaction_type(): txn_id = 'TYPETEST123456789' @@ -87,6 +87,53 @@ def test_transaction_fields_formatting(fields): for name in session._requests[0].params.get('fields', '').split(',') } +@pytest.mark.parametrize('days_diff', [10, 45, 89]) +def test_iter_transactions_date_window(days_diff): + start_date = START_DATE + end_date = start_date + datetime.timedelta(days=days_diff) + session = MockSession( + MockResponse({'page': 1, 'total_pages': 1, 'transaction_details': []}), + infinite=True, + ) + paypal = client_mod.PayPalAPIClient(session) + assert not any(paypal.iter_transactions(start_date, end_date)) + req_count = len(session._requests) + assert req_count == ((days_diff // 30) + 1) + start_str = start_date.isoformat(timespec='seconds') + end_str = end_date.isoformat(timespec='seconds') + prev_end = start_str + for number, request in enumerate(session._requests, 1): + assert request.params['start_date'] == prev_end + if number == req_count: + assert request.params['end_date'] == end_str + else: + assert prev_end < request.params['end_date'] < end_str + prev_end = request.params['end_date'] + +@pytest.mark.parametrize('days_diff', [10, 45, 89]) +def test_get_transactions_date_window(days_diff): + end_date = END_DATE + start_date = end_date - datetime.timedelta(days=days_diff) + session = MockSession( + MockResponse({'page': 1, 'total_pages': 1, 'transaction_details': []}), + infinite=True, + ) + paypal = client_mod.PayPalAPIClient(session) + with pytest.raises(ValueError): + paypal.get_transaction('DATEWINDOW1234567', end_date, start_date) + req_count = len(session._requests) + assert req_count == ((days_diff // 30) + 1) + start_str = start_date.isoformat(timespec='seconds') + end_str = end_date.isoformat(timespec='seconds') + prev_start = end_str + for number, request in enumerate(session._requests, 1): + assert request.params['end_date'] == prev_start + if number == req_count: + assert request.params['start_date'] == start_str + else: + assert prev_start > request.params['start_date'] > start_str + prev_start = request.params['start_date'] + @pytest.mark.parametrize('name', [ 'BAD_REQUEST', 'UNAUTHORIZED',