Projet

Général

Profil

0001-add-mollie-payment-method-28933.patch

Valentin Deniaud, 06 mai 2020 18:35

Télécharger (17,2 ko)

Voir les différences:

Subject: [PATCH] add mollie payment method (#28933)

 eopayment/__init__.py |   5 +-
 eopayment/common.py   |   7 +-
 eopayment/mollie.py   | 153 +++++++++++++++++++++++
 tests/test_mollie.py  | 285 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 445 insertions(+), 5 deletions(-)
 create mode 100644 eopayment/mollie.py
 create mode 100644 tests/test_mollie.py
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/payments'
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
                'default': 'Publik',
62
            },
63
            {
64
                'name': 'payment_methods',
65
                'caption': _('Allowed payment methods'),
66
                'type': list,
67
            },
68
        ],
69
    }
70

  
71
    def request(self, amount, email=None, first_name=None, last_name=None, **kwargs):
72
        amount = self.clean_amount(amount, cents=False)
73

  
74
        body = {
75
            'amount': {
76
                'value': amount,
77
                'currency': 'EUR',
78
            },
79
            'redirectUrl': self.normal_return_url,
80
            'webhookUrl': self.automatic_return_url,
81
            'metadata': {
82
                'email_address': email,
83
                'first_name': first_name,
84
                'last_name': last_name,
85
            },
86
            'description': self.description,
87
        }
88
        if self.payment_methods:
89
            if len(self.payment_methods) == 1:
90
                body['method'] = self.payment_methods[0]
91
            else:
92
                body['method'] = self.payment_methods
93

  
94
        resp = self.call_endpoint('POST', 'payments', data=body)
95

  
96
        return resp['id'], URL, resp['_links']['checkout']['href']
97

  
98
    def response(self, query_string, **kwargs):
99
        fields = parse_qs(query_string)
100
        payment_id = fields['id'][0]
101
        resp = self.call_endpoint('GET', 'payments/' + payment_id)
102

  
103
        status = resp['status']
104
        if status == 'paid':
105
            result = PAID
106
        elif status in ('canceled', 'expired'):
107
            result = CANCELLED
108
        else:
109
            result = ERROR
110

  
111
        response = PaymentResponse(
112
            result=result,
113
            signed=True,
114
            bank_data=resp,
115
            order_id=payment_id,
116
            transaction_id=payment_id,
117
            bank_status=status,
118
            test=resp['mode'] == 'test'
119
        )
120
        return response
121

  
122
    def cancel(self, amount, bank_data, **kwargs):
123
        payment_id = bank_data['id']
124
        if not bank_data.get('isCancelable'):
125
            raise ResponseError('payment %s cannot be canceled' % payment_id)
126

  
127
        resp = self.call_endpoint('DELETE', 'payments/' + payment_id)
128
        status = resp['status']
129
        if not status == 'canceled':
130
            raise ResponseError('expected "canceled" status, got "%s" instead' % status)
131
        return resp
132

  
133
    def call_endpoint(self, method, endpoint, data=None):
134
        url = urljoin(self.service_url, endpoint)
135
        headers = {'Authorization': 'Bearer %s' % self.api_key}
136
        try:
137
            response = requests.request(method, url, headers=headers, json=data)
138
        except requests.exceptions.RequestException as e:
139
            raise PaymentException('%s error on endpoint "%s": %s' % (method, endpoint, e))
140
        try:
141
            result = response.json()
142
        except ValueError:
143
            self.logger.debug('received invalid json %r', response.text)
144
            raise PaymentException('%s on endpoint "%s" returned invalid JSON: %s' %
145
                                   (method, endpoint, response.text))
146
        self.logger.debug('received "%s" with status %s', result, response.status_code)
147
        try:
148
            response.raise_for_status()
149
        except requests.exceptions.HTTPError as e:
150
            raise PaymentException(
151
                '%s error on endpoint "%s": %s "%s"' % (method, endpoint, e,
152
                                                        result.get('detail', result)))
153
        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_address": "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
@pytest.fixture
188
def mollie_credit_card(mollie):
189
    mollie.payment_methods = ['creditcard']
190
    return mollie
191

  
192

  
193
@with_httmock(add_payment)
194
def test_mollie_request(mollie):
195
    email = 'test@test.com'
196
    payment_id, kind, url = mollie.request(2.5, email=email)
197

  
198
    assert payment_id == PAYMENT_ID
199
    assert kind == eopayment.URL
200
    assert 'mollie.com/payscreen/' in url
201

  
202
    body = json.loads(add_payment.call['requests'][0].body.decode())
203
    assert body['amount']['value'] == '2.5'
204
    assert body['amount']['currency'] == 'EUR'
205
    assert body['metadata']['email_address'] == email
206
    assert body['webhookUrl'] == WEBHOOK_URL
207
    assert body['redirectUrl'] == RETURN_URL
208

  
209

  
210
@with_httmock(add_payment)
211
def test_mollie_request_payment_method(mollie_credit_card):
212
    payment_id, kind, url = mollie_credit_card.request(2.5)
213
    body = json.loads(add_payment.call['requests'][0].body.decode())
214

  
215
    assert body['method'] == 'creditcard'
216

  
217

  
218
@with_httmock(successful_payment)
219
def test_mollie_response(mollie):
220
    payment_response = mollie.response(QUERY_STRING)
221

  
222
    assert payment_response.result == eopayment.PAID
223
    assert payment_response.signed is True
224
    assert payment_response.bank_data == GET_PAYMENTS_RESPONSE
225
    assert payment_response.order_id == PAYMENT_ID
226
    assert payment_response.transaction_id == PAYMENT_ID
227
    assert payment_response.bank_status == 'paid'
228
    assert payment_response.test is True
229

  
230
    request = successful_payment.call['requests'][0]
231
    assert PAYMENT_ID in request.url
232

  
233

  
234
@with_httmock(failed_payment)
235
def test_mollie_response_failed(mollie):
236
    payment_response = mollie.response(QUERY_STRING)
237
    assert payment_response.result == eopayment.ERROR
238

  
239

  
240
@with_httmock(canceled_payment_get)
241
def test_mollie_response_canceled(mollie):
242
    payment_response = mollie.response(QUERY_STRING)
243
    assert payment_response.result == eopayment.CANCELED
244

  
245

  
246
@with_httmock(expired_payment)
247
def test_mollie_response_expired(mollie):
248
    payment_response = mollie.response(QUERY_STRING)
249
    assert payment_response.result == eopayment.CANCELED
250

  
251

  
252
@with_httmock(canceled_payment)
253
def test_mollie_cancel(mollie):
254
    resp = mollie.cancel(amount=42, bank_data=POST_PAYMENTS_RESPONSE)
255
    request = canceled_payment.call['requests'][0]
256
    assert PAYMENT_ID in request.url
257

  
258

  
259
@with_httmock(failed_payment)
260
def test_mollie_cancel_error(mollie):
261
    with pytest.raises(eopayment.ResponseError) as excinfo:
262
        mollie.cancel(amount=995, bank_data=POST_PAYMENTS_RESPONSE)
263
        assert 'expected "canceled" status, got "error" instead' in str(excinfo.value)
264

  
265

  
266
@with_httmock(connection_error)
267
def test_mollie_endpoint_connection_error(mollie):
268
    with pytest.raises(eopayment.PaymentException) as excinfo:
269
        mollie.call_endpoint('GET', 'payments')
270
        assert 'test msg' in str(excinfo.value)
271

  
272

  
273
@with_httmock(http_error)
274
def test_mollie_endpoint_http_error(mollie):
275
    with pytest.raises(eopayment.PaymentException) as excinfo:
276
        mollie.call_endpoint('GET', 'payments')
277
        assert 'Not Found' in str(excinfo.value)
278
        assert 'token' in str(excinfo.value)
279

  
280

  
281
@with_httmock(invalid_json)
282
def test_mollie_endpoint_json_error(mollie):
283
    with pytest.raises(eopayment.PaymentException) as excinfo:
284
        mollie.call_endpoint('GET', 'payments')
285
        assert 'JSON' in str(excinfo.value)
0
-