├── yappa ├── __init__.py ├── exceptions.py ├── settings.py ├── utils.py ├── tests │ ├── test_adaptive_base.py │ ├── test_utils.py │ ├── test_models.py │ ├── test_payment.py │ └── test_preapproval.py ├── models.py └── api.py ├── requirements.txt ├── .gitignore ├── TODO.md ├── setup.py └── README.md /yappa/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (0, 0, 1) 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.10.0 2 | pytz==2016.4 3 | nose==1.3.7 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *~ 3 | *.swp 4 | *.pyc 5 | Thumbs.db 6 | .idea 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /yappa/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class AdaptiveApiException(Exception): 3 | pass 4 | 5 | 6 | class PayException(AdaptiveApiException): 7 | pass 8 | 9 | 10 | class PreApprovalException(AdaptiveApiException): 11 | pass 12 | 13 | 14 | class InvalidReceiverException(AdaptiveApiException): 15 | pass 16 | -------------------------------------------------------------------------------- /yappa/settings.py: -------------------------------------------------------------------------------- 1 | 2 | class Settings(object): 3 | def __init__(self, debug=False): 4 | 5 | if debug: 6 | self.PAYPAL_ENDPOINT = 'https://svcs.sandbox.paypal.com/AdaptivePayments' 7 | self.PAYPAL_AUTH_URL = 'https://www.sandbox.paypal.com/cgi-bin/webscr' 8 | self.PAYAPL_APP_ID = 'APP-80W284485P519543T' 9 | 10 | else: 11 | self.PAYPAL_ENDPOINT = 'https://svcs.paypal.com/AdaptivePayments' 12 | self.PAYPAL_AUTH_URL = 'https://www.paypal.com/webscr' 13 | self.PAYAPL_APP_ID = None 14 | -------------------------------------------------------------------------------- /yappa/utils.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | from datetime import datetime, timezone 3 | 4 | import pytz 5 | 6 | 7 | def current_local_time(zone='Asia/Taipei'): 8 | """ 9 | Get current time with specified time zone 10 | 11 | @param zone: zone name 12 | @return: 13 | """ 14 | now = datetime.now(timezone.utc) 15 | local_now = now.astimezone(pytz.timezone(zone)) 16 | 17 | return local_now 18 | 19 | 20 | def decimal_default(obj): 21 | """ 22 | Used for json.dumps() 23 | 24 | @param obj: 25 | @return: 26 | """ 27 | if isinstance(obj, decimal.Decimal): 28 | return float(obj) 29 | raise TypeError 30 | 31 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | 2 | ## Add Django integration sample 3 | 4 | ### Preapproval with Django form 5 | ``` 6 | from django.conf import settings 7 | debug = hasattr(settings, 'DEBUG', False) 8 | 9 | paypal_credentials = 10 | 11 | preapproval = PreApproval(credentials=paypal_credentials, debug=debug) 12 | 13 | data = form.cleaned_data 14 | preapproval.request(**data) 15 | 16 | ``` 17 | 18 | ## Questions 19 | 20 | - In `Pay` operation, is the `ClientDetails` field necessary? 21 | 22 | - Need to support other payment operations? (Chained, Delayed Chained) 23 | 24 | ## TODO 25 | 26 | - Need to check there should be only one primary receiver 27 | 28 | - Implement other action types for payment? (CREATE, PAY_PRIMARY) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | import yappa 5 | 6 | REQUIREMENTS = [ 7 | 'requests==2.10.0', 8 | 'pytz==2016.4' 9 | ] 10 | 11 | setup( 12 | name='yappa', 13 | version=".".join(map(str, yappa.__version__)), 14 | author='Spin Lai', 15 | author_email='pengo.lai@gmail.com', 16 | install_requires=REQUIREMENTS, 17 | description='Another Python library for integrating PayPal Adaptive Payments', 18 | packages=find_packages(), 19 | include_package_data=True, 20 | classifiers=[ 21 | 'Environment :: Web Environment', 22 | "Intended Audience :: Developers", 23 | "Operating System :: OS Independent", 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 3.5', 26 | 'Topic :: Software Development :: Libraries :: Python Modules' 27 | ], 28 | test_suite='nose.collector', 29 | tests_require=[ 30 | 'nose' 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /yappa/tests/test_adaptive_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from yappa.api import AdaptiveApiBase 4 | 5 | 6 | class AdaptiveBaseTestCase(unittest.TestCase): 7 | def setUp(self): 8 | pass 9 | 10 | def tearDown(self): 11 | pass 12 | 13 | def test_build_failure_response(self): 14 | raw_response = { 15 | 'error': [{ 16 | 'category': 'Application', 17 | 'domain': 'PLATFORM', 18 | 'errorId': '580022', 19 | 'message': 'Invalid request parameter: preapprovalKey with value ABCD', 20 | 'parameter': ['preapprovalKey', 'ABCD'], 21 | 'severity': 'Error', 22 | 'subdomain': 'Application' 23 | }], 24 | 'responseEnvelope': { 25 | 'ack': 'Failure', 26 | 'build': '20420247', 27 | 'correlationId': '9a2ae1abba0ac', 28 | 'timestamp': '2016-05-29T09:13:32.007-07:00' 29 | } 30 | } 31 | 32 | resp = AdaptiveApiBase.build_failure_response(raw_response) 33 | 34 | self.assertEquals(resp.ack, 'Failure') 35 | self.assertEquals(resp.errorId, '580022'), 36 | self.assertEquals(resp.timestamp, '2016-05-29T09:13:32.007-07:00') 37 | self.assertEquals(resp.message, 'Invalid request parameter: preapprovalKey with value ABCD') -------------------------------------------------------------------------------- /yappa/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from unittest.mock import patch 4 | from datetime import datetime, timezone 5 | from pytz.exceptions import UnknownTimeZoneError 6 | from decimal import Decimal 7 | 8 | from yappa.utils import current_local_time 9 | from yappa.utils import decimal_default 10 | 11 | 12 | class UtilsTestCase(unittest.TestCase): 13 | def setUp(self): 14 | pass 15 | 16 | def tearDown(self): 17 | pass 18 | 19 | @patch('yappa.utils.datetime') 20 | def test_current_local_time_taipei(self, mock_datetime): 21 | mock_now = datetime(2016, 5, 27, 16, 33, 0, 0, tzinfo=timezone.utc) 22 | mock_datetime.now.return_value = mock_now 23 | 24 | local_now = current_local_time('Asia/Taipei') 25 | 26 | self.assertEqual(local_now.isoformat(), '2016-05-28T00:33:00+08:00') 27 | 28 | @patch('yappa.utils.datetime') 29 | def test_current_local_time_canada(self, mock_datetime): 30 | mock_now = datetime(2016, 5, 27, 16, 33, 0, 0, tzinfo=timezone.utc) 31 | mock_datetime.now.return_value = mock_now 32 | 33 | local_now = current_local_time('Canada/Central') 34 | 35 | self.assertEqual(local_now.isoformat(), '2016-05-27T11:33:00-05:00') 36 | 37 | @patch('yappa.utils.datetime') 38 | def test_current_local_time_with_invalid_zone(self, mock_datetime): 39 | mock_now = datetime(2016, 5, 27, 16, 33, 0, 0, tzinfo=timezone.utc) 40 | mock_datetime.now.return_value = mock_now 41 | 42 | with self.assertRaises(UnknownTimeZoneError) as e: 43 | current_local_time('invalid/timezone') 44 | 45 | def test_decimal_default(self): 46 | product = { 47 | 'price': Decimal('55.12') 48 | } 49 | 50 | result = json.dumps(product, default=decimal_default) 51 | self.assertEqual(result, '{"price": 55.12}') 52 | 53 | def test_decimal_default_with_non_decimal(self): 54 | product = { 55 | 'name': 'Product 1' 56 | } 57 | 58 | result = json.dumps(product, default=decimal_default) 59 | self.assertEqual(result, '{"name": "Product 1"}') 60 | -------------------------------------------------------------------------------- /yappa/models.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from yappa.exceptions import InvalidReceiverException 4 | 5 | 6 | class Receiver(object): 7 | 8 | def __init__(self, *, email, amount, primary=None): # Only keyword arguments accepted 9 | if not isinstance(amount, Decimal): 10 | raise InvalidReceiverException('amount needs to be instance of Decimal') 11 | elif primary is not None and not isinstance(primary, bool): 12 | raise InvalidReceiverException('primary argument needs to be Boolean type') 13 | 14 | self.email = email 15 | self.amount = amount 16 | self.primary = primary 17 | 18 | def to_dict(self): 19 | result = { 20 | 'email': self.email, 21 | 'amount': str(self.amount) 22 | } 23 | 24 | if self.primary is not None: 25 | result['primary'] = str(self.primary).lower() 26 | 27 | return result 28 | 29 | def __unicode__(self): 30 | return self.email 31 | 32 | def __repr__(self): 33 | return ''.format(self.email) 34 | 35 | 36 | class ReceiverList(object): 37 | MAX_RECEIVER_AMOUNT = 6 38 | 39 | def __init__(self, receivers=None): 40 | self.receivers = [] 41 | 42 | if receivers and len(receivers) > self.MAX_RECEIVER_AMOUNT: 43 | raise InvalidReceiverException('each payment request has a maximum of {} receivers'. 44 | format(self.MAX_RECEIVER_AMOUNT)) 45 | 46 | if receivers is not None: 47 | for receiver in receivers: 48 | self.append(receiver) 49 | 50 | def __len__(self): 51 | return len(self.receivers) 52 | 53 | def append(self, receiver): 54 | if not isinstance(receiver, Receiver): 55 | raise InvalidReceiverException('receiver needs to be instance of yappa.models.Reciever') 56 | 57 | if len(self.receivers) == self.MAX_RECEIVER_AMOUNT: 58 | raise InvalidReceiverException('each payment request has a maximum of {} receivers'. 59 | format(self.MAX_RECEIVER_AMOUNT)) 60 | 61 | self.receivers.append(receiver) 62 | 63 | def to_json(self): 64 | return { 65 | 'receiver': [receiver.to_dict() for receiver in self.receivers] 66 | } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yet Another Python Paypal Adaptive 2 | 3 | A simple python wrapper of Paypal Adaptive APIs. 4 | 5 | 6 | ## Prerequisites 7 | 8 | - Python >= 3.4 9 | - requests >= 2.10.0 10 | 11 | ## Installation 12 | ``` 13 | python setup.py install 14 | ``` 15 | 16 | ## Usage 17 | 18 | ### Example of credentials 19 | ``` 20 | credentials = { 21 | 'PAYPAL_USER_ID': , 22 | 'PAYPAL_PASSWORD': , 23 | 'PAYPAL_SIGNATURE': , 24 | 'PAYPAL_APP_ID': 25 | } 26 | ``` 27 | 28 | ### Example of request preapproval for future payments 29 | ``` 30 | from decimal import Decimal 31 | from yappa.api import PreAproval 32 | 33 | # debug=True will use sandbox 34 | preapproval = PreApproval(credentials, debug=True) 35 | 36 | resp = preapproval.request( 37 | startingDate='2016-05-28T00:33:00+08:0', 38 | endingDate='2016-06-28T00:33:00+08:0', 39 | currencyCode='USD', 40 | returnUrl='http://return.url', 41 | cancelUrl='http://cancel.url', 42 | maxAmountPerPayment=Decimal('50.00'), 43 | maxNumberOfPayments=20, 44 | maxTotalAmountOfAllPayments=Decimal('1500.00') 45 | ) 46 | 47 | # Get preapproval key and authorization URL 48 | preapproval_key = resp.preapprovalKey # e.g. 'PA-111111111' 49 | auth_url = resp.nextUrl # e.g. 'https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_ap-preapproval&preapprovalkey=PA-111111111' 50 | ``` 51 | 52 | ### Example of capture payments 53 | ``` 54 | from decimal import Decimal 55 | from yappa.api import Pay 56 | from yappa.models import Receiver, ReceiverList 57 | 58 | receivers = [ 59 | Receiver(email='receiver1@gmail.com', amount=Decimal('10.00')), 60 | Receiver(email='receiver2@gmail.com', amount=Decimal('15.00')) 61 | ] 62 | 63 | receiver_list = ReceiverList(receivers) 64 | 65 | pay = Pay(self.credentials, debug=True) 66 | 67 | resp = pay.request( 68 | currencyCode='USD', 69 | returnUrl='http://return.url', 70 | cancelUrl='http://cancel.url', 71 | senderEmail='sender@gmail.com', 72 | memo='some message', 73 | receiverList=receiver_list 74 | ) 75 | 76 | # Get pay key and payment details 77 | pay_key = resp.payKey 78 | exec_status = resp.paymentExecStatus # e.g. 'COMPLETED' 79 | sender = resp.sender # e.g. {'accountId': 'XXXAAABBB'} 80 | payment_info = resp.paymentInfoList 81 | ``` 82 | 83 | ### Example of failure response 84 | ``` 85 | pay = Pay(self.credentials, debug=True) 86 | resp = pay.request(...) 87 | 88 | if resp.ack == 'Failure': 89 | error_id = resp.errorId # e.g. '579040' 90 | message = resp.message # e.g. 'Receiver PayPal accounts must be unique.' 91 | timestamp = resp.timestamp # e.g. '2016-05-30T10:27:03.931-07:00' 92 | ``` -------------------------------------------------------------------------------- /yappa/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from decimal import Decimal 3 | 4 | from yappa.models import Receiver, ReceiverList 5 | from yappa.exceptions import InvalidReceiverException 6 | 7 | 8 | class ModelTestCase(unittest.TestCase): 9 | def setUp(self): 10 | self.receiver_email = 'fake_receiver@gmail.com' 11 | 12 | def tearDown(self): 13 | pass 14 | 15 | def test_init_receiver_with_empty_email(self): 16 | with self.assertRaises(TypeError) as context: 17 | Receiver(amount=Decimal('12.5')) 18 | 19 | def test_init_receiver_with_empty_amount(self): 20 | with self.assertRaises(TypeError) as context: 21 | Receiver(email=self.receiver_email) 22 | 23 | def test_init_receiver_with_invalid_amount(self): 24 | with self.assertRaises(InvalidReceiverException) as context: 25 | Receiver(email=self.receiver_email, amount=22.2) 26 | 27 | self.assertEquals(context.exception.args[0], 'amount needs to be instance of Decimal') 28 | 29 | def test_init_receiver_with_invalid_primary_argument(self): 30 | with self.assertRaises(InvalidReceiverException) as context: 31 | Receiver(email=self.receiver_email, amount=Decimal('22.2'), primary='True') 32 | 33 | self.assertEquals(context.exception.args[0], 'primary argument needs to be Boolean type') 34 | 35 | def test_primary_receiver(self): 36 | receiver = Receiver(email=self.receiver_email, amount=Decimal('22.2'), primary=True) 37 | 38 | self. assertEquals(receiver.to_dict(), { 39 | 'email': self.receiver_email, 40 | 'amount': '22.2', 41 | 'primary': 'true' 42 | }) 43 | 44 | def test_not_primary_receiver(self): 45 | receiver = Receiver(email=self.receiver_email, amount=Decimal('22.2'), primary=False) 46 | 47 | self.assertEquals(receiver.to_dict(), { 48 | 'email': self.receiver_email, 49 | 'amount': '22.2', 50 | 'primary': 'false' 51 | }) 52 | 53 | def test_init_receiver_list_exceed_the_maximum(self): 54 | receivers = [Receiver(email='receiver{}@gmail.com'.format(i+1), 55 | amount=Decimal('10.0')) for i in range(7)] 56 | 57 | with self.assertRaises(InvalidReceiverException) as context: 58 | ReceiverList(receivers) 59 | 60 | self.assertEquals(context.exception.args[0], 'each payment request has a maximum of 6 receivers') 61 | 62 | def test_append_receiver_that_exceed_the_maxium(self): 63 | receivers = [Receiver(email='receiver{}@gmail.com'.format(i + 1), 64 | amount=Decimal('10.0')) for i in range(6)] 65 | 66 | receiver_list = ReceiverList(receivers) 67 | 68 | with self.assertRaises(InvalidReceiverException) as context: 69 | receiver_list.append(Receiver(email='boom@gmail.com', amount=Decimal('66.6'))) 70 | 71 | self.assertEquals(context.exception.args[0], 'each payment request has a maximum of 6 receivers') 72 | 73 | def test_append_invalid_receiver(self): 74 | receiver_list = ReceiverList() 75 | 76 | with self.assertRaises(InvalidReceiverException) as context: 77 | receiver_list.append({}) 78 | 79 | self.assertEquals(context.exception.args[0], 'receiver needs to be instance of yappa.models.Reciever') 80 | 81 | def test_init_receiver_list_successfully(self): 82 | receivers = [Receiver(email='receiver{}@gmail.com'.format(i + 1), 83 | amount=Decimal(10+i*5)) for i in range(3)] 84 | 85 | receiver_list = ReceiverList(receivers) 86 | 87 | self.assertEquals(receiver_list.to_json(), { 88 | 'receiver': [ 89 | {'email': 'receiver1@gmail.com', 'amount': '10'}, 90 | {'email': 'receiver2@gmail.com', 'amount': '15'}, 91 | {'email': 'receiver3@gmail.com', 'amount': '20'}, 92 | ] 93 | }) 94 | 95 | def test_append_receiver_successfully(self): 96 | receiver_list = ReceiverList([Receiver(email='first@gmail.com', amount=Decimal('11.1'))]) 97 | 98 | self.assertEquals(receiver_list.to_json(), { 99 | 'receiver': [{'email': 'first@gmail.com', 'amount': '11.1'}] 100 | }) 101 | 102 | receiver_list.append(Receiver(email='second@gmail.com', amount=Decimal('22.2'))) 103 | 104 | self.assertEquals(receiver_list.to_json(), { 105 | 'receiver': [ 106 | {'email': 'first@gmail.com', 'amount': '11.1'}, 107 | {'email': 'second@gmail.com', 'amount': '22.2'}, 108 | ] 109 | }) 110 | -------------------------------------------------------------------------------- /yappa/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | from abc import ABCMeta, abstractmethod 3 | from collections import namedtuple 4 | 5 | import requests 6 | 7 | from .settings import Settings 8 | from .utils import decimal_default 9 | from .models import ReceiverList 10 | from .exceptions import InvalidReceiverException 11 | 12 | 13 | class AdaptiveApiBase(metaclass=ABCMeta): 14 | 15 | def __init__(self, credentials, debug=False): 16 | settings = Settings(debug=debug) 17 | 18 | self.endpoint = settings.PAYPAL_ENDPOINT 19 | self.auth_url = settings.PAYPAL_AUTH_URL 20 | self.credentials = credentials 21 | 22 | self.headers = {} 23 | self.payload = { 24 | 'requestEnvelope': { 25 | 'errorLanguage': 'en_US', 26 | } 27 | } 28 | 29 | self._build_headers() 30 | 31 | def _build_headers(self): 32 | headers = { 33 | 'X-PAYPAL-SECURITY-USERID': self.credentials['PAYPAL_USER_ID'], 34 | 'X-PAYPAL-SECURITY-PASSWORD': self.credentials['PAYPAL_PASSWORD'], 35 | 'X-PAYPAL-SECURITY-SIGNATURE': self.credentials['PAYPAL_SIGNATURE'], 36 | 'X-PAYPAL-APPLICATION-ID': self.credentials['PAYPAL_APP_ID'], 37 | 'X-PAYPAL-REQUEST-DATA-FORMAT': 'JSON', 38 | 'X-PAYPAL-RESPONSE-DATA-FORMAT': 'JSON', 39 | } 40 | 41 | self.headers.update(headers) 42 | 43 | @staticmethod 44 | def build_failure_response(response_json): 45 | """ 46 | Build PayPal request failure response object 47 | 48 | @param response_json: response dictionary 49 | @return: custom response object 50 | """ 51 | ApiResponse = namedtuple('ApiResponse', ['ack', 'message', 'errorId', 'timestamp']) 52 | ack = response_json['responseEnvelope']['ack'] 53 | timestamp = response_json['responseEnvelope']['timestamp'] 54 | error = response_json['error'][0] 55 | 56 | return ApiResponse( 57 | ack=ack, 58 | errorId=error.get('errorId'), 59 | message=error.get('message'), 60 | timestamp=timestamp) 61 | 62 | def request(self, *args, **kwargs): 63 | self.payload.update(self.build_payload(*args, **kwargs)) 64 | 65 | response = requests.post(self.endpoint, 66 | data=json.dumps(self.payload, default=decimal_default), 67 | headers=self.headers) 68 | 69 | return self.build_response(response.json()) 70 | 71 | @abstractmethod 72 | def build_payload(self, *args, **kwargs): 73 | pass 74 | 75 | @abstractmethod 76 | def build_response(self, response): 77 | pass 78 | 79 | 80 | class PreApproval(AdaptiveApiBase): 81 | 82 | def __init__(self, *args, **kwargs): 83 | super().__init__(*args, **kwargs) 84 | self.endpoint = '{}/{}'.format(self.endpoint, 'Preapproval') 85 | 86 | def build_payload(self, *args, **kwargs): 87 | return { 88 | 'startingDate': kwargs.get('startingDate'), 89 | 'endingDate': kwargs.get('endingDate'), 90 | 'returnUrl': kwargs.get('returnUrl'), 91 | 'cancelUrl': kwargs.get('cancelUrl'), 92 | 'currencyCode': kwargs.get('currencyCode'), 93 | 'maxAmountPerPayment': kwargs.get('maxAmountPerPayment'), 94 | 'maxNumberOfPayments': kwargs.get('maxNumberOfPayments'), 95 | 'maxTotalAmountOfAllPayments': kwargs.get('maxTotalAmountOfAllPayments') 96 | } 97 | 98 | def build_response(self, response): 99 | ack = response['responseEnvelope']['ack'] 100 | 101 | if ack in ('Success', 'SuccessWithWarning'): 102 | ApiResponse = namedtuple('ApiResponse', ['ack', 'preapprovalKey', 'nextUrl']) 103 | key = response.get('preapprovalKey') 104 | next_url = '' 105 | 106 | if self.auth_url and key: 107 | next_url = '{}?cmd=_ap-preapproval&preapprovalkey={}'.format(self.auth_url, key) 108 | 109 | api_response = ApiResponse(ack=ack, preapprovalKey=key, nextUrl=next_url) 110 | 111 | else: # ack in ('Failure', 'FailureWithWarning') 112 | api_response = self.build_failure_response(response) 113 | 114 | return api_response 115 | 116 | 117 | class PreApprovalDetails(AdaptiveApiBase): 118 | 119 | def __init__(self, *args, **kwargs): 120 | super().__init__(*args, **kwargs) 121 | self.endpoint = '{}/{}'.format(self.endpoint, 'PreapprovalDetails') 122 | 123 | def build_payload(self, *args, **kwargs): 124 | return { 125 | 'preapprovalKey': kwargs.get('preapprovalKey'), 126 | } 127 | 128 | def build_response(self, response): 129 | ack = response['responseEnvelope']['ack'] 130 | response_fields = ['ack', 'approved', 'cancelUrl', 'curPayments', 'curPaymentsAmount', 131 | 'curPeriodAttempts', 'currencyCode', 'dateOfMonth', 'dayOfWeek', 132 | 'displayMaxTotalAmount', 'endingDate', 'maxTotalAmountOfAllPayments', 133 | 'paymentPeriod', 'pinType', 'returnUrl', 'startingDate', 'status', 134 | 'sender', 'senderEmail'] 135 | 136 | if ack in ('Success', 'SuccessWithWarning'): 137 | ApiResponse = namedtuple('ApiResponse', response_fields) 138 | response_kwargs = {field: ack if field == 'ack' else response.get(field) for field in response_fields} 139 | 140 | api_response = ApiResponse(**response_kwargs) 141 | 142 | else: 143 | api_response = self.build_failure_response(response) 144 | 145 | return api_response 146 | 147 | 148 | class Pay(AdaptiveApiBase): 149 | DEFAULT_FEES_PAYER = 'EACHRECEIVER' 150 | 151 | def __init__(self, *args, **kwargs): 152 | super().__init__(*args, **kwargs) 153 | self.endpoint = '{}/{}'.format(self.endpoint, 'Pay') 154 | 155 | def build_payload(self, *args, **kwargs): 156 | receiver_list = kwargs.get('receiverList') 157 | preapproval_key = kwargs.get('preapprovalKey', None) 158 | memo = kwargs.get('memo', None) 159 | 160 | if not isinstance(receiver_list, ReceiverList): 161 | raise InvalidReceiverException('receiverList needs to be instance of yappa.models.RecieverList') 162 | 163 | payload = { 164 | 'actionType': 'PAY', 165 | 'feesPayer': kwargs.get('feesPayer', self.DEFAULT_FEES_PAYER), 166 | 'currencyCode': kwargs.get('currencyCode'), 167 | 'senderEmail': kwargs.get('senderEmail'), 168 | 'receiverList': receiver_list.to_json(), 169 | 'returnUrl': kwargs.get('returnUrl'), 170 | 'cancelUrl': kwargs.get('cancelUrl'), 171 | } 172 | 173 | if preapproval_key is not None: 174 | payload['preapprovalKey'] = preapproval_key 175 | 176 | if memo is not None and memo.strip() != '': 177 | payload['memo'] = memo 178 | 179 | return payload 180 | 181 | def build_response(self, response): 182 | ack = response['responseEnvelope']['ack'] 183 | response_fields = ['ack', 'payKey', 'paymentExecStatus', 'paymentInfoList', 'sender'] 184 | 185 | if ack in ('Success', 'SuccessWithWarning'): 186 | ApiResponse = namedtuple('ApiResponse', response_fields) 187 | info_list = response.get('paymentInfoList', None) 188 | payment_info_list = info_list['paymentInfo'] if info_list else None 189 | 190 | response_kwargs = { 191 | 'ack': ack, 192 | 'payKey': response.get('payKey'), 193 | 'paymentExecStatus': response.get('paymentExecStatus'), 194 | 'paymentInfoList': payment_info_list, 195 | 'sender': response.get('sender') 196 | } 197 | 198 | api_response = ApiResponse(**response_kwargs) 199 | 200 | else: 201 | api_response = self.build_failure_response(response) 202 | 203 | return api_response 204 | -------------------------------------------------------------------------------- /yappa/tests/test_payment.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from unittest.mock import patch 4 | from decimal import Decimal 5 | 6 | from yappa.api import Pay 7 | from yappa.models import Receiver, ReceiverList 8 | 9 | 10 | class PaymentTestCase(unittest.TestCase): 11 | def setUp(self): 12 | self.credentials = { 13 | 'PAYPAL_USER_ID': 'fakeuserid', 14 | 'PAYPAL_PASSWORD': 'fakepassword', 15 | 'PAYPAL_SIGNATURE': '123456789', 16 | 'PAYPAL_APP_ID': 'APP-123456' 17 | } 18 | 19 | self.currency = 'USD' 20 | self.return_url = 'http://return.url' 21 | self.cancel_url = 'http://cancel.url' 22 | self.preapproval_key = 'PA-11111111111111111' 23 | self.fees_payer = 'EACHRECEIVER' 24 | self.sender_email = 'fakesender@gmail.com' 25 | self.memo = 'Example memo' 26 | 27 | receivers = [Receiver(email='receiver{}@gmail.com'.format(i + 1), 28 | amount=Decimal(10+i*5)) for i in range(3)] 29 | self.receiver_list = ReceiverList(receivers) 30 | 31 | self.pay_key = 'AP-2125055755555555' 32 | 33 | def tearDown(self): 34 | pass 35 | 36 | @patch('yappa.api.requests') 37 | def test_request_capturing_preapproved_payment(self, mock_request): 38 | expected_endpoint = 'https://svcs.sandbox.paypal.com/AdaptivePayments/Pay' 39 | expected_headers = { 40 | 'X-PAYPAL-SECURITY-USERID': 'fakeuserid', 41 | 'X-PAYPAL-SECURITY-PASSWORD': 'fakepassword', 42 | 'X-PAYPAL-SECURITY-SIGNATURE': '123456789', 43 | 'X-PAYPAL-APPLICATION-ID': 'APP-123456', 44 | 'X-PAYPAL-REQUEST-DATA-FORMAT': 'JSON', 45 | 'X-PAYPAL-RESPONSE-DATA-FORMAT': 'JSON', 46 | } 47 | expected_payload = { 48 | 'actionType': 'PAY', 49 | 'returnUrl': self.return_url, 50 | 'cancelUrl': self.cancel_url, 51 | 'currencyCode': self.currency, 52 | 'feesPayer': self.fees_payer, 53 | 'senderEmail': self.sender_email, 54 | 'memo': self.memo, 55 | 'receiverList': { 56 | 'receiver': [ 57 | {'email': 'receiver1@gmail.com', 'amount': '10'}, 58 | {'email': 'receiver2@gmail.com', 'amount': '15'}, 59 | {'email': 'receiver3@gmail.com', 'amount': '20'}, 60 | ] 61 | }, 62 | 'requestEnvelope': { 63 | 'errorLanguage': 'en_US', 64 | } 65 | } 66 | 67 | pay = Pay(self.credentials, debug=True) 68 | 69 | pay.request( 70 | currencyCode='USD', 71 | returnUrl=self.return_url, 72 | cancelUrl=self.cancel_url, 73 | senderEmail=self.sender_email, 74 | memo=self.memo, 75 | receiverList=self.receiver_list 76 | ) 77 | 78 | args, kwargs = mock_request.post.call_args 79 | 80 | self.assertEquals(args, (expected_endpoint,)) 81 | self.assertEquals(kwargs['headers'], expected_headers) 82 | self.assertEquals(json.loads(kwargs['data']), expected_payload) 83 | 84 | @patch('yappa.api.requests.post') 85 | def test_capture_preapproved_payment_successfully(self, mock_post): 86 | expected_payment_info_list = [ 87 | { 88 | 'pendingRefund': 'false', 89 | 'receiver': { 90 | 'accountId': 'RUCGXXXXXXXX', 91 | 'amount': '6.00', 92 | 'email': 'receiver1@gmail.com', 93 | 'primary': 'false' 94 | }, 95 | 'senderTransactionId': '07V41747777777777', 96 | 'senderTransactionStatus': 'COMPLETED', 97 | 'transactionId': '111111111111', 98 | 'transactionStatus': 'COMPLETED' 99 | }, 100 | { 101 | 'pendingRefund': 'false', 102 | 'receiver': { 103 | 'accountId': 'WRFQXXXXXXXX', 104 | 'amount': '12.00', 105 | 'email': 'receiver2@gmail.com', 106 | 'primary': 'false' 107 | }, 108 | 'senderTransactionId': '98692817999999999', 109 | 'senderTransactionStatus': 'COMPLETED', 110 | 'transactionId': '2222222222222', 111 | 'transactionStatus': 'COMPLETED' 112 | } 113 | ] 114 | mock_response = { 115 | 'payKey': self.pay_key, 116 | 'paymentExecStatus': 'COMPLETED', 117 | 'paymentInfoList': { 118 | 'paymentInfo': expected_payment_info_list 119 | }, 120 | 'responseEnvelope': { 121 | 'ack': 'Success', 122 | 'build': '20420247', 123 | 'correlationId': 'a92e1583464e5', 124 | 'timestamp': '2016-05-30T08:39:34.156-07:00' 125 | }, 126 | 'sender': {'accountId': 'SD97PL53N4N2Y'} 127 | } 128 | 129 | mock_post.return_value.json.return_value = mock_response 130 | receiver_list = ReceiverList([]) # No need to be true receiver list 131 | 132 | pay = Pay(self.credentials, debug=True) 133 | resp = pay.request(receiverList=receiver_list) 134 | 135 | self.assertEquals(resp.ack, 'Success') 136 | self.assertEquals(resp.payKey, self.pay_key) 137 | self.assertEquals(resp.paymentExecStatus, 'COMPLETED') 138 | self.assertEquals(resp.sender, {'accountId': 'SD97PL53N4N2Y'}) 139 | self.assertEquals(resp.paymentInfoList, expected_payment_info_list) 140 | 141 | @patch('yappa.api.requests.post') 142 | def test_capture_preapproved_payment_with_invalid_preapproval_key(self, mock_post): 143 | mock_response = { 144 | 'error': [{ 145 | 'category': 'Application', 146 | 'domain': 'PLATFORM', 147 | 'errorId': '580022', 148 | 'message': 'Invalid request parameter: preapprovalKey with value NON_EXISTENT_KEY', 149 | 'parameter': ['preapprovalKey', 'NON_EXISTENT_KEY'], 150 | 'severity': 'Error', 151 | 'subdomain': 'Application' 152 | }], 153 | 'responseEnvelope': { 154 | 'ack': 'Failure', 155 | 'build': '20420247', 156 | 'correlationId': 'e0c1fa3692d17', 157 | 'timestamp': '2016-05-30T10:21:25.631-07:00' 158 | } 159 | } 160 | 161 | mock_post.return_value.json.return_value = mock_response 162 | receiver_list = ReceiverList([]) 163 | 164 | pay = Pay(self.credentials, debug=True) 165 | resp = pay.request(receiverList=receiver_list) 166 | 167 | self.assertEquals(resp.ack, 'Failure') 168 | self.assertEquals(resp.errorId, '580022') 169 | self.assertEquals(resp.message, 'Invalid request parameter: preapprovalKey with value NON_EXISTENT_KEY') 170 | self.assertEquals(resp.timestamp, '2016-05-30T10:21:25.631-07:00') 171 | 172 | @patch('yappa.api.requests.post') 173 | def test_capture_preapproved_payment_with_duplicate_receiver(self, mock_post): 174 | mock_response = { 175 | 'error': [{ 176 | 'category': 'Application', 177 | 'domain': 'PLATFORM', 178 | 'errorId': '579040', 179 | 'message': 'Receiver PayPal accounts must be unique.', 180 | 'parameter': ['receiver'], 181 | 'severity': 'Error', 182 | 'subdomain': 'Application' 183 | }], 184 | 'responseEnvelope': { 185 | 'ack': 'Failure', 186 | 'build': '20420247', 187 | 'correlationId': '93b6326927fc8', 188 | 'timestamp': '2016-05-30T10:27:03.931-07:00' 189 | } 190 | } 191 | 192 | mock_post.return_value.json.return_value = mock_response 193 | receiver_list = ReceiverList([]) 194 | 195 | pay = Pay(self.credentials, debug=True) 196 | resp = pay.request(receiverList=receiver_list) 197 | 198 | self.assertEquals(resp.ack, 'Failure') 199 | self.assertEquals(resp.errorId, '579040') 200 | self.assertEquals(resp.message, 'Receiver PayPal accounts must be unique.') 201 | self.assertEquals(resp.timestamp, '2016-05-30T10:27:03.931-07:00') 202 | -------------------------------------------------------------------------------- /yappa/tests/test_preapproval.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | from unittest.mock import patch 4 | from decimal import Decimal 5 | 6 | from yappa.api import PreApproval, PreApprovalDetails 7 | 8 | 9 | class PreApprovalTestCase(unittest.TestCase): 10 | def setUp(self): 11 | self.credentials = { 12 | 'PAYPAL_USER_ID': 'fakeuserid', 13 | 'PAYPAL_PASSWORD': 'fakepassword', 14 | 'PAYPAL_SIGNATURE': '123456789', 15 | 'PAYPAL_APP_ID': 'APP-123456' 16 | } 17 | 18 | self.starting_date = '2016-05-28T00:33:00+08:0' 19 | self.ending_date = '2016-06-28T00:33:00+08:0' 20 | self.currency = 'USD' 21 | self.return_url = 'http://return.url' 22 | self.cancel_url = 'http://cancel.url' 23 | self.max_amount_per_payment = Decimal('35.55') 24 | self.max_number_of_payments = 15 25 | self.max_total_amount_of_all_payments = Decimal('500.55') 26 | 27 | self.preapproval_key = 'PA-11111111111111111' 28 | 29 | def tearDown(self): 30 | pass 31 | 32 | @patch('yappa.api.requests') 33 | def test_request_preapproval(self, mock_request): 34 | expected_endpoint = 'https://svcs.sandbox.paypal.com/AdaptivePayments/Preapproval' 35 | expected_headers = { 36 | 'X-PAYPAL-SECURITY-USERID': 'fakeuserid', 37 | 'X-PAYPAL-SECURITY-PASSWORD': 'fakepassword', 38 | 'X-PAYPAL-SECURITY-SIGNATURE': '123456789', 39 | 'X-PAYPAL-APPLICATION-ID': 'APP-123456', 40 | 'X-PAYPAL-REQUEST-DATA-FORMAT': 'JSON', 41 | 'X-PAYPAL-RESPONSE-DATA-FORMAT': 'JSON', 42 | } 43 | expected_payload = { 44 | 'startingDate': self.starting_date, 45 | 'endingDate': self.ending_date, 46 | 'returnUrl': self.return_url, 47 | 'cancelUrl': self.cancel_url, 48 | 'currencyCode': self.currency, 49 | 'maxAmountPerPayment': 35.55, 50 | 'maxNumberOfPayments': self.max_number_of_payments, 51 | 'maxTotalAmountOfAllPayments': 500.55, 52 | 'requestEnvelope': { 53 | 'errorLanguage': 'en_US', 54 | } 55 | } 56 | 57 | preapproval = PreApproval(self.credentials, debug=True) 58 | 59 | preapproval.request( 60 | startingDate=self.starting_date, 61 | endingDate=self.ending_date, 62 | currencyCode='USD', 63 | returnUrl=self.return_url, 64 | cancelUrl=self.cancel_url, 65 | maxAmountPerPayment=self.max_amount_per_payment, 66 | maxNumberOfPayments=self.max_number_of_payments, 67 | maxTotalAmountOfAllPayments=self.max_total_amount_of_all_payments 68 | ) 69 | 70 | args, kwargs = mock_request.post.call_args 71 | 72 | self.assertEquals(args, (expected_endpoint,)) 73 | self.assertEquals(kwargs['headers'], expected_headers) 74 | self.assertEquals(json.loads(kwargs['data']), expected_payload) 75 | 76 | @patch('yappa.api.requests.post') 77 | def test_request_preapproval_successfully(self, mock_post): 78 | mock_response = { 79 | 'preapprovalKey': self.preapproval_key, 80 | 'responseEnvelope': { 81 | 'ack': 'Success', 82 | 'build': '99999999', 83 | 'correlationId': 'd99999eac1e999', 84 | 'timestamp': '2016-05-29T03:27:49.944-07:00'} 85 | } 86 | mock_post.return_value.json.return_value = mock_response 87 | 88 | preapproval = PreApproval(self.credentials, debug=True) 89 | resp = preapproval.request() 90 | 91 | self.assertEquals(resp.ack, 'Success') 92 | self.assertEquals(resp.preapprovalKey, self.preapproval_key) 93 | self.assertEqual(resp.nextUrl, ('https://www.sandbox.paypal.com/cgi-bin/webscr?' 94 | 'cmd=_ap-preapproval&preapprovalkey=PA-11111111111111111')) 95 | 96 | @patch('yappa.api.requests.post') 97 | def test_request_preapproval_with_invalid_payment(self, mock_post): 98 | mock_response = { 99 | 'error': [{ 100 | 'category': 'Application', 101 | 'domain': 'PLATFORM', 102 | 'errorId': '580001', 103 | 'message': 'Invalid request: Data validation', 104 | 'parameter': ['Data validation warning(line -1, col 0): null'], 105 | 'severity': 'Error', 106 | 'subdomain': 'Application' 107 | }], 108 | 'responseEnvelope': { 109 | 'ack': 'Failure', 110 | 'build': '20420247', 111 | 'correlationId': '0d30f655e5515', 112 | 'timestamp': '2016-05-29T04:55:31.432-07:00' 113 | } 114 | } 115 | mock_post.return_value.json.return_value = mock_response 116 | 117 | preapproval = PreApproval(self.credentials, debug=True) 118 | resp = preapproval.request() 119 | 120 | self.assertEquals(resp.ack, 'Failure') 121 | self.assertEquals(resp.errorId, '580001') 122 | self.assertEquals(resp.message, 'Invalid request: Data validation') 123 | self.assertEquals(resp.timestamp, '2016-05-29T04:55:31.432-07:00') 124 | 125 | @patch('yappa.api.requests.post') 126 | def test_request_preapproval_with_invalid_date_range(self, mock_post): 127 | mock_response = { 128 | 'error': [{ 129 | 'category': 'Application', 130 | 'domain': 'PLATFORM', 131 | 'errorId': '580024', 132 | 'message': 'The start date must be in the future', 133 | 'parameter': ['startingDate'], 134 | 'severity': 'Error', 135 | 'subdomain': 'Application' 136 | }], 137 | 'responseEnvelope': { 138 | 'ack': 'Failure', 139 | 'build': '20420247', 140 | 'correlationId': '1b392fdd4b4f2', 141 | 'timestamp': '2016-05-29T09:25:28.817-07:00' 142 | } 143 | } 144 | mock_post.return_value.json.return_value = mock_response 145 | 146 | preapproval = PreApproval(self.credentials, debug=True) 147 | resp = preapproval.request() 148 | 149 | self.assertEquals(resp.ack, 'Failure') 150 | self.assertEquals(resp.errorId, '580024') 151 | self.assertEquals(resp.message, 'The start date must be in the future') 152 | self.assertEquals(resp.timestamp, '2016-05-29T09:25:28.817-07:00') 153 | 154 | @patch('yappa.api.requests') 155 | def test_retrieve_preapproval_details(self, mock_request): 156 | expected_endpoint = 'https://svcs.sandbox.paypal.com/AdaptivePayments/PreapprovalDetails' 157 | expected_headers = { 158 | 'X-PAYPAL-SECURITY-USERID': 'fakeuserid', 159 | 'X-PAYPAL-SECURITY-PASSWORD': 'fakepassword', 160 | 'X-PAYPAL-SECURITY-SIGNATURE': '123456789', 161 | 'X-PAYPAL-APPLICATION-ID': 'APP-123456', 162 | 'X-PAYPAL-REQUEST-DATA-FORMAT': 'JSON', 163 | 'X-PAYPAL-RESPONSE-DATA-FORMAT': 'JSON', 164 | } 165 | expected_payload = { 166 | 'preapprovalKey': self.preapproval_key, 167 | 'requestEnvelope': { 168 | 'errorLanguage': 'en_US', 169 | } 170 | } 171 | 172 | preapproval_detials = PreApprovalDetails(self.credentials, debug=True) 173 | 174 | preapproval_detials.request( 175 | preapprovalKey=self.preapproval_key, 176 | ) 177 | 178 | args, kwargs = mock_request.post.call_args 179 | 180 | self.assertEquals(args, (expected_endpoint,)) 181 | self.assertEquals(kwargs['headers'], expected_headers) 182 | self.assertEquals(json.loads(kwargs['data']), expected_payload) 183 | 184 | @patch('yappa.api.requests.post') 185 | def test_retrieve_preapproval_details_unapproved(self, mock_post): 186 | mock_response = { 187 | 'approved': 'false', 188 | 'cancelUrl': self.cancel_url, 189 | 'curPayments': '0', 190 | 'curPaymentsAmount': '0.00', 191 | 'curPeriodAttempts': '0', 192 | 'currencyCode': 'USD', 193 | 'dateOfMonth': '0', 194 | 'dayOfWeek': 'NO_DAY_SPECIFIED', 195 | 'displayMaxTotalAmount': 'false', 196 | 'endingDate': '2016-06-19T18:27:48.000+08:00', 197 | 'maxTotalAmountOfAllPayments': '500.00', 198 | 'paymentPeriod': 'NO_PERIOD_SPECIFIED', 199 | 'pinType': 'NOT_REQUIRED', 200 | 'responseEnvelope': { 201 | 'ack': 'Success', 202 | 'build': '20420247', 203 | 'correlationId': 'c8c558a0c0401', 204 | 'timestamp': '2016-05-29T04:09:05.377-07:00' 205 | }, 206 | 'returnUrl': self.return_url, 207 | 'startingDate': '2016-05-30T18:27:48.000+08:00', 208 | 'status': 'ACTIVE' 209 | } 210 | mock_post.return_value.json.return_value = mock_response 211 | 212 | preapproval_detials = PreApprovalDetails(self.credentials, debug=True) 213 | resp = preapproval_detials.request() 214 | 215 | self.assertEquals(resp.ack, 'Success') 216 | self.assertEquals(resp.approved, 'false') 217 | self.assertEquals(resp.status, 'ACTIVE') 218 | self.assertEquals(resp.maxTotalAmountOfAllPayments, '500.00') 219 | 220 | @patch('yappa.api.requests.post') 221 | def test_retrieve_preapproval_details_approved(self, mock_post): 222 | mock_response = { 223 | 'approved': 'true', 224 | 'cancelUrl': self.cancel_url, 225 | 'curPayments': '0', 226 | 'curPaymentsAmount': '0.00', 227 | 'curPeriodAttempts': '0', 228 | 'currencyCode': 'USD', 229 | 'dateOfMonth': '0', 230 | 'dayOfWeek': 'NO_DAY_SPECIFIED', 231 | 'displayMaxTotalAmount': 'false', 232 | 'endingDate': '2016-06-19T18:27:48.000+08:00', 233 | 'maxTotalAmountOfAllPayments': '500.00', 234 | 'paymentPeriod': 'NO_PERIOD_SPECIFIED', 235 | 'pinType': 'NOT_REQUIRED', 236 | 'responseEnvelope': { 237 | 'ack': 'Success', 238 | 'build': '20420247', 239 | 'correlationId': 'c8c558a0c0401', 240 | 'timestamp': '2016-05-29T04:09:05.377-07:00' 241 | }, 242 | 'returnUrl': self.return_url, 243 | 'sender': {'accountId': 'ABCDEFG'}, 244 | 'senderEmail': 'fake-buyer@gmail.com', 245 | 'startingDate': '2016-05-30T18:27:48.000+08:00', 246 | 'status': 'ACTIVE' 247 | } 248 | mock_post.return_value.json.return_value = mock_response 249 | 250 | preapproval_detials = PreApprovalDetails(self.credentials, debug=True) 251 | resp = preapproval_detials.request() 252 | 253 | self.assertEquals(resp.ack, 'Success') 254 | self.assertEquals(resp.approved, 'true') 255 | self.assertEquals(resp.status, 'ACTIVE') 256 | self.assertEquals(resp.maxTotalAmountOfAllPayments, '500.00') 257 | self.assertEquals(resp.sender, {'accountId': 'ABCDEFG'}) 258 | self.assertEquals(resp.senderEmail, 'fake-buyer@gmail.com') 259 | 260 | @patch('yappa.api.requests.post') 261 | def test_retrieve_preapproval_details_with_invalid_key(self, mock_post): 262 | mock_response = { 263 | 'error': [{ 264 | 'category': 'Application', 265 | 'domain': 'PLATFORM', 266 | 'errorId': '580022', 267 | 'message': 'Invalid request parameter: preapprovalKey with value PA-5W434979gggggg', 268 | 'parameter': ['preapprovalKey', 'PA-5W434979gggggg'], 269 | 'severity': 'Error', 270 | 'subdomain': 'Application' 271 | }], 272 | 'responseEnvelope': { 273 | 'ack': 'Failure', 274 | 'build': '20420247', 275 | 'correlationId': '9a2ae1abba0ac', 276 | 'timestamp': '2016-05-29T09:13:32.007-07:00' 277 | } 278 | } 279 | mock_post.return_value.json.return_value = mock_response 280 | 281 | preapproval_detials = PreApprovalDetails(self.credentials, debug=True) 282 | resp = preapproval_detials.request() 283 | 284 | self.assertEquals(resp.ack, 'Failure') 285 | self.assertEquals(resp.errorId, '580022') 286 | self.assertEquals(resp.message, 'Invalid request parameter: preapprovalKey with value PA-5W434979gggggg') 287 | self.assertEquals(resp.timestamp, '2016-05-29T09:13:32.007-07:00') 288 | --------------------------------------------------------------------------------