0001-add-mollie-payment-method-28933.patch
eopayment/__init__.py | ||
---|---|---|
34 | 34 |
'get_backends', 'PAYFIP_WS'] |
35 | 35 | |
36 | 36 |
if six.PY3: |
37 |
__all__.append('KEYWARE')
|
|
37 |
__all__.extend(['KEYWARE', 'MOLLIE'])
|
|
38 | 38 | |
39 | 39 |
SIPS = 'sips' |
40 | 40 |
SIPS2 = 'sips2' |
... | ... | |
47 | 47 |
PAYZEN = 'payzen' |
48 | 48 |
PAYFIP_WS = 'payfip_ws' |
49 | 49 |
KEYWARE = 'keyware' |
50 |
MOLLIE = 'mollie' |
|
50 | 51 | |
51 | 52 |
logger = logging.getLogger(__name__) |
52 | 53 | |
... | ... | |
57 | 58 |
return module.Payment |
58 | 59 | |
59 | 60 |
__BACKENDS = [DUMMY, SIPS, SIPS2, SYSTEMPAY, SPPLUS, OGONE, PAYBOX, PAYZEN, |
60 |
TIPI, PAYFIP_WS, KEYWARE] |
|
61 |
TIPI, PAYFIP_WS, KEYWARE, MOLLIE]
|
|
61 | 62 | |
62 | 63 | |
63 | 64 |
def get_backends(): |
eopayment/common.py | ||
---|---|---|
169 | 169 |
return id |
170 | 170 | |
171 | 171 |
@staticmethod |
172 |
def clean_amount(amount, min_amount=0, max_amount=None): |
|
172 |
def clean_amount(amount, min_amount=0, max_amount=None, cents=True):
|
|
173 | 173 |
try: |
174 | 174 |
amount = Decimal(amount) |
175 | 175 |
except ValueError: |
... | ... | |
177 | 177 |
'at most after the decimal point', amount) |
178 | 178 |
if int(amount) < min_amount or (max_amount and int(amount) > max_amount): |
179 | 179 |
raise ValueError('amount %s is not in range [%s, %s]' % (amount, min_amount, max_amount)) |
180 |
amount *= Decimal('100') # convert to cents |
|
181 |
amount = amount.to_integral_value(ROUND_DOWN) |
|
180 |
if cents: |
|
181 |
amount *= Decimal('100') # convert to cents |
|
182 |
amount = amount.to_integral_value(ROUND_DOWN) |
|
182 | 183 |
return str(amount) |
183 | 184 | |
184 | 185 |
eopayment/mollie.py | ||
---|---|---|
1 |
# eopayment - online payment library |
|
2 |
# Copyright (C) 2011-2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
from gettext import gettext as _ |
|
18 | ||
19 |
import requests |
|
20 |
from six.moves.urllib.parse import parse_qs, urljoin |
|
21 | ||
22 |
from .common import (CANCELLED, ERROR, PAID, URL, PaymentCommon, |
|
23 |
PaymentException, PaymentResponse, ResponseError) |
|
24 | ||
25 |
__all__ = ['Payment'] |
|
26 | ||
27 | ||
28 |
class Payment(PaymentCommon): |
|
29 |
'''Implements Mollie API, see https://docs.mollie.com/reference/v2/.''' |
|
30 |
service_url = 'https://api.mollie.com/v2/' |
|
31 | ||
32 |
description = { |
|
33 |
'caption': 'Mollie payment backend', |
|
34 |
'parameters': [ |
|
35 |
{ |
|
36 |
'name': 'normal_return_url', |
|
37 |
'caption': _('Normal return URL'), |
|
38 |
'required': True, |
|
39 |
}, |
|
40 |
{ |
|
41 |
'name': 'automatic_return_url', |
|
42 |
'caption': _('Asychronous return URL'), |
|
43 |
'required': True, |
|
44 |
}, |
|
45 |
{ |
|
46 |
'name': 'service_url', |
|
47 |
'caption': _('URL of the payment service'), |
|
48 |
'default': service_url, |
|
49 |
'type': str, |
|
50 |
'validation': lambda x: x.startswith('https'), |
|
51 |
}, |
|
52 |
{ |
|
53 |
'name': 'api_key', |
|
54 |
'caption': _('API key'), |
|
55 |
'required': True, |
|
56 |
'validation': lambda x: x.startswith('test_') or x.startswith('live_'), |
|
57 |
}, |
|
58 |
{ |
|
59 |
'name': 'description', |
|
60 |
'caption': _('General description that will be displayed for all payments'), |
|
61 |
'required': True, |
|
62 |
}, |
|
63 |
], |
|
64 |
} |
|
65 | ||
66 |
def request(self, amount, **kwargs): |
|
67 |
amount = self.clean_amount(amount, cents=False) |
|
68 | ||
69 |
metadata = {k: v for k, v in kwargs.items() |
|
70 |
if k in ('email', 'first_name', 'last_name') and v is not None} |
|
71 |
body = { |
|
72 |
'amount': { |
|
73 |
'value': amount, |
|
74 |
'currency': 'EUR', |
|
75 |
}, |
|
76 |
'redirectUrl': self.normal_return_url, |
|
77 |
'webhookUrl': self.automatic_return_url, |
|
78 |
'metadata': metadata, |
|
79 |
'description': self.description, |
|
80 |
} |
|
81 | ||
82 |
resp = self.call_endpoint('POST', 'payments', data=body) |
|
83 | ||
84 |
return resp['id'], URL, resp['_links']['checkout']['href'] |
|
85 | ||
86 |
def response(self, query_string, redirect=False, order_id_hint=None, |
|
87 |
order_status_hint=None, **kwargs): |
|
88 |
if redirect: |
|
89 |
if order_status_hint in (PAID, CANCELLED, ERROR): |
|
90 |
return PaymentResponse(order_id=order_id_hint, result=order_status_hint) |
|
91 |
else: |
|
92 |
payment_id = order_id_hint |
|
93 |
elif query_string: |
|
94 |
fields = parse_qs(query_string) |
|
95 |
payment_id = fields['id'][0] |
|
96 |
else: |
|
97 |
raise ResponseError('cannot infer payment id') |
|
98 | ||
99 |
resp = self.call_endpoint('GET', 'payments/' + payment_id) |
|
100 | ||
101 |
status = resp['status'] |
|
102 |
if status == 'paid': |
|
103 |
result = PAID |
|
104 |
elif status in ('canceled', 'expired'): |
|
105 |
result = CANCELLED |
|
106 |
else: |
|
107 |
result = ERROR |
|
108 | ||
109 |
response = PaymentResponse( |
|
110 |
result=result, |
|
111 |
signed=True, |
|
112 |
bank_data=resp, |
|
113 |
order_id=payment_id, |
|
114 |
transaction_id=payment_id, |
|
115 |
bank_status=status, |
|
116 |
test=resp['mode'] == 'test' |
|
117 |
) |
|
118 |
return response |
|
119 | ||
120 |
def call_endpoint(self, method, endpoint, data=None): |
|
121 |
url = urljoin(self.service_url, endpoint) |
|
122 |
headers = {'Authorization': 'Bearer %s' % self.api_key} |
|
123 |
try: |
|
124 |
response = requests.request(method, url, headers=headers, json=data) |
|
125 |
except requests.exceptions.RequestException as e: |
|
126 |
raise PaymentException('%s error on endpoint "%s": %s' % (method, endpoint, e)) |
|
127 |
try: |
|
128 |
result = response.json() |
|
129 |
except ValueError: |
|
130 |
self.logger.debug('received invalid json %r', response.text) |
|
131 |
raise PaymentException('%s on endpoint "%s" returned invalid JSON: %s' % |
|
132 |
(method, endpoint, response.text)) |
|
133 |
self.logger.debug('received "%s" with status %s', result, response.status_code) |
|
134 |
try: |
|
135 |
response.raise_for_status() |
|
136 |
except requests.exceptions.HTTPError as e: |
|
137 |
raise PaymentException( |
|
138 |
'%s error on endpoint "%s": %s "%s"' % (method, endpoint, e, |
|
139 |
result.get('detail', result))) |
|
140 |
return result |
tests/test_mollie.py | ||
---|---|---|
1 |
# eopayment - online payment library |
|
2 |
# Copyright (C) 2011-2020 Entr'ouvert |
|
3 |
# |
|
4 |
# This program is free software: you can redistribute it and/or modify it |
|
5 |
# under the terms of the GNU Affero General Public License as published |
|
6 |
# by the Free Software Foundation, either version 3 of the License, or |
|
7 |
# (at your option) any later version. |
|
8 |
# |
|
9 |
# This program is distributed in the hope that it will be useful, |
|
10 |
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11 |
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12 |
# GNU Affero General Public License for more details. |
|
13 |
# |
|
14 |
# You should have received a copy of the GNU Affero General Public License |
|
15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16 | ||
17 |
import json |
|
18 | ||
19 |
import requests |
|
20 |
import six |
|
21 | ||
22 |
import eopayment |
|
23 |
import pytest |
|
24 |
from eopayment.mollie import Payment |
|
25 |
from httmock import remember_called, response, urlmatch, with_httmock |
|
26 | ||
27 |
pytestmark = pytest.mark.skipif(six.PY2, reason='this payment module only supports python3') |
|
28 | ||
29 |
WEBHOOK_URL = 'https://callback.example.com' |
|
30 |
RETURN_URL = 'https://return.example.com' |
|
31 |
API_KEY = 'test' |
|
32 | ||
33 |
PAYMENT_ID = "tr_7UhSN1zuXS" |
|
34 |
QUERY_STRING = 'id=' + PAYMENT_ID |
|
35 | ||
36 | ||
37 |
POST_PAYMENTS_RESPONSE = { |
|
38 |
"resource": "payment", |
|
39 |
"id": PAYMENT_ID, |
|
40 |
"mode": "test", |
|
41 |
"createdAt": "2018-03-20T09:13:37+00:00", |
|
42 |
"amount": { |
|
43 |
"value": "3.50", |
|
44 |
"currency": "EUR" |
|
45 |
}, |
|
46 |
"description": "Payment #12345", |
|
47 |
"method": "null", |
|
48 |
"status": "open", |
|
49 |
"isCancelable": True, |
|
50 |
"expiresAt": "2018-03-20T09:28:37+00:00", |
|
51 |
"sequenceType": "oneoff", |
|
52 |
"redirectUrl": "https://webshop.example.org/payment/12345/", |
|
53 |
"webhookUrl": "https://webshop.example.org/payments/webhook/", |
|
54 |
"_links": { |
|
55 |
"checkout": { |
|
56 |
"href": "https://www.mollie.com/payscreen/select-method/7UhSN1zuXS", |
|
57 |
"type": "text/html" |
|
58 |
}, |
|
59 |
} |
|
60 |
} |
|
61 | ||
62 | ||
63 |
GET_PAYMENTS_RESPONSE = { |
|
64 |
"amount": { |
|
65 |
"currency": "EUR", |
|
66 |
"value": "3.50" |
|
67 |
}, |
|
68 |
"amountRefunded": { |
|
69 |
"currency": "EUR", |
|
70 |
"value": "0.00" |
|
71 |
}, |
|
72 |
"amountRemaining": { |
|
73 |
"currency": "EUR", |
|
74 |
"value": "3.50" |
|
75 |
}, |
|
76 |
"countryCode": "FR", |
|
77 |
"createdAt": "2020-05-06T13:04:26+00:00", |
|
78 |
"description": "Publik", |
|
79 |
"details": { |
|
80 |
"cardAudience": "consumer", |
|
81 |
"cardCountryCode": "NL", |
|
82 |
"cardHolder": "T. TEST", |
|
83 |
"cardLabel": "Mastercard", |
|
84 |
"cardNumber": "6787", |
|
85 |
"cardSecurity": "normal", |
|
86 |
"feeRegion": "other" |
|
87 |
}, |
|
88 |
"id": PAYMENT_ID, |
|
89 |
"metadata": { |
|
90 |
"email": "test@entrouvert.com", |
|
91 |
"first_name": "test", |
|
92 |
"last_name": "test" |
|
93 |
}, |
|
94 |
"isCancelable": True, |
|
95 |
"method": "creditcard", |
|
96 |
"mode": "test", |
|
97 |
"paidAt": "2020-05-06T14:01:04+00:00", |
|
98 |
"profileId": "pfl_WNPCPTGepu", |
|
99 |
"redirectUrl": "https://localhost/lingo/return-payment-backend/3/MTAw.1jWJis.6TbbjwSEurag6v4Z2VCheISBFjw/", |
|
100 |
"resource": "payment", |
|
101 |
"sequenceType": "oneoff", |
|
102 |
"settlementAmount": { |
|
103 |
"currency": "EUR", |
|
104 |
"value": "3.50" |
|
105 |
}, |
|
106 |
"status": "paid", |
|
107 |
"webhookUrl": "https://localhost/lingo/callback-payment-backend/3/" |
|
108 |
} |
|
109 | ||
110 | ||
111 |
@remember_called |
|
112 |
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='POST') |
|
113 |
def add_payment(url, request): |
|
114 |
return response(200, POST_PAYMENTS_RESPONSE, request=request) |
|
115 | ||
116 | ||
117 |
@remember_called |
|
118 |
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET') |
|
119 |
def successful_payment(url, request): |
|
120 |
return response(200, GET_PAYMENTS_RESPONSE, request=request) |
|
121 | ||
122 | ||
123 |
@remember_called |
|
124 |
@urlmatch(scheme='https', netloc='api.mollie.com', path=r'/v2/payments', method='DELETE') |
|
125 |
def canceled_payment(url, request): |
|
126 |
resp = GET_PAYMENTS_RESPONSE.copy() |
|
127 |
resp['status'] = 'canceled' |
|
128 |
return response(200, resp, request=request) |
|
129 | ||
130 | ||
131 |
@remember_called |
|
132 |
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments') |
|
133 |
def failed_payment(url, request): |
|
134 |
resp = GET_PAYMENTS_RESPONSE.copy() |
|
135 |
resp['status'] = 'failed' |
|
136 |
return response(200, resp, request=request) |
|
137 | ||
138 | ||
139 |
@remember_called |
|
140 |
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments') |
|
141 |
def expired_payment(url, request): |
|
142 |
resp = GET_PAYMENTS_RESPONSE.copy() |
|
143 |
resp['status'] = 'expired' |
|
144 |
return response(200, resp, request=request) |
|
145 | ||
146 | ||
147 |
@remember_called |
|
148 |
@urlmatch(scheme='https', netloc='api.mollie.com', path=r'/v2/payments', method='GET') |
|
149 |
def canceled_payment_get(url, request): |
|
150 |
resp = GET_PAYMENTS_RESPONSE.copy() |
|
151 |
resp['status'] = 'canceled' |
|
152 |
return response(200, resp, request=request) |
|
153 | ||
154 | ||
155 |
@remember_called |
|
156 |
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET') |
|
157 |
def connection_error(url, request): |
|
158 |
raise requests.ConnectionError('test msg') |
|
159 | ||
160 | ||
161 |
@remember_called |
|
162 |
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET') |
|
163 |
def http_error(url, request): |
|
164 |
error_payload = { |
|
165 |
'status': 404, |
|
166 |
'title': 'Not Found', |
|
167 |
'detail': 'No payment exists with token hop.', |
|
168 |
} |
|
169 |
return response(400, error_payload, request=request) |
|
170 | ||
171 | ||
172 |
@remember_called |
|
173 |
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET') |
|
174 |
def invalid_json(url, request): |
|
175 |
return response(200, '{', request=request) |
|
176 | ||
177 | ||
178 |
@pytest.fixture |
|
179 |
def mollie(): |
|
180 |
return Payment({ |
|
181 |
'normal_return_url': RETURN_URL, |
|
182 |
'automatic_return_url': WEBHOOK_URL, |
|
183 |
'api_key': API_KEY, |
|
184 |
}) |
|
185 | ||
186 | ||
187 |
@with_httmock(add_payment) |
|
188 |
def test_mollie_request(mollie): |
|
189 |
email = 'test@test.com' |
|
190 |
payment_id, kind, url = mollie.request(2.5, email=email) |
|
191 | ||
192 |
assert payment_id == PAYMENT_ID |
|
193 |
assert kind == eopayment.URL |
|
194 |
assert 'mollie.com/payscreen/' in url |
|
195 | ||
196 |
body = json.loads(add_payment.call['requests'][0].body.decode()) |
|
197 |
assert body['amount']['value'] == '2.5' |
|
198 |
assert body['amount']['currency'] == 'EUR' |
|
199 |
assert body['metadata']['email'] == email |
|
200 |
assert body['webhookUrl'] == WEBHOOK_URL |
|
201 |
assert body['redirectUrl'] == RETURN_URL |
|
202 | ||
203 | ||
204 |
@with_httmock(successful_payment) |
|
205 |
def test_mollie_response(mollie): |
|
206 |
payment_response = mollie.response(QUERY_STRING) |
|
207 | ||
208 |
assert payment_response.result == eopayment.PAID |
|
209 |
assert payment_response.signed is True |
|
210 |
assert payment_response.bank_data == GET_PAYMENTS_RESPONSE |
|
211 |
assert payment_response.order_id == PAYMENT_ID |
|
212 |
assert payment_response.transaction_id == PAYMENT_ID |
|
213 |
assert payment_response.bank_status == 'paid' |
|
214 |
assert payment_response.test is True |
|
215 | ||
216 |
request = successful_payment.call['requests'][0] |
|
217 |
assert PAYMENT_ID in request.url |
|
218 | ||
219 | ||
220 |
@with_httmock(successful_payment) |
|
221 |
def test_mollie_response_on_redirect(mollie): |
|
222 |
payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID, |
|
223 |
order_status_hint=0) |
|
224 |
assert payment_response.result == eopayment.PAID |
|
225 | ||
226 |
request = successful_payment.call['requests'][0] |
|
227 |
assert PAYMENT_ID in request.url |
|
228 | ||
229 | ||
230 |
def test_mollie_response_on_redirect_final_status(mollie): |
|
231 |
payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID, |
|
232 |
order_status_hint=eopayment.PAID) |
|
233 |
assert payment_response.result == eopayment.PAID |
|
234 |
assert payment_response.order_id == PAYMENT_ID |
|
235 | ||
236 | ||
237 |
@with_httmock(failed_payment) |
|
238 |
def test_mollie_response_failed(mollie): |
|
239 |
payment_response = mollie.response(QUERY_STRING) |
|
240 |
assert payment_response.result == eopayment.ERROR |
|
241 | ||
242 | ||
243 |
@with_httmock(canceled_payment_get) |
|
244 |
def test_mollie_response_canceled(mollie): |
|
245 |
payment_response = mollie.response(QUERY_STRING) |
|
246 |
assert payment_response.result == eopayment.CANCELED |
|
247 | ||
248 | ||
249 |
@with_httmock(expired_payment) |
|
250 |
def test_mollie_response_expired(mollie): |
|
251 |
payment_response = mollie.response(QUERY_STRING) |
|
252 |
assert payment_response.result == eopayment.CANCELED |
|
253 | ||
254 | ||
255 |
@with_httmock(connection_error) |
|
256 |
def test_mollie_endpoint_connection_error(mollie): |
|
257 |
with pytest.raises(eopayment.PaymentException) as excinfo: |
|
258 |
mollie.call_endpoint('GET', 'payments') |
|
259 |
assert 'test msg' in str(excinfo.value) |
|
260 | ||
261 | ||
262 |
@with_httmock(http_error) |
|
263 |
def test_mollie_endpoint_http_error(mollie): |
|
264 |
with pytest.raises(eopayment.PaymentException) as excinfo: |
|
265 |
mollie.call_endpoint('GET', 'payments') |
|
266 |
assert 'Not Found' in str(excinfo.value) |
|
267 |
assert 'token' in str(excinfo.value) |
|
268 | ||
269 | ||
270 |
@with_httmock(invalid_json) |
|
271 |
def test_mollie_endpoint_json_error(mollie): |
|
272 |
with pytest.raises(eopayment.PaymentException) as excinfo: |
|
273 |
mollie.call_endpoint('GET', 'payments') |
|
274 |
assert 'JSON' in str(excinfo.value) |
|
0 |
- |