2020-11-18 20:57:38 +00:00
|
|
|
"""client.py - PayPal API client class"""
|
|
|
|
# Copyright © 2020 Brett Smith
|
|
|
|
#
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
|
|
# (at your option) any later version.
|
|
|
|
#
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU Affero General Public License for more details.
|
|
|
|
#
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
import collections
|
|
|
|
import datetime
|
|
|
|
import enum
|
|
|
|
import logging
|
|
|
|
import math
|
2020-11-19 20:38:13 +00:00
|
|
|
import operator
|
2020-11-18 20:57:38 +00:00
|
|
|
import urllib.parse as urlparse
|
|
|
|
|
|
|
|
import requests
|
|
|
|
import requests_oauthlib # type:ignore[import]
|
|
|
|
from oauthlib import oauth2 # type:ignore[import]
|
|
|
|
|
|
|
|
from typing import (
|
|
|
|
Any,
|
|
|
|
Iterator,
|
|
|
|
Mapping,
|
|
|
|
Optional,
|
|
|
|
Sequence,
|
|
|
|
Type,
|
|
|
|
TypeVar,
|
|
|
|
Union,
|
|
|
|
)
|
|
|
|
from .paypal_types import (
|
|
|
|
APIResponse,
|
|
|
|
)
|
|
|
|
from .transaction import Transaction
|
|
|
|
|
|
|
|
FieldsType = TypeVar('FieldsType', bound='PayPalFields')
|
|
|
|
Params = Mapping[str, str]
|
|
|
|
|
|
|
|
class PayPalSite(enum.Enum):
|
|
|
|
SANDBOX = 'api-m.sandbox.paypal.com'
|
|
|
|
LIVE = 'api-m.paypal.com'
|
|
|
|
|
|
|
|
def url(self) -> str:
|
|
|
|
return f'https://{self.value}'
|
|
|
|
|
|
|
|
|
|
|
|
class PayPalFields(enum.Flag):
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Base class for PayPal fields specifiers
|
|
|
|
|
|
|
|
Multiple PayPal APIs accept a ``fields`` parameter to let the user specify
|
|
|
|
what details to return. This class lets code enumerate acceptable values
|
|
|
|
and combine them programmatically. The ``param_value`` method then helps
|
|
|
|
``PayPalAPIClient`` format the result when needed.
|
|
|
|
"""
|
|
|
|
|
2020-11-18 20:57:38 +00:00
|
|
|
@classmethod
|
|
|
|
def choices(cls) -> Iterator[str]:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Iterate the names of all field values"""
|
2020-11-18 20:57:38 +00:00
|
|
|
for flag in cls:
|
|
|
|
yield flag.name.lower()
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def combine(cls: Type[FieldsType], fields: Optional[Sequence[FieldsType]]=None) -> FieldsType:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Combine multiple field objects into one
|
|
|
|
|
|
|
|
This method just returns the result of ORing all of the fields in the
|
|
|
|
sequence together. If no argument is given or the sequence is empty,
|
|
|
|
return the combination of all fields.
|
|
|
|
"""
|
2020-11-18 20:57:38 +00:00
|
|
|
if fields:
|
|
|
|
fields_iter = iter(fields)
|
|
|
|
else:
|
|
|
|
fields_iter = iter(cls)
|
|
|
|
retval = next(fields_iter)
|
|
|
|
for field in fields_iter:
|
|
|
|
retval |= field
|
|
|
|
return retval
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_arg(cls: Type[FieldsType], arg: str) -> FieldsType:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Return a field object from an argument name string"""
|
2020-11-18 20:57:38 +00:00
|
|
|
try:
|
|
|
|
return cls[arg.upper()]
|
|
|
|
except KeyError:
|
|
|
|
raise ValueError(f"unknown {cls.__name__} {arg!r}") from None
|
|
|
|
|
|
|
|
def is_base_field(self) -> bool:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Return true if this is a single field value, not a combination"""
|
2020-11-18 20:57:38 +00:00
|
|
|
return not math.log2(self.value) % 1
|
|
|
|
|
|
|
|
def _base_value(self) -> str:
|
|
|
|
return self.name.lower()
|
|
|
|
|
|
|
|
def param_value(self) -> str:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Return these fields formatted as a query string
|
|
|
|
|
|
|
|
The result is in the format PayPal's API expects for ``fields``
|
|
|
|
parameters.
|
|
|
|
"""
|
2020-11-18 20:57:38 +00:00
|
|
|
return ','.join(
|
|
|
|
flag._base_value()
|
|
|
|
for flag in type(self)
|
|
|
|
if flag & self and flag.is_base_field()
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class SubscriptionFields(PayPalFields):
|
|
|
|
LAST_FAILED_PAYMENT = enum.auto()
|
|
|
|
PLAN = enum.auto()
|
|
|
|
ALL = LAST_FAILED_PAYMENT | PLAN
|
|
|
|
|
|
|
|
|
|
|
|
class TransactionFields(PayPalFields):
|
|
|
|
TRANSACTION = enum.auto()
|
|
|
|
PAYER = enum.auto()
|
|
|
|
SHIPPING = enum.auto()
|
|
|
|
AUCTION = enum.auto()
|
|
|
|
CART = enum.auto()
|
|
|
|
INCENTIVE = enum.auto()
|
|
|
|
STORE = enum.auto()
|
|
|
|
ALL = TRANSACTION | PAYER | SHIPPING | AUCTION | CART | INCENTIVE | STORE
|
|
|
|
|
|
|
|
def _base_value(self) -> str:
|
|
|
|
return f'{self.name.lower()}_info'
|
|
|
|
|
|
|
|
|
|
|
|
class PayPalSession(requests_oauthlib.OAuth2Session):
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Low-level HTTP session for the PayPal API
|
|
|
|
|
|
|
|
This is a subclass of requests_oauthlib.OAuth2Session that implements
|
|
|
|
PayPal's recommended authorization strategy: if an API request returns
|
|
|
|
HTTP Unauthorized, get an OAuth token and retry. This gracefully handles
|
|
|
|
refreshing expired tokens.
|
|
|
|
|
|
|
|
This class only handles the mechanics of handling an HTTP connection.
|
|
|
|
It doesn't know anything about the higher-level REST API. That's the job
|
|
|
|
of ``PayPalAPIClient``.
|
|
|
|
"""
|
2020-11-18 20:57:38 +00:00
|
|
|
TOKEN_PATH = '/v1/oauth2/token'
|
|
|
|
|
|
|
|
def __init__(self, client: oauth2.Client, client_secret: str) -> None:
|
|
|
|
super().__init__(client=client)
|
|
|
|
self._client_secret = client_secret
|
|
|
|
|
|
|
|
def request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
|
|
|
|
response = super().request(method, url, **kwargs)
|
|
|
|
if response.status_code == requests.codes.UNAUTHORIZED:
|
|
|
|
self.fetch_token(
|
|
|
|
token_url=urlparse.urljoin(url, self.TOKEN_PATH),
|
|
|
|
client_id=self._client.client_id,
|
|
|
|
client_secret=self._client_secret,
|
|
|
|
)
|
|
|
|
response = super().request(method, url, **kwargs)
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
class PayPalAPIClient:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Primary access point for the PayPal API
|
|
|
|
|
|
|
|
This is the primary class of the library. Most users will instantiate a
|
|
|
|
``PayPalAPIClient`` using one of the constructor classmethods, then call
|
|
|
|
methods to make API calls.
|
|
|
|
"""
|
|
|
|
|
2020-11-18 20:57:38 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
session: requests.Session,
|
|
|
|
root_url: Union[str, PayPalSite]=PayPalSite.SANDBOX,
|
|
|
|
logger: Optional[logging.Logger]=None,
|
|
|
|
) -> None:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Low-level constructor
|
|
|
|
|
|
|
|
``PayPalAPIClient`` expects its underlying ``Session`` object to know
|
|
|
|
how to authorize itself to PayPal. Usually that means using an instance
|
|
|
|
of ``PayPalSession``. You can implement and provide your own subclass
|
|
|
|
of ``requests.Session`` to do this if you prefer.
|
|
|
|
|
|
|
|
``root_url`` is either a PayPalSite value, or a string with a full URL
|
|
|
|
to a PayPal API endpoint.
|
|
|
|
|
|
|
|
``logger`` is a ``logging.Logger`` object where all log messages (like
|
|
|
|
API errors) will be sent. If none is provided, this instance will get
|
|
|
|
its own, with a name based on the hostname in ``root_url``.
|
|
|
|
"""
|
2020-11-18 20:57:38 +00:00
|
|
|
self._session = session
|
|
|
|
if isinstance(root_url, str):
|
|
|
|
self._root_url = root_url
|
|
|
|
else:
|
|
|
|
self._root_url = root_url.url()
|
|
|
|
if logger is None:
|
|
|
|
host_parts = urlparse.urlsplit(self._root_url).netloc.split('.')
|
|
|
|
host_parts.reverse()
|
|
|
|
logger_name = f'paypal_rest.PayPalAPIClient.{".".join(host_parts)}'
|
|
|
|
logger = logging.getLogger(logger_name)
|
|
|
|
self.logger = logger
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_client_secret(
|
|
|
|
cls,
|
|
|
|
client_id: str,
|
|
|
|
client_secret: str,
|
|
|
|
root_url: Union[str, PayPalSite]=PayPalSite.SANDBOX,
|
|
|
|
logger: Optional[logging.Logger]=None,
|
|
|
|
) -> 'PayPalAPIClient':
|
2020-11-19 17:00:14 +00:00
|
|
|
"""High-level constructor from individual string arguments
|
|
|
|
|
|
|
|
Given ``client_id`` and ``client_secret`` strings, this method
|
|
|
|
constructs a ``PayPalSesssion`` from them, and then returns a
|
|
|
|
``PayPalAPIClient`` backed by it.
|
|
|
|
|
|
|
|
``root_url`` and ``logger`` arguments are passed directly to
|
|
|
|
``PayPalAPIClient.__init__``.
|
|
|
|
"""
|
2020-11-18 20:57:38 +00:00
|
|
|
client = oauth2.BackendApplicationClient(client_id=client_id)
|
|
|
|
session = PayPalSession(client, client_secret)
|
|
|
|
return cls(session, root_url, logger)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_config(
|
|
|
|
cls,
|
|
|
|
config: Mapping[str, str],
|
|
|
|
default_url: Union[str, PayPalSite]=PayPalSite.SANDBOX,
|
|
|
|
logger: Optional[logging.Logger]=None,
|
|
|
|
) -> 'PayPalAPIClient':
|
2020-11-19 17:00:14 +00:00
|
|
|
"""High-level constructor from a configuration mapping
|
|
|
|
|
|
|
|
Given a mapping of strings (e.g., a configparser section object),
|
|
|
|
gets the arguments necessary to call ``from_client_secret``, and calls
|
|
|
|
it.
|
|
|
|
|
|
|
|
If the mapping has a ``site`` key, the value will be used as the
|
|
|
|
``root_url``. Otherwise, ``root_url`` has the value of ``default_url``.
|
|
|
|
|
|
|
|
``logger`` is passed directly to ``PayPalAPIClient.__init__``.
|
|
|
|
"""
|
2020-11-18 20:57:38 +00:00
|
|
|
try:
|
|
|
|
client_id = config['client_id']
|
|
|
|
client_secret = config['client_secret']
|
|
|
|
except KeyError as error:
|
|
|
|
raise ValueError(f"configuration missing {error.args[0]!r}") from None
|
|
|
|
try:
|
|
|
|
root_url: Union[str, PayPalSite] = config['site']
|
|
|
|
except KeyError:
|
|
|
|
root_url = default_url
|
|
|
|
else:
|
|
|
|
try:
|
|
|
|
# In this case, we know root_url must be a str because we got
|
|
|
|
# it from the Mapping, but mypy doesn't track that.
|
|
|
|
root_url = PayPalSite[root_url.upper()] # type:ignore[union-attr]
|
|
|
|
except KeyError:
|
|
|
|
pass
|
|
|
|
return cls.from_client_secret(client_id, client_secret, root_url, logger)
|
|
|
|
|
|
|
|
def _get_json(self, path: str, params: Optional[Params]=None) -> APIResponse:
|
|
|
|
url = urlparse.urljoin(self._root_url, path)
|
|
|
|
response = self._session.get(url, params=params)
|
|
|
|
try:
|
|
|
|
response.raise_for_status()
|
|
|
|
except requests.HTTPError:
|
|
|
|
self._log_error(response.json())
|
|
|
|
raise
|
|
|
|
else:
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
def _iter_pages(self, path: str, params: Params) -> Iterator[APIResponse]:
|
|
|
|
response: APIResponse = {'page': 0, 'total_pages': 1}
|
|
|
|
page_params = collections.ChainMap(params).new_child()
|
|
|
|
while response['page'] < response['total_pages']:
|
|
|
|
page_params['page'] = str(response['page'] + 1)
|
|
|
|
response = self._get_json(path, page_params)
|
|
|
|
yield response
|
|
|
|
|
|
|
|
def _log_error(self, error: APIResponse) -> None:
|
|
|
|
parts = [
|
|
|
|
'{name}: {message}'.format_map(error),
|
|
|
|
*('{issue} (in {location})'.format_map(detail)
|
|
|
|
for detail in error.get('details', ())),
|
|
|
|
]
|
|
|
|
self.logger.error(" — ".join(parts))
|
|
|
|
|
2020-11-19 20:38:13 +00:00
|
|
|
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]
|
|
|
|
|
2020-11-18 20:57:38 +00:00
|
|
|
def get_subscription(
|
|
|
|
self,
|
|
|
|
subscription_id: str,
|
|
|
|
fields: SubscriptionFields=SubscriptionFields.ALL,
|
|
|
|
) -> APIResponse:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Fetch and return a subscription by its id"""
|
2020-11-18 20:57:38 +00:00
|
|
|
return self._get_json(f'/v1/billing/subscriptions/{subscription_id}', {
|
|
|
|
'fields': fields.param_value(),
|
|
|
|
})
|
|
|
|
|
|
|
|
def get_transaction(
|
|
|
|
self,
|
|
|
|
transaction_id: str,
|
|
|
|
end_date: Optional[datetime.datetime]=None,
|
|
|
|
start_date: Optional[datetime.datetime]=None,
|
|
|
|
fields: TransactionFields=TransactionFields.TRANSACTION,
|
2020-11-24 14:22:40 +00:00
|
|
|
) -> Transaction:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Find and return a transaction by its id
|
|
|
|
|
|
|
|
The PayPal API does not provide a way to look up transactions solely by
|
|
|
|
id. This is a convenience method that wraps the search method to search
|
|
|
|
different windows of time until it finds the desired transaction.
|
|
|
|
|
|
|
|
``start_date`` and ``end_date`` specify the full window of time to
|
|
|
|
search. This method starts by searching 30 days before ``end_date``,
|
|
|
|
then the previous 30 days, and so on until it reaches ``start_date``.
|
|
|
|
The default ``end_date`` is now, and the default ``start_date`` is
|
|
|
|
three years ago (the API only supports searches this far back).
|
|
|
|
|
|
|
|
``fields`` is a TransactionFields object that flags the information to
|
|
|
|
include in the returned Transaction.
|
|
|
|
"""
|
2020-11-18 20:57:38 +00:00
|
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
|
|
if end_date is None:
|
|
|
|
end_date = now
|
|
|
|
if start_date is None:
|
|
|
|
# The API only goes back three years
|
|
|
|
start_date = now - datetime.timedelta(days=365 * 3)
|
|
|
|
response: APIResponse = {'transaction_details': None}
|
2020-11-19 20:38:13 +00:00
|
|
|
for params in self._iter_date_params(end_date, start_date, {
|
2020-11-18 20:57:38 +00:00
|
|
|
'transaction_id': transaction_id,
|
|
|
|
'fields': fields.param_value(),
|
2020-11-19 20:38:13 +00:00
|
|
|
}):
|
|
|
|
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")
|
2020-11-18 20:57:38 +00:00
|
|
|
|
|
|
|
def iter_transactions(
|
|
|
|
self,
|
|
|
|
start_date: datetime.datetime,
|
|
|
|
end_date: datetime.datetime,
|
|
|
|
fields: TransactionFields=TransactionFields.TRANSACTION,
|
|
|
|
) -> Iterator[Transaction]:
|
2020-11-19 17:00:14 +00:00
|
|
|
"""Iterate transactions over a date range
|
|
|
|
|
|
|
|
``start_date`` and ``end_date`` represent the range to query.
|
|
|
|
``fields`` is a TransactionsFields object that flags the information to
|
|
|
|
include in returned Transactions.
|
|
|
|
"""
|
2020-11-19 20:38:13 +00:00
|
|
|
for params in self._iter_date_params(start_date, end_date, {
|
2020-11-18 20:57:38 +00:00
|
|
|
'fields': fields.param_value(),
|
|
|
|
}):
|
2020-11-19 20:38:13 +00:00
|
|
|
for page in self._iter_pages('/v1/reporting/transactions', params):
|
|
|
|
for txn_source in page['transaction_details']:
|
|
|
|
yield Transaction(txn_source)
|