Changeset View
Changeset View
Standalone View
Standalone View
looper/gateways.py
| import abc | import abc | ||||
| import enum | import enum | ||||
| import logging | import logging | ||||
| import typing | from typing import Dict, Optional, Union, cast, Iterable, Set, Any | ||||
| import attr | import attr | ||||
| import braintree | import braintree | ||||
| import braintree.resource | import braintree.resource | ||||
| from django.conf import settings | from django.conf import settings | ||||
| from looper.money import Money | from looper.money import Money | ||||
| import looper.exceptions | import looper.exceptions | ||||
| log = logging.getLogger(__name__) | log = logging.getLogger(__name__) | ||||
| MaybeConfiguredGateway = typing.Union['AbstractPaymentGateway', 'GatewayNotConfigured'] | MaybeConfiguredGateway = Union['AbstractPaymentGateway', 'GatewayNotConfigured'] | ||||
| class GatewayNotConfigured: | class GatewayNotConfigured: | ||||
| """Dummy class that indicates a payment provider gateway isn't configured.""" | """Dummy class that indicates a payment provider gateway isn't configured.""" | ||||
| class BraintreeError(looper.exceptions.GatewayError): | class BraintreeError(looper.exceptions.GatewayError): | ||||
| import braintree.attribute_getter | import braintree.attribute_getter | ||||
| import braintree.error_result | import braintree.error_result | ||||
| def __init__(self, braintree_error: braintree.error_result.ErrorResult) -> None: | def __init__(self, braintree_error: braintree.error_result.ErrorResult) -> None: | ||||
| """Convert Braintree-specific errors into strings. | """Convert Braintree-specific errors into strings. | ||||
| Braintree returns errors as strings (i.e. ending in a period) which | Braintree returns errors as strings (i.e. ending in a period) which | ||||
| makes composition more difficult, so we strip trailing periods. | makes composition more difficult, so we strip trailing periods. | ||||
| """ | """ | ||||
| message = braintree_error.message.rstrip('.') | message = braintree_error.message.rstrip('.') | ||||
| super().__init__( | super().__init__( | ||||
| message, | message, | ||||
| (self._nice_message(err, idx, message) | ( | ||||
| for idx, err in enumerate(braintree_error.errors.deep_errors)), | self._nice_message(err, idx, message) | ||||
| for idx, err in enumerate(braintree_error.errors.deep_errors) | |||||
| ), | |||||
| ) | ) | ||||
| @staticmethod | @staticmethod | ||||
| def _nice_message(braintree_error: braintree.attribute_getter.AttributeGetter, | def _nice_message( | ||||
| braintree_error: braintree.attribute_getter.AttributeGetter, | |||||
| error_index: int, | error_index: int, | ||||
| base_message: str) -> str: | base_message: str, | ||||
| ) -> str: | |||||
| parts = [getattr(braintree_error, 'message', 'unknown error').rstrip('.')] | parts = [getattr(braintree_error, 'message', 'unknown error').rstrip('.')] | ||||
| if hasattr(braintree_error, 'code'): | if hasattr(braintree_error, 'code'): | ||||
| code = braintree_error.code | code = braintree_error.code | ||||
| if error_index == 0 and parts[0] == base_message: | if error_index == 0 and parts[0] == base_message: | ||||
| # This is common with Braintree, f.e. base_message='Payment method token is invalid' | # This is common with Braintree, f.e. base_message='Payment method token is invalid' | ||||
| # and parts[0]='Payment method token is invalid'. In that case we just include | # and parts[0]='Payment method token is invalid'. In that case we just include | ||||
| # the error code and avoid repeating the same message. | # the error code and avoid repeating the same message. | ||||
| Show All 36 Lines | def from_braintree(cls, payment_method: braintree.resource.Resource) -> 'PaymentMethodInfo': | ||||
| info.expiration_date = payment_method.expiration_date | info.expiration_date = payment_method.expiration_date | ||||
| info.image_url = payment_method.image_url | info.image_url = payment_method.image_url | ||||
| info.card_type = payment_method.card_type | info.card_type = payment_method.card_type | ||||
| elif isinstance(payment_method, braintree.PayPalAccount): | elif isinstance(payment_method, braintree.PayPalAccount): | ||||
| info.method_type = cls.Type.PAYPAL_ACCOUNT | info.method_type = cls.Type.PAYPAL_ACCOUNT | ||||
| info.email = payment_method.email | info.email = payment_method.email | ||||
| info.image_url = payment_method.image_url | info.image_url = payment_method.image_url | ||||
| else: | else: | ||||
| cls._log.warning('Payment method %r is of unsupported class %r', | cls._log.warning( | ||||
| payment_method, payment_method.__class__) | 'Payment method %r is of unsupported class %r', | ||||
| payment_method, | |||||
| payment_method.__class__, | |||||
| ) | |||||
| return info | return info | ||||
| def recognisable_name(self) -> str: | def recognisable_name(self) -> str: | ||||
| """Construct a name so that customers can recognise the payment method.""" | """Construct a name so that customers can recognise the payment method.""" | ||||
| if self.method_type == self.Type.CREDIT_CARD: | if self.method_type == self.Type.CREDIT_CARD: | ||||
| return f'{self.card_type} credit card ending in {self.last_4}' | return f'{self.card_type} credit card ending in {self.last_4}' | ||||
| if self.method_type == self.Type.PAYPAL_ACCOUNT: | if self.method_type == self.Type.PAYPAL_ACCOUNT: | ||||
| return f'PayPal account {self.email}' | return f'PayPal account {self.email}' | ||||
| Show All 16 Lines | def type_for_database(self) -> str: | ||||
| return '' | return '' | ||||
| class Registry: | class Registry: | ||||
| """Contains an instance for each AbstractPaymentGateway instance. | """Contains an instance for each AbstractPaymentGateway instance. | ||||
| This is the primary means of obtaining an interface with a payment provider. | This is the primary means of obtaining an interface with a payment provider. | ||||
| """ | """ | ||||
| _log = log.getChild('Registry') | _log = log.getChild('Registry') | ||||
| _instances: typing.Dict[str, MaybeConfiguredGateway] = {} # initialized lazily | _instances: Dict[str, MaybeConfiguredGateway] = {} # initialized lazily | ||||
| """Mapping from payment gateway name to instance for that gateway.""" | """Mapping from payment gateway name to instance for that gateway.""" | ||||
| @classmethod | @classmethod | ||||
| def gateway_names(cls) -> typing.Iterable[str]: | def gateway_names(cls) -> Iterable[str]: | ||||
| """Return names of the registered gateways.""" | """Return names of the registered gateways.""" | ||||
| if not cls._instances: | if not cls._instances: | ||||
| cls._instantiate_gateways() | cls._instantiate_gateways() | ||||
| return cls._instances.keys() | return cls._instances.keys() | ||||
| @classmethod | @classmethod | ||||
| def instance_for(cls, gateway_name: str) -> 'AbstractPaymentGateway': | def instance_for(cls, gateway_name: str) -> 'AbstractPaymentGateway': | ||||
| if not cls._instances: | if not cls._instances: | ||||
| cls._instantiate_gateways() | cls._instantiate_gateways() | ||||
| try: | try: | ||||
| gw_instance = cls._instances[gateway_name] | gw_instance = cls._instances[gateway_name] | ||||
| except KeyError: | except KeyError: | ||||
| raise looper.exceptions.GatewayNotImplemented( | raise looper.exceptions.GatewayNotImplemented( | ||||
| f'No such Gateway provider {gateway_name!r}') | f'No such Gateway provider {gateway_name!r}' | ||||
| ) | |||||
| if gw_instance is GatewayNotConfigured: | if gw_instance is GatewayNotConfigured: | ||||
| raise looper.exceptions.GatewayConfigurationMissing( | raise looper.exceptions.GatewayConfigurationMissing( | ||||
| f'Gateway provider {gateway_name!r} not configured') | f'Gateway provider {gateway_name!r} not configured' | ||||
| ) | |||||
| assert isinstance(gw_instance, AbstractPaymentGateway) | assert isinstance(gw_instance, AbstractPaymentGateway) | ||||
| return gw_instance | return gw_instance | ||||
| @classmethod | @classmethod | ||||
| def _instantiate_gateways(cls) -> None: | def _instantiate_gateways(cls) -> None: | ||||
| """Create one instance per AbstractPaymentGateway subclass.""" | """Create one instance per AbstractPaymentGateway subclass.""" | ||||
| cls._instances.clear() | cls._instances.clear() | ||||
| Show All 10 Lines | def _instantiate_gateways(cls) -> None: | ||||
| # Ignore the type here because we cannot declare the type at the assignment | # Ignore the type here because we cannot declare the type at the assignment | ||||
| # in the try-block. | # in the try-block. | ||||
| gw_instance = GatewayNotConfigured # type: ignore | gw_instance = GatewayNotConfigured # type: ignore | ||||
| cls._instances[gw_class.gateway_name] = gw_instance | cls._instances[gw_class.gateway_name] = gw_instance | ||||
| class AbstractPaymentGateway(metaclass=abc.ABCMeta): | class AbstractPaymentGateway(metaclass=abc.ABCMeta): | ||||
| gateway_name: str = '' # set in each subclass. | gateway_name: str = '' # set in each subclass. | ||||
| supported_collection_methods: typing.Set[str] = set() | supported_collection_methods: Set[str] = set() | ||||
| supports_refunds = True | supports_refunds = True | ||||
| supports_transactions = True | supports_transactions = True | ||||
| _log = log.getChild('AbstractPaymentGateway') | _log = log.getChild('AbstractPaymentGateway') | ||||
| def __init__(self) -> None: | def __init__(self) -> None: | ||||
| gateway_settings = settings.GATEWAYS.get(self.gateway_name) | gateway_settings: Optional[object] = settings.GATEWAYS.get(self.gateway_name) | ||||
| if gateway_settings is None: | if gateway_settings is None: | ||||
| raise looper.exceptions.GatewayConfigurationMissing( | raise looper.exceptions.GatewayConfigurationMissing( | ||||
| f'Missing settings for Gateway {self.gateway_name!r}') | f'Missing settings for Gateway {self.gateway_name!r}' | ||||
| ) | |||||
| self.settings = gateway_settings | if not isinstance(gateway_settings, dict): | ||||
| raise looper.exceptions.GatewayConfigurationError( | |||||
| f'Expected a dict for Gateway {self.gateway_name}, found: {gateway_settings}' | |||||
| ) | |||||
| self.settings: Dict[str, object] = cast(Dict[str, object], gateway_settings) | |||||
| def __init_subclass__(cls) -> None: | def __init_subclass__(cls) -> None: | ||||
| assert cls.gateway_name, 'subclasses must set gateway_name' | assert cls.gateway_name, 'subclasses must set gateway_name' | ||||
| assert cls.supported_collection_methods, 'subclasses must set supported_collection_methods' | assert cls.supported_collection_methods, 'subclasses must set supported_collection_methods' | ||||
| super().__init_subclass__() | super().__init_subclass__() | ||||
| cls._log = log.getChild(cls.__name__) | cls._log = log.getChild(cls.__name__) | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def generate_client_token(self, for_currency: str, | def generate_client_token( | ||||
| gateway_customer_id: typing.Optional[str] = None) -> str: | self, for_currency: str, gateway_customer_id: Optional[str] = None | ||||
| ) -> str: | |||||
| """Return a token to be used with the drop-in UI of a gateway.""" | """Return a token to be used with the drop-in UI of a gateway.""" | ||||
| pass | pass | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def customer_create(self) -> str: | def customer_create(self) -> str: | ||||
| """Create a customer record at the payment provider. | """Create a customer record at the payment provider. | ||||
| :return: The customer ID at the payment provider. | :return: The customer ID at the payment provider. | ||||
| :raise: GatewayError if the user cannot be created. | :raise: GatewayError if the user cannot be created. | ||||
| """ | """ | ||||
| pass | pass | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def payment_method_create(self, | def payment_method_create( | ||||
| self, | |||||
| payment_method_nonce: str, | payment_method_nonce: str, | ||||
| gateway_customer_id: str, | gateway_customer_id: str, | ||||
| verification_data: typing.Optional[typing.Any] = None, | verification_data: Optional[Any] = None, | ||||
| ) -> PaymentMethodInfo: | ) -> PaymentMethodInfo: | ||||
| """Store a payment method for the specified customer. | """Store a payment method for the specified customer. | ||||
| :return: The PaymentMethodInfo, which contains the payment method token. | :return: The PaymentMethodInfo, which contains the payment method token. | ||||
| :raise: GatewayError if the payment method cannot be created. | :raise: GatewayError if the payment method cannot be created. | ||||
| """ | """ | ||||
| pass | pass | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| Show All 14 Lines | def transact_sale(self, payment_method_token: str, amount: Money) -> str: | ||||
| :raise looper.exceptions.CurrencyNotSupported: when the amount has a | :raise looper.exceptions.CurrencyNotSupported: when the amount has a | ||||
| currency for which there is no configured Merchant Account ID. | currency for which there is no configured Merchant Account ID. | ||||
| :raise looper.exceptions.GatewayError: when there was an error | :raise looper.exceptions.GatewayError: when there was an error | ||||
| performing the transaction at the payment gateway. | performing the transaction at the payment gateway. | ||||
| """ | """ | ||||
| pass | pass | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def find_transaction(self, transaction_id) -> dict: | def find_transaction(self, transaction_id: str) -> Dict[str, object]: | ||||
| """Lookup a transaction and return it as a dictionary.""" | """Lookup a transaction and return it as a dictionary.""" | ||||
| pass | pass | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def refund(self, gateway_transaction_id: str, amount: Money): | def refund(self, gateway_transaction_id: str, amount: Money) -> None: | ||||
| """Refund a transaction. | """Refund a transaction. | ||||
| :param: gateway_transaction_id: the transaction ID on the gateway. | :param: gateway_transaction_id: the transaction ID on the gateway. | ||||
| :param amount: The amount to refund, must not be more than the original | :param amount: The amount to refund, must not be more than the original | ||||
| transaction amount. | transaction amount. | ||||
| Note that the amount to refund is non-optional, contrary to, for | Note that the amount to refund is non-optional, contrary to, for | ||||
| example, the Braintree API. This is to make the amount explicit. | example, the Braintree API. This is to make the amount explicit. | ||||
| In the unlikely case that Braintree has a different amount stored | In the unlikely case that Braintree has a different amount stored | ||||
| for the transaction than we have, we should be refunding the amount | for the transaction than we have, we should be refunding the amount | ||||
| we expect to be refunding. If this is not possible, errors should be | we expect to be refunding. If this is not possible, errors should be | ||||
| explicit. | explicit. | ||||
| """ | """ | ||||
| pass | pass | ||||
| class MockableGateway(AbstractPaymentGateway): | class MockableGateway(AbstractPaymentGateway): | ||||
| """Payment gateway that expects to be mocked. | """Payment gateway that expects to be mocked. | ||||
| Every function will raise an exception. This allows functions to be mocked, | Every function will raise an exception. This allows functions to be mocked, | ||||
| and unexpected calls to non-mocked functions to be detected. | and unexpected calls to non-mocked functions to be detected. | ||||
| """ | """ | ||||
| gateway_name: str = 'mock' | gateway_name: str = 'mock' | ||||
| supported_collection_methods = {'automatic'} | supported_collection_methods = {'automatic'} | ||||
| _log = log.getChild('MockableGateway') | _log = log.getChild('MockableGateway') | ||||
| # noinspection PyMissingConstructor | # noinspection PyMissingConstructor | ||||
| def __init__(self): | def __init__(self) -> None: | ||||
| # Dont' call the superclass, it'll expect configuration which we don't need nor want. | # Dont' call the superclass, it'll expect configuration which we don't need nor want. | ||||
| self.settings = {} | self.settings: Dict[str, object] = {} | ||||
| def generate_client_token(self, for_currency: str, | def generate_client_token( | ||||
| gateway_customer_id: typing.Optional[str] = None) -> str: | self, for_currency: str, gateway_customer_id: Optional[str] = None | ||||
| ) -> str: | |||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to MockableGateway.generate_client_token({gateway_customer_id!r})') | f'Unexpected call to MockableGateway.generate_client_token({gateway_customer_id!r})' | ||||
| ) | |||||
| def customer_create(self) -> str: | def customer_create(self) -> str: | ||||
| raise NotImplementedError(f'Unexpected call to MockableGateway.customer_create()') | raise NotImplementedError(f'Unexpected call to MockableGateway.customer_create()') | ||||
| def payment_method_create(self, | def payment_method_create( | ||||
| self, | |||||
| payment_method_nonce: str, | payment_method_nonce: str, | ||||
| gateway_customer_id: str, | gateway_customer_id: str, | ||||
| verification_data: typing.Optional[typing.Any] = None, | verification_data: Optional[Any] = None, | ||||
| ) -> PaymentMethodInfo: | ) -> PaymentMethodInfo: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to MockableGateway.payment_method_create' | f'Unexpected call to MockableGateway.payment_method_create' | ||||
| f'({payment_method_nonce!r}, {gateway_customer_id!r})') | f'({payment_method_nonce!r}, {gateway_customer_id!r})' | ||||
| ) | |||||
| def payment_method_delete(self, payment_method_token: str) -> None: | def payment_method_delete(self, payment_method_token: str) -> None: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to MockableGateway.payment_method_delete({payment_method_token!r})') | f'Unexpected call to MockableGateway.payment_method_delete({payment_method_token!r})' | ||||
| ) | |||||
| def find_customer_payment_method(self, payment_method_token: str) -> PaymentMethodInfo: | def find_customer_payment_method(self, payment_method_token: str) -> PaymentMethodInfo: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to MockableGateway.find_customer_payment_method' | f'Unexpected call to MockableGateway.find_customer_payment_method' | ||||
| f'({payment_method_token!r})') | f'({payment_method_token!r})' | ||||
| ) | |||||
| def transact_sale(self, payment_method_token: str, amount: Money) -> str: | def transact_sale(self, payment_method_token: str, amount: Money) -> str: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to MockableGateway.transact_sale' | f'Unexpected call to MockableGateway.transact_sale' | ||||
| f'({payment_method_token!r}, {amount!r})') | f'({payment_method_token!r}, {amount!r})' | ||||
| ) | |||||
| def find_transaction(self, transaction_id) -> dict: | def find_transaction(self, transaction_id: str) -> Dict[str, object]: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to MockableGateway.find_transaction({transaction_id!r})') | f'Unexpected call to MockableGateway.find_transaction({transaction_id!r})' | ||||
| ) | |||||
| def refund(self, gateway_transaction_id: str, amount: Money): | def refund(self, gateway_transaction_id: str, amount: Money) -> None: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to MockableGateway.refund({gateway_transaction_id!r}, {amount})') | f'Unexpected call to MockableGateway.refund({gateway_transaction_id!r}, {amount})' | ||||
| ) | |||||
| class BraintreeGateway(AbstractPaymentGateway): | class BraintreeGateway(AbstractPaymentGateway): | ||||
| gateway_name = 'braintree' | gateway_name = 'braintree' | ||||
| supported_collection_methods = {'automatic'} | supported_collection_methods = {'automatic'} | ||||
| TRANSACTION_SUCCESS_STATUSES = [ | TRANSACTION_SUCCESS_STATUSES = [ | ||||
| braintree.Transaction.Status.Authorized, | braintree.Transaction.Status.Authorized, | ||||
| braintree.Transaction.Status.Authorizing, | braintree.Transaction.Status.Authorizing, | ||||
| braintree.Transaction.Status.Settled, | braintree.Transaction.Status.Settled, | ||||
| braintree.Transaction.Status.SettlementConfirmed, | braintree.Transaction.Status.SettlementConfirmed, | ||||
| braintree.Transaction.Status.SettlementPending, | braintree.Transaction.Status.SettlementPending, | ||||
| braintree.Transaction.Status.Settling, | braintree.Transaction.Status.Settling, | ||||
| braintree.Transaction.Status.SubmittedForSettlement | braintree.Transaction.Status.SubmittedForSettlement, | ||||
| ] | ] | ||||
| def __init__(self): | def __init__(self) -> None: | ||||
| super().__init__() | super().__init__() | ||||
| self.braintree = braintree.BraintreeGateway( | self.braintree = braintree.BraintreeGateway( | ||||
| braintree.Configuration( | braintree.Configuration( | ||||
| environment=self.settings['environment'], | environment=self.settings['environment'], | ||||
| merchant_id=self.settings['merchant_id'], | merchant_id=self.settings['merchant_id'], | ||||
| public_key=self.settings['public_key'], | public_key=self.settings['public_key'], | ||||
| private_key=self.settings['private_key'] | private_key=self.settings['private_key'], | ||||
| ) | ) | ||||
| ) | ) | ||||
| def _merchant_account_id(self, currency: str) -> str: | def _merchant_account_id(self, currency: str) -> str: | ||||
| """Return the Merchant Account ID for the given currency. | """Return the Merchant Account ID for the given currency. | ||||
| Configured in Braintree at Account → Merchant Account Info. | Configured in Braintree at Account → Merchant Account Info. | ||||
| """ | """ | ||||
| merchant_account_ids: typing.Mapping[str, str] = self.settings['merchant_account_ids'] | merchant_account_ids = self.settings['merchant_account_ids'] | ||||
| if not isinstance(merchant_account_ids, dict): | |||||
| raise looper.exceptions.GatewayConfigurationError( | |||||
| f'The field merchant_account_ids for Gateway {self.gateway_name}' | |||||
| f' should be a dict but found: {merchant_account_ids}' | |||||
| ) | |||||
| merchant_account_ids = cast(Dict[str, str], merchant_account_ids) | |||||
| try: | try: | ||||
| return merchant_account_ids[currency] | return merchant_account_ids[currency] | ||||
| except KeyError: | except KeyError: | ||||
| self._log.error('Unsupported currency %r requested', currency) | self._log.error('Unsupported currency %r requested', currency) | ||||
| supported = set(merchant_account_ids.keys()) | supported = set(merchant_account_ids.keys()) | ||||
| raise looper.exceptions.CurrencyNotSupported( | raise looper.exceptions.CurrencyNotSupported( | ||||
| f'Currency {currency!r} not supported, only {supported}') | f'Currency {currency!r} not supported, only {supported}' | ||||
| ) | |||||
| def generate_client_token(self, for_currency: str, | def generate_client_token( | ||||
| gateway_customer_id: typing.Optional[str] = None) -> str: | self, for_currency: str, gateway_customer_id: Optional[str] = None | ||||
| ) -> str: | |||||
| """Generate Braintree client token. | """Generate Braintree client token. | ||||
| Returns a string which contains all authorization and configuration | Returns a string which contains all authorization and configuration | ||||
| information our client needs to initialize the Braintree Client SDK to | information our client needs to initialize the Braintree Client SDK to | ||||
| communicate with Braintree. | communicate with Braintree. | ||||
| See https://developers.braintreepayments.com/reference/request/client-token/generate/python | See https://developers.braintreepayments.com/reference/request/client-token/generate/python | ||||
| """ | """ | ||||
| params = {} | params = {} | ||||
| if gateway_customer_id: | if gateway_customer_id: | ||||
| params['customer_id'] = gateway_customer_id | params['customer_id'] = gateway_customer_id | ||||
| if for_currency: | if for_currency: | ||||
| merchant_account_id = self._merchant_account_id(for_currency) | merchant_account_id = self._merchant_account_id(for_currency) | ||||
| params['merchant_account_id'] = merchant_account_id | params['merchant_account_id'] = merchant_account_id | ||||
| return self.braintree.client_token.generate(params) | |||||
| client_token = self.braintree.client_token.generate(params) | |||||
| assert isinstance(client_token, str) | |||||
| return client_token | |||||
| def customer_create(self) -> str: | def customer_create(self) -> str: | ||||
| """Create a customer at BrainTree. | """Create a customer at BrainTree. | ||||
| See https://developers.braintreepayments.com/reference/request/customer/create/python | See https://developers.braintreepayments.com/reference/request/customer/create/python | ||||
| """ | """ | ||||
| self._log.debug('Creating new customer at Braintree') | self._log.debug('Creating new customer at Braintree') | ||||
| result = self.braintree.customer.create({}) | result = self.braintree.customer.create({}) | ||||
| if not result.is_success: | if not result.is_success: | ||||
| self._log.debug('Error creating new customer: message=%s errors=%s', | self._log.debug( | ||||
| result.message, result.errors) | 'Error creating new customer: message=%s errors=%s', result.message, result.errors | ||||
| ) | |||||
| raise BraintreeError(result) | raise BraintreeError(result) | ||||
| return result.customer.id | customer_id = result.customer.id | ||||
| assert isinstance(customer_id, str) | |||||
| return customer_id | |||||
| def payment_method_create( | def payment_method_create( | ||||
| self, payment_method_nonce: str, | self, | ||||
| payment_method_nonce: str, | |||||
| gateway_customer_id: str, | gateway_customer_id: str, | ||||
| verification_data: typing.Optional[typing.Any] = None, | verification_data: Optional[Any] = None, | ||||
| ) -> PaymentMethodInfo: | ) -> PaymentMethodInfo: | ||||
| """Create a payment method at BrainTree. | """Create a payment method at BrainTree. | ||||
| :param verification_data: `device_data` to be passed to Payment Method: Create. | :param verification_data: `device_data` to be passed to Payment Method: Create. | ||||
| See https://developers.braintreepayments.com/reference/request/payment-method/create/python | See https://developers.braintreepayments.com/reference/request/payment-method/create/python | ||||
| """ | """ | ||||
| result = self.braintree.payment_method.create({ | result = self.braintree.payment_method.create( | ||||
| { | |||||
| 'customer_id': gateway_customer_id, | 'customer_id': gateway_customer_id, | ||||
| 'payment_method_nonce': payment_method_nonce, | 'payment_method_nonce': payment_method_nonce, | ||||
| 'options': { | 'options': { | ||||
| 'make_default': True, | 'make_default': True, | ||||
| # **N.B**: | # **N.B**: | ||||
| # 3D Secure information is lost when a 3D Secured payment_method_nonce | # 3D Secure information is lost when a 3D Secured payment_method_nonce | ||||
| # is used in a "Customer: Create" or a "Payment Method: Create" call | # is used in a "Customer: Create" or a "Payment Method: Create" call | ||||
| # without a verify_card flag. | # without a verify_card flag. | ||||
| 'verify_card': True, | 'verify_card': True, | ||||
| }, | }, | ||||
| 'device_data': verification_data, | 'device_data': verification_data, | ||||
| }) | } | ||||
| ) | |||||
| if not result.is_success: | if not result.is_success: | ||||
| self._log.debug('Error creating payment method: message=%s errors=%s', | self._log.debug( | ||||
| result.message, result.errors) | 'Error creating payment method: message=%s errors=%s', | ||||
| result.message, | |||||
| result.errors, | |||||
| ) | |||||
| raise BraintreeError(result) | raise BraintreeError(result) | ||||
| return PaymentMethodInfo.from_braintree(result.payment_method) | return PaymentMethodInfo.from_braintree(result.payment_method) | ||||
| def payment_method_delete(self, payment_method_token: str) -> None: | def payment_method_delete(self, payment_method_token: str) -> None: | ||||
| """Delete a payment method at BrainTree. | """Delete a payment method at BrainTree. | ||||
| See https://developers.braintreepayments.com/reference/request/payment-method/delete/python | See https://developers.braintreepayments.com/reference/request/payment-method/delete/python | ||||
| """ | """ | ||||
| from braintree.exceptions import not_found_error, braintree_error | from braintree.exceptions import not_found_error, braintree_error | ||||
| try: | try: | ||||
| result = self.braintree.payment_method.delete(payment_method_token) | result = self.braintree.payment_method.delete(payment_method_token) | ||||
| except not_found_error.NotFoundError: | except not_found_error.NotFoundError: | ||||
| # Deleting something that isn't there is fine. | # Deleting something that isn't there is fine. | ||||
| self._log.debug('Error deleting payment method, token was unknown at Braintree.') | self._log.debug('Error deleting payment method, token was unknown at Braintree.') | ||||
| return | return | ||||
| except braintree_error.BraintreeError as ex: | except braintree_error.BraintreeError as ex: | ||||
| self._log.info('Error deleting payment token: %s', ex) | self._log.info('Error deleting payment token: %s', ex) | ||||
| raise looper.exceptions.GatewayError(message=str(ex)) | raise looper.exceptions.GatewayError(message=str(ex)) | ||||
| if not result.is_success: | if not result.is_success: | ||||
| self._log.debug('Error deleting payment method: message=%s errors=%s', | self._log.debug( | ||||
| result.message, result.errors) | 'Error deleting payment method: message=%s errors=%s', | ||||
| result.message, | |||||
| result.errors, | |||||
| ) | |||||
| raise BraintreeError(result) | raise BraintreeError(result) | ||||
| def find_customer_payment_method(self, payment_method_token: str) -> PaymentMethodInfo: | def find_customer_payment_method(self, payment_method_token: str) -> PaymentMethodInfo: | ||||
| from braintree.exceptions.not_found_error import NotFoundError | from braintree.exceptions.not_found_error import NotFoundError | ||||
| try: | try: | ||||
| payment_method = self.braintree.payment_method.find(payment_method_token) | payment_method = self.braintree.payment_method.find(payment_method_token) | ||||
| except NotFoundError: | except NotFoundError: | ||||
| raise looper.exceptions.NotFoundError('Payment gateway not found') | raise looper.exceptions.NotFoundError('Payment gateway not found') | ||||
| return PaymentMethodInfo.from_braintree(payment_method) | return PaymentMethodInfo.from_braintree(payment_method) | ||||
| def transact_sale(self, payment_method_token: str, amount: Money) -> str: | def transact_sale(self, payment_method_token: str, amount: Money) -> str: | ||||
| """Perform a sale by requesting a monetary amount to be paid. | """Perform a sale by requesting a monetary amount to be paid. | ||||
| See https://developers.braintreepayments.com/reference/request/transaction/sale/python | See https://developers.braintreepayments.com/reference/request/transaction/sale/python | ||||
| :return: The transaction ID of the payment gateway. | :return: The transaction ID of the payment gateway. | ||||
| :raise looper.exceptions.CurrencyNotSupported: when the amount has a | :raise looper.exceptions.CurrencyNotSupported: when the amount has a | ||||
| currency for which there is no configured Merchant Account ID. | currency for which there is no configured Merchant Account ID. | ||||
| :raise looper.exceptions.GatewayError: when there was an error | :raise looper.exceptions.GatewayError: when there was an error | ||||
| performing the transaction at Braintree. | performing the transaction at Braintree. | ||||
| """ | """ | ||||
| self._log.debug('Performing transaction of %s', amount) | self._log.debug('Performing transaction of %s', amount) | ||||
| ma_id = self._merchant_account_id(amount.currency) | ma_id = self._merchant_account_id(amount.currency) | ||||
| result = self.braintree.transaction.sale({ | result = self.braintree.transaction.sale( | ||||
| { | |||||
| 'payment_method_token': payment_method_token, | 'payment_method_token': payment_method_token, | ||||
| 'amount': amount.decimals_string, | 'amount': amount.decimals_string, | ||||
| 'merchant_account_id': ma_id, | 'merchant_account_id': ma_id, | ||||
| 'options': { | 'options': {'submit_for_settlement': True}, | ||||
| 'submit_for_settlement': True, | } | ||||
| }, | ) | ||||
| }) | |||||
| if not result.is_success: | if not result.is_success: | ||||
| raise BraintreeError(result) | raise BraintreeError(result) | ||||
| self._log.info('Transaction %r for amount %s was successful with status=%r', | self._log.info( | ||||
| result.transaction.id, amount, result.transaction.status) | 'Transaction %r for amount %s was successful with status=%r', | ||||
| return result.transaction.id | result.transaction.id, | ||||
| amount, | |||||
| result.transaction.status, | |||||
| ) | |||||
| transaction_id = result.transaction.id | |||||
| assert isinstance(transaction_id, str) | |||||
| return transaction_id | |||||
| def find_transaction(self, transaction_id): | def find_transaction(self, transaction_id: str) -> Dict[str, object]: | ||||
| transaction = self.braintree.transaction.find(transaction_id) | transaction = self.braintree.transaction.find(transaction_id) | ||||
| return {'transaction_id': transaction.id} | return {'transaction_id': transaction.id} | ||||
| def refund(self, gateway_transaction_id: str, amount: Money): | def refund(self, gateway_transaction_id: str, amount: Money) -> None: | ||||
| """Refund a transaction. | """Refund a transaction. | ||||
| :param: gateway_transaction_id: the transaction ID on the gateway. | :param: gateway_transaction_id: the transaction ID on the gateway. | ||||
| :param amount: The amount to refund, may not be more than the original | :param amount: The amount to refund, may not be more than the original | ||||
| transaction amount. | transaction amount. | ||||
| See https://developers.braintreepayments.com/reference/request/transaction/refund/python | See https://developers.braintreepayments.com/reference/request/transaction/refund/python | ||||
| """ | """ | ||||
| from braintree.exceptions import not_found_error, braintree_error | from braintree.exceptions import not_found_error, braintree_error | ||||
| self._log.debug('Refunding transaction %r for %s', gateway_transaction_id, amount) | self._log.debug('Refunding transaction %r for %s', gateway_transaction_id, amount) | ||||
| try: | try: | ||||
| result = self.braintree.transaction.refund(gateway_transaction_id, | result = self.braintree.transaction.refund( | ||||
| amount.decimals_string) | gateway_transaction_id, amount.decimals_string | ||||
| ) | |||||
| except not_found_error.NotFoundError as ex: | except not_found_error.NotFoundError as ex: | ||||
| self._log.debug('Error refunding, transaction %r was unknown at Braintree: %s', | self._log.debug( | ||||
| gateway_transaction_id, ex) | 'Error refunding, transaction %r was unknown at Braintree: %s', | ||||
| gateway_transaction_id, | |||||
| ex, | |||||
| ) | |||||
| raise looper.exceptions.GatewayError( | raise looper.exceptions.GatewayError( | ||||
| message=f'transaction {gateway_transaction_id} was unknown at Braintree') | message=f'transaction {gateway_transaction_id} was unknown at Braintree' | ||||
| ) | |||||
| except braintree_error.BraintreeError as ex: | except braintree_error.BraintreeError as ex: | ||||
| self._log.info('Error refunding transaction %r: %s', gateway_transaction_id, ex) | self._log.info('Error refunding transaction %r: %s', gateway_transaction_id, ex) | ||||
| raise looper.exceptions.GatewayError(message=str(ex)) | raise looper.exceptions.GatewayError(message=str(ex)) | ||||
| if not result.is_success: | if not result.is_success: | ||||
| raise BraintreeError(result) | raise BraintreeError(result) | ||||
| self._log.info('Transaction %r refund for amount %s was successful', | self._log.info( | ||||
| gateway_transaction_id, amount) | 'Transaction %r refund for amount %s was successful', gateway_transaction_id, amount | ||||
| ) | |||||
| class BankGateway(AbstractPaymentGateway): | class BankGateway(AbstractPaymentGateway): | ||||
| """Payment gateway to support bank payments. | """Payment gateway to support bank payments. | ||||
| Requires manual intervention in the admin to mark orders as paid. | Requires manual intervention in the admin to mark orders as paid. | ||||
| """ | """ | ||||
| gateway_name: str = 'bank' | gateway_name: str = 'bank' | ||||
| supported_collection_methods = {'manual'} | supported_collection_methods = {'manual'} | ||||
| supports_refunds = False | supports_refunds = False | ||||
| supports_transactions = False | supports_transactions = False | ||||
| _log = log.getChild('BankGateway') | _log = log.getChild('BankGateway') | ||||
| def generate_client_token(self, for_currency: str, | def generate_client_token( | ||||
| gateway_customer_id: typing.Optional[str] = None) -> str: | self, for_currency: str, gateway_customer_id: Optional[str] = None | ||||
| ) -> str: | |||||
| return '' | return '' | ||||
| def customer_create(self) -> str: | def customer_create(self) -> str: | ||||
| """Always return the same meaningless customer ID.""" | """Always return the same meaningless customer ID.""" | ||||
| return 'bank' | return 'bank' | ||||
| def payment_method_create(self, | def payment_method_create( | ||||
| self, | |||||
| payment_method_nonce: str, | payment_method_nonce: str, | ||||
| gateway_customer_id: str, | gateway_customer_id: str, | ||||
| verification_data: typing.Optional[typing.Any] = None, | verification_data: Optional[Any] = None, | ||||
| ) -> PaymentMethodInfo: | ) -> PaymentMethodInfo: | ||||
| """Always return the same PaymentMethodInfo""" | """Always return the same PaymentMethodInfo""" | ||||
| return PaymentMethodInfo('bank', PaymentMethodInfo.Type.BANK_ACCOUNT) | return PaymentMethodInfo('bank', PaymentMethodInfo.Type.BANK_ACCOUNT) | ||||
| def payment_method_delete(self, payment_method_token: str) -> None: | def payment_method_delete(self, payment_method_token: str) -> None: | ||||
| """Does nothing, as the gateway itself (that is, the bank) does nothing.""" | """Does nothing, as the gateway itself (that is, the bank) does nothing.""" | ||||
| def find_customer_payment_method(self, payment_method_token: str) -> PaymentMethodInfo: | def find_customer_payment_method(self, payment_method_token: str) -> PaymentMethodInfo: | ||||
| """Always return the same PaymentMethodInfo""" | """Always return the same PaymentMethodInfo""" | ||||
| return PaymentMethodInfo('bank', PaymentMethodInfo.Type.BANK_ACCOUNT) | return PaymentMethodInfo('bank', PaymentMethodInfo.Type.BANK_ACCOUNT) | ||||
| def transact_sale(self, payment_method_token: str, amount: Money) -> str: | def transact_sale(self, payment_method_token: str, amount: Money) -> str: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to BankGateway.transact_sale' | f'Unexpected call to BankGateway.transact_sale' | ||||
| f'({payment_method_token!r}, {amount!r})') | f'({payment_method_token!r}, {amount!r})' | ||||
| ) | |||||
| def find_transaction(self, transaction_id) -> dict: | def find_transaction(self, transaction_id: str) -> Dict[str, object]: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to BankGateway.find_transaction({transaction_id!r})') | f'Unexpected call to BankGateway.find_transaction({transaction_id!r})' | ||||
| ) | |||||
| def refund(self, gateway_transaction_id: str, amount: Money): | def refund(self, gateway_transaction_id: str, amount: Money) -> None: | ||||
| raise NotImplementedError( | raise NotImplementedError( | ||||
| f'Unexpected call to BankGateway.refund({gateway_transaction_id!r}, {amount})') | f'Unexpected call to BankGateway.refund({gateway_transaction_id!r}, {amount})' | ||||
| ) | |||||