diff --git a/eopayment/__init__.py b/eopayment/__init__.py
index 281e151..245c5e2 100644
--- a/eopayment/__init__.py
+++ b/eopayment/__init__.py
@@ -34,7 +34,7 @@ __all__ = ['Payment', 'URL', 'HTML', 'FORM', 'SIPS', 'SYSTEMPAY', 'SPPLUS',
'get_backends', 'PAYFIP_WS']
if six.PY3:
- __all__.append('KEYWARE')
+ __all__.extend(['KEYWARE', 'MOLLIE'])
SIPS = 'sips'
SIPS2 = 'sips2'
@@ -47,6 +47,7 @@ PAYBOX = 'paybox'
PAYZEN = 'payzen'
PAYFIP_WS = 'payfip_ws'
KEYWARE = 'keyware'
+MOLLIE = 'mollie'
logger = logging.getLogger(__name__)
@@ -57,7 +58,7 @@ def get_backend(kind):
return module.Payment
__BACKENDS = [DUMMY, SIPS, SIPS2, SYSTEMPAY, SPPLUS, OGONE, PAYBOX, PAYZEN,
- TIPI, PAYFIP_WS, KEYWARE]
+ TIPI, PAYFIP_WS, KEYWARE, MOLLIE]
def get_backends():
diff --git a/eopayment/common.py b/eopayment/common.py
index 614984b..b027837 100644
--- a/eopayment/common.py
+++ b/eopayment/common.py
@@ -169,7 +169,7 @@ class PaymentCommon(object):
return id
@staticmethod
- def clean_amount(amount, min_amount=0, max_amount=None):
+ def clean_amount(amount, min_amount=0, max_amount=None, cents=True):
try:
amount = Decimal(amount)
except ValueError:
@@ -177,8 +177,9 @@ class PaymentCommon(object):
'at most after the decimal point', amount)
if int(amount) < min_amount or (max_amount and int(amount) > max_amount):
raise ValueError('amount %s is not in range [%s, %s]' % (amount, min_amount, max_amount))
- amount *= Decimal('100') # convert to cents
- amount = amount.to_integral_value(ROUND_DOWN)
+ if cents:
+ amount *= Decimal('100') # convert to cents
+ amount = amount.to_integral_value(ROUND_DOWN)
return str(amount)
diff --git a/eopayment/mollie.py b/eopayment/mollie.py
new file mode 100644
index 0000000..400fa1e
--- /dev/null
+++ b/eopayment/mollie.py
@@ -0,0 +1,140 @@
+# eopayment - online payment library
+# Copyright (C) 2011-2020 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from gettext import gettext as _
+
+import requests
+from six.moves.urllib.parse import parse_qs, urljoin
+
+from .common import (CANCELLED, ERROR, PAID, URL, PaymentCommon,
+ PaymentException, PaymentResponse, ResponseError)
+
+__all__ = ['Payment']
+
+
+class Payment(PaymentCommon):
+ '''Implements Mollie API, see https://docs.mollie.com/reference/v2/.'''
+ service_url = 'https://api.mollie.com/v2/'
+
+ description = {
+ 'caption': 'Mollie payment backend',
+ 'parameters': [
+ {
+ 'name': 'normal_return_url',
+ 'caption': _('Normal return URL'),
+ 'required': True,
+ },
+ {
+ 'name': 'automatic_return_url',
+ 'caption': _('Asychronous return URL'),
+ 'required': True,
+ },
+ {
+ 'name': 'service_url',
+ 'caption': _('URL of the payment service'),
+ 'default': service_url,
+ 'type': str,
+ 'validation': lambda x: x.startswith('https'),
+ },
+ {
+ 'name': 'api_key',
+ 'caption': _('API key'),
+ 'required': True,
+ 'validation': lambda x: x.startswith('test_') or x.startswith('live_'),
+ },
+ {
+ 'name': 'description',
+ 'caption': _('General description that will be displayed for all payments'),
+ 'required': True,
+ },
+ ],
+ }
+
+ def request(self, amount, **kwargs):
+ amount = self.clean_amount(amount, cents=False)
+
+ metadata = {k: v for k, v in kwargs.items()
+ if k in ('email', 'first_name', 'last_name') and v is not None}
+ body = {
+ 'amount': {
+ 'value': amount,
+ 'currency': 'EUR',
+ },
+ 'redirectUrl': self.normal_return_url,
+ 'webhookUrl': self.automatic_return_url,
+ 'metadata': metadata,
+ 'description': self.description,
+ }
+
+ resp = self.call_endpoint('POST', 'payments', data=body)
+
+ return resp['id'], URL, resp['_links']['checkout']['href']
+
+ def response(self, query_string, redirect=False, order_id_hint=None,
+ order_status_hint=None, **kwargs):
+ if redirect:
+ if order_status_hint in (PAID, CANCELLED, ERROR):
+ return PaymentResponse(order_id=order_id_hint, result=order_status_hint)
+ else:
+ payment_id = order_id_hint
+ elif query_string:
+ fields = parse_qs(query_string)
+ payment_id = fields['id'][0]
+ else:
+ raise ResponseError('cannot infer payment id')
+
+ resp = self.call_endpoint('GET', 'payments/' + payment_id)
+
+ status = resp['status']
+ if status == 'paid':
+ result = PAID
+ elif status in ('canceled', 'expired'):
+ result = CANCELLED
+ else:
+ result = ERROR
+
+ response = PaymentResponse(
+ result=result,
+ signed=True,
+ bank_data=resp,
+ order_id=payment_id,
+ transaction_id=payment_id,
+ bank_status=status,
+ test=resp['mode'] == 'test'
+ )
+ return response
+
+ def call_endpoint(self, method, endpoint, data=None):
+ url = urljoin(self.service_url, endpoint)
+ headers = {'Authorization': 'Bearer %s' % self.api_key}
+ try:
+ response = requests.request(method, url, headers=headers, json=data)
+ except requests.exceptions.RequestException as e:
+ raise PaymentException('%s error on endpoint "%s": %s' % (method, endpoint, e))
+ try:
+ result = response.json()
+ except ValueError:
+ self.logger.debug('received invalid json %r', response.text)
+ raise PaymentException('%s on endpoint "%s" returned invalid JSON: %s' %
+ (method, endpoint, response.text))
+ self.logger.debug('received "%s" with status %s', result, response.status_code)
+ try:
+ response.raise_for_status()
+ except requests.exceptions.HTTPError as e:
+ raise PaymentException(
+ '%s error on endpoint "%s": %s "%s"' % (method, endpoint, e,
+ result.get('detail', result)))
+ return result
diff --git a/tests/test_mollie.py b/tests/test_mollie.py
new file mode 100644
index 0000000..a4b33b4
--- /dev/null
+++ b/tests/test_mollie.py
@@ -0,0 +1,274 @@
+# eopayment - online payment library
+# Copyright (C) 2011-2020 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import json
+
+import requests
+import six
+
+import eopayment
+import pytest
+from eopayment.mollie import Payment
+from httmock import remember_called, response, urlmatch, with_httmock
+
+pytestmark = pytest.mark.skipif(six.PY2, reason='this payment module only supports python3')
+
+WEBHOOK_URL = 'https://callback.example.com'
+RETURN_URL = 'https://return.example.com'
+API_KEY = 'test'
+
+PAYMENT_ID = "tr_7UhSN1zuXS"
+QUERY_STRING = 'id=' + PAYMENT_ID
+
+
+POST_PAYMENTS_RESPONSE = {
+ "resource": "payment",
+ "id": PAYMENT_ID,
+ "mode": "test",
+ "createdAt": "2018-03-20T09:13:37+00:00",
+ "amount": {
+ "value": "3.50",
+ "currency": "EUR"
+ },
+ "description": "Payment #12345",
+ "method": "null",
+ "status": "open",
+ "isCancelable": True,
+ "expiresAt": "2018-03-20T09:28:37+00:00",
+ "sequenceType": "oneoff",
+ "redirectUrl": "https://webshop.example.org/payment/12345/",
+ "webhookUrl": "https://webshop.example.org/payments/webhook/",
+ "_links": {
+ "checkout": {
+ "href": "https://www.mollie.com/payscreen/select-method/7UhSN1zuXS",
+ "type": "text/html"
+ },
+ }
+}
+
+
+GET_PAYMENTS_RESPONSE = {
+ "amount": {
+ "currency": "EUR",
+ "value": "3.50"
+ },
+ "amountRefunded": {
+ "currency": "EUR",
+ "value": "0.00"
+ },
+ "amountRemaining": {
+ "currency": "EUR",
+ "value": "3.50"
+ },
+ "countryCode": "FR",
+ "createdAt": "2020-05-06T13:04:26+00:00",
+ "description": "Publik",
+ "details": {
+ "cardAudience": "consumer",
+ "cardCountryCode": "NL",
+ "cardHolder": "T. TEST",
+ "cardLabel": "Mastercard",
+ "cardNumber": "6787",
+ "cardSecurity": "normal",
+ "feeRegion": "other"
+ },
+ "id": PAYMENT_ID,
+ "metadata": {
+ "email": "test@entrouvert.com",
+ "first_name": "test",
+ "last_name": "test"
+ },
+ "isCancelable": True,
+ "method": "creditcard",
+ "mode": "test",
+ "paidAt": "2020-05-06T14:01:04+00:00",
+ "profileId": "pfl_WNPCPTGepu",
+ "redirectUrl": "https://localhost/lingo/return-payment-backend/3/MTAw.1jWJis.6TbbjwSEurag6v4Z2VCheISBFjw/",
+ "resource": "payment",
+ "sequenceType": "oneoff",
+ "settlementAmount": {
+ "currency": "EUR",
+ "value": "3.50"
+ },
+ "status": "paid",
+ "webhookUrl": "https://localhost/lingo/callback-payment-backend/3/"
+}
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='POST')
+def add_payment(url, request):
+ return response(200, POST_PAYMENTS_RESPONSE, request=request)
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
+def successful_payment(url, request):
+ return response(200, GET_PAYMENTS_RESPONSE, request=request)
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path=r'/v2/payments', method='DELETE')
+def canceled_payment(url, request):
+ resp = GET_PAYMENTS_RESPONSE.copy()
+ resp['status'] = 'canceled'
+ return response(200, resp, request=request)
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments')
+def failed_payment(url, request):
+ resp = GET_PAYMENTS_RESPONSE.copy()
+ resp['status'] = 'failed'
+ return response(200, resp, request=request)
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments')
+def expired_payment(url, request):
+ resp = GET_PAYMENTS_RESPONSE.copy()
+ resp['status'] = 'expired'
+ return response(200, resp, request=request)
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path=r'/v2/payments', method='GET')
+def canceled_payment_get(url, request):
+ resp = GET_PAYMENTS_RESPONSE.copy()
+ resp['status'] = 'canceled'
+ return response(200, resp, request=request)
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
+def connection_error(url, request):
+ raise requests.ConnectionError('test msg')
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
+def http_error(url, request):
+ error_payload = {
+ 'status': 404,
+ 'title': 'Not Found',
+ 'detail': 'No payment exists with token hop.',
+ }
+ return response(400, error_payload, request=request)
+
+
+@remember_called
+@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
+def invalid_json(url, request):
+ return response(200, '{', request=request)
+
+
+@pytest.fixture
+def mollie():
+ return Payment({
+ 'normal_return_url': RETURN_URL,
+ 'automatic_return_url': WEBHOOK_URL,
+ 'api_key': API_KEY,
+ })
+
+
+@with_httmock(add_payment)
+def test_mollie_request(mollie):
+ email = 'test@test.com'
+ payment_id, kind, url = mollie.request(2.5, email=email)
+
+ assert payment_id == PAYMENT_ID
+ assert kind == eopayment.URL
+ assert 'mollie.com/payscreen/' in url
+
+ body = json.loads(add_payment.call['requests'][0].body.decode())
+ assert body['amount']['value'] == '2.5'
+ assert body['amount']['currency'] == 'EUR'
+ assert body['metadata']['email'] == email
+ assert body['webhookUrl'] == WEBHOOK_URL
+ assert body['redirectUrl'] == RETURN_URL
+
+
+@with_httmock(successful_payment)
+def test_mollie_response(mollie):
+ payment_response = mollie.response(QUERY_STRING)
+
+ assert payment_response.result == eopayment.PAID
+ assert payment_response.signed is True
+ assert payment_response.bank_data == GET_PAYMENTS_RESPONSE
+ assert payment_response.order_id == PAYMENT_ID
+ assert payment_response.transaction_id == PAYMENT_ID
+ assert payment_response.bank_status == 'paid'
+ assert payment_response.test is True
+
+ request = successful_payment.call['requests'][0]
+ assert PAYMENT_ID in request.url
+
+
+@with_httmock(successful_payment)
+def test_mollie_response_on_redirect(mollie):
+ payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID,
+ order_status_hint=0)
+ assert payment_response.result == eopayment.PAID
+
+ request = successful_payment.call['requests'][0]
+ assert PAYMENT_ID in request.url
+
+
+def test_mollie_response_on_redirect_final_status(mollie):
+ payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID,
+ order_status_hint=eopayment.PAID)
+ assert payment_response.result == eopayment.PAID
+ assert payment_response.order_id == PAYMENT_ID
+
+
+@with_httmock(failed_payment)
+def test_mollie_response_failed(mollie):
+ payment_response = mollie.response(QUERY_STRING)
+ assert payment_response.result == eopayment.ERROR
+
+
+@with_httmock(canceled_payment_get)
+def test_mollie_response_canceled(mollie):
+ payment_response = mollie.response(QUERY_STRING)
+ assert payment_response.result == eopayment.CANCELED
+
+
+@with_httmock(expired_payment)
+def test_mollie_response_expired(mollie):
+ payment_response = mollie.response(QUERY_STRING)
+ assert payment_response.result == eopayment.CANCELED
+
+
+@with_httmock(connection_error)
+def test_mollie_endpoint_connection_error(mollie):
+ with pytest.raises(eopayment.PaymentException) as excinfo:
+ mollie.call_endpoint('GET', 'payments')
+ assert 'test msg' in str(excinfo.value)
+
+
+@with_httmock(http_error)
+def test_mollie_endpoint_http_error(mollie):
+ with pytest.raises(eopayment.PaymentException) as excinfo:
+ mollie.call_endpoint('GET', 'payments')
+ assert 'Not Found' in str(excinfo.value)
+ assert 'token' in str(excinfo.value)
+
+
+@with_httmock(invalid_json)
+def test_mollie_endpoint_json_error(mollie):
+ with pytest.raises(eopayment.PaymentException) as excinfo:
+ mollie.call_endpoint('GET', 'payments')
+ assert 'JSON' in str(excinfo.value)