From a8fded9de2106de439e9924121c8a96f9c393229 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Tue, 24 Dec 2019 13:42:47 +0100
Subject: [PATCH 2/2] lingo: support anonymous and no basket payment (#36876)

---
 .../templates/lingo/combo/payment-status.html |  53 ++++
 combo/apps/lingo/urls.py                      |  11 +-
 combo/apps/lingo/views.py                     | 201 ++++++++++++--
 tests/test_lingo_payment.py                   | 255 +++++++++++++++++-
 tests/test_lingo_remote_regie.py              |  19 +-
 5 files changed, 494 insertions(+), 45 deletions(-)
 create mode 100644 combo/apps/lingo/templates/lingo/combo/payment-status.html

diff --git a/combo/apps/lingo/templates/lingo/combo/payment-status.html b/combo/apps/lingo/templates/lingo/combo/payment-status.html
new file mode 100644
index 0000000..d362130
--- /dev/null
+++ b/combo/apps/lingo/templates/lingo/combo/payment-status.html
@@ -0,0 +1,53 @@
+{% extends "combo/page_template.html" %}
+{% load staticfiles i18n %}
+
+{% block combo-content %}
+{% block wait-js %}
+<script>
+function display_error(message) {
+    $('#transaction-error').text(message);
+    $('#transaction-error').show();
+    $("#wait-msg").hide();
+}
+
+$(function() {
+    var next_url = '{{next_url}}';
+    var transaction_id = '{{transaction_id}}';
+    if (transaction_id === "") {
+        display_error($('#transaction-error').data('error'));
+    }
+    else {
+        $.ajax({
+            url: `/api/lingo/transaction-status/${transaction_id}/`,
+            success: function(data, status) {
+                if (!data.wait) {
+                    $('#wait-msg').text($('#wait-msg').data('continue'))
+                    // wait a little to show messages
+                    setTimeout(function(){location.href=next_url}, 3000);
+                } else if (data.error) {
+                    display_error(data.error_msg)
+                } else {
+                    setTimeout(wait_payment, 3000, next_url, transaction_id);
+                }
+            },
+            error: function(error) {
+                display_error($('#transaction-status').data('error'));
+                window.console && console.log(':(', error);
+            }
+        });
+    }
+});
+</script>
+{% endblock %}
+
+{% block wait-content%}
+<div>
+  {% block wait-message %}
+  <h2 id="wait-msg" data-continue="{% trans "Wait a moment or click on 'Continue'." %}">{% trans "Please wait while your request is being processed..." %}</h2>
+  {% endblock %}
+   <div id="transaction-error" class="errornotice" data-error="{% trans 'An error occured' %}" style="display: none;"></div>
+   <p><a id="next-url" href="{{next_url}}">{% trans "Continue" %}</a></p>
+   </p>
+</div>
+{% endblock %}
+{% endblock %}
diff --git a/combo/apps/lingo/urls.py b/combo/apps/lingo/urls.py
index 902d895..1a1bd31 100644
--- a/combo/apps/lingo/urls.py
+++ b/combo/apps/lingo/urls.py
@@ -21,7 +21,8 @@ from combo.urls_utils import decorated_includes, manager_required
 from .views import (RegiesApiView, AddBasketItemApiView, PayView, CallbackView,
                     ReturnView, ItemDownloadView, ItemView, CancelItemView,
                     RemoveBasketItemApiView, ValidateTransactionApiView,
-                    CancelTransactionApiView, SelfInvoiceView, BasketItemPayView)
+                    CancelTransactionApiView, SelfInvoiceView, BasketItemPayView,
+                    TransactionStatusApiView, PaymentStatusView)
 from .manager_views import (RegieListView, RegieCreateView, RegieUpdateView,
                             RegieDeleteView, TransactionListView, BasketItemErrorListView,
                             download_transactions_csv, PaymentBackendListView,
@@ -60,6 +61,10 @@ urlpatterns = [
         name='api-validate-transaction'),
     url('^api/lingo/cancel-transaction$', CancelTransactionApiView.as_view(),
         name='api-cancel-transaction'),
+    url(
+        '^api/lingo/transaction-status/(?P<transaction_signature>.+)/$', TransactionStatusApiView.as_view(),
+        name='api-transaction-status'
+    ),
     url(r'^lingo/pay$', PayView.as_view(), name='lingo-pay'),
     url(r'^lingo/cancel/(?P<pk>\w+)/$', CancelItemView.as_view(), name='lingo-cancel-item'),
     url(r'^lingo/callback/(?P<regie_pk>\w+)/$', CallbackView.as_view(), name='lingo-callback'),
@@ -74,8 +79,10 @@ urlpatterns = [
         ItemDownloadView.as_view(), name='download-item-pdf'),
     url(r'^lingo/item/(?P<regie_id>[\w,-]+)/(?P<item_crypto_id>[\w,-]+)/$',
         ItemView.as_view(), name='view-item'),
-    url(r'^lingo/item/(?P<item_id>\d+)/pay$',
+    url(r'^lingo/item/(?P<item_signature>.+)/pay$',
         BasketItemPayView.as_view(), name='basket-item-pay-view'),
+    url(r'^lingo/payment-status$',
+        PaymentStatusView.as_view(), name='payment-status'),
     url(r'^lingo/self-invoice/(?P<cell_id>\w+)/$', SelfInvoiceView.as_view(),
         name='lingo-self-invoice'),
 ]
diff --git a/combo/apps/lingo/views.py b/combo/apps/lingo/views.py
index bb90d4b..4656bea 100644
--- a/combo/apps/lingo/views.py
+++ b/combo/apps/lingo/views.py
@@ -23,11 +23,13 @@ import requests
 from django.contrib.auth.models import User
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.core.urlresolvers import reverse
-from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest
+from django.core import signing
+from django.http import HttpResponse, HttpResponseNotFound, HttpResponseRedirect, HttpResponseBadRequest
 from django.http import HttpResponseForbidden, Http404, JsonResponse
 from django.template.response import TemplateResponse
 from django.utils import timezone, dateparse, six
 from django.utils.encoding import force_text
+from django.utils.http import urlencode
 from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import View, DetailView, ListView, TemplateView
 from django.conf import settings
@@ -41,22 +43,28 @@ import eopayment
 from combo.data.models import Page
 from combo.utils import check_request_signature, aes_hex_decrypt, DecryptionError
 from combo.profile.utils import get_user_from_name_id
+from combo.public.views import publish_page
 
 from .models import (Regie, BasketItem, Transaction, TransactionOperation,
-                     LingoBasketCell, SelfDeclaredInvoicePayment, PaymentBackend)
+                     LingoBasketCell, SelfDeclaredInvoicePayment, PaymentBackend, EXPIRED)
 
-def get_eopayment_object(request, regie_or_payment_backend):
+
+def get_eopayment_object(request, regie_or_payment_backend, transaction_id=None):
     payment_backend = regie_or_payment_backend
     if isinstance(regie_or_payment_backend, Regie):
         payment_backend = regie_or_payment_backend.payment_backend
     options = payment_backend.service_options
+    normal_return_url = reverse(
+        'lingo-return-payment-backend',
+        kwargs={'payment_backend_pk': payment_backend.id}
+    )
+    if transaction_id:
+        normal_return_url = "%s?lingo-transaction-id=%s" % (normal_return_url, signing.dumps(transaction_id))
     options.update({
         'automatic_return_url': request.build_absolute_uri(
                 reverse('lingo-callback-payment-backend',
                         kwargs={'payment_backend_pk': payment_backend.id})),
-        'normal_return_url': request.build_absolute_uri(
-                reverse('lingo-return-payment-backend',
-                        kwargs={'payment_backend_pk': payment_backend.id})),
+        'normal_return_url': request.build_absolute_uri(normal_return_url)
     })
     return eopayment.Payment(payment_backend.service, options)
 
@@ -150,7 +158,7 @@ class AddBasketItemApiView(View):
             elif request.GET.get('email'):
                 user = User.objects.get(email=request.GET.get('email'))
             else:
-                raise Exception('no user specified')
+                user = None
         except User.DoesNotExist:
             raise Exception('unknown user')
 
@@ -192,9 +200,17 @@ class AddBasketItemApiView(View):
                     'Bad format for capture date, it should be yyyy-mm-dd.')
 
         item.save()
-        item.regie.compute_extra_fees(user=item.user)
-
-        payment_url = reverse('basket-item-pay-view', kwargs={'item_id': item.id})
+        if user:
+            item.regie.compute_extra_fees(user=item.user)
+        else:
+            if item.regie.extra_fees_ws_url:
+                HttpResponseBadRequest('Can not compute extra fees with anonymous user.')
+
+        payment_url = reverse(
+            'basket-item-pay-view',
+            kwargs={
+                'item_signature': signing.dumps(item.pk)
+            })
         return JsonResponse({'result': 'success', 'id': str(item.id),
                              'payment_url': request.build_absolute_uri(payment_url)})
 
@@ -321,7 +337,10 @@ class CancelTransactionApiView(View):
 
 class PayMixin(object):
     @atomic
-    def handle_payment(self, request, regie, items, remote_items, next_url='/', email=''):
+    def handle_payment(
+            self, request, regie, items, remote_items, next_url='/', email='', firstname='',
+            lastname=''):
+
         if remote_items:
             total_amount = sum([x.amount for x in remote_items])
         else:
@@ -329,7 +348,7 @@ class PayMixin(object):
 
         if total_amount < regie.payment_min_amount:
             messages.warning(request, _(u'Minimal payment amount is %s €.') % regie.payment_min_amount)
-            return HttpResponseRedirect(next_url)
+            return HttpResponseRedirect(get_payment_status_view(next_url=items[0].source_url))
 
         for item in items:
             if item.regie != regie:
@@ -344,8 +363,6 @@ class PayMixin(object):
             lastname = user.last_name
         else:
             transaction.user = None
-            firstname = ''
-            lastname = ''
 
         transaction.save()
         transaction.regie = regie
@@ -354,7 +371,7 @@ class PayMixin(object):
         transaction.status = 0
         transaction.amount = total_amount
 
-        payment = get_eopayment_object(request, regie)
+        payment = get_eopayment_object(request, regie, transaction.pk)
         kwargs = {
             'email': email, 'first_name': firstname, 'last_name': lastname
         }
@@ -373,8 +390,9 @@ class PayMixin(object):
 
         # store the next url in session in order to be able to redirect to
         # it if payment is canceled
-        request.session.setdefault('lingo_next_url',
-                                   {})[order_id] = request.build_absolute_uri(next_url)
+        if next_url:
+            request.session.setdefault('lingo_next_url',
+                                       {})[str(transaction.pk)] = request.build_absolute_uri(next_url)
         request.session.modified = True
 
         if kind == eopayment.URL:
@@ -433,21 +451,45 @@ class PayView(PayMixin, View):
         return self.handle_payment(request, regie, items, remote_items, next_url, email)
 
 
+def get_payment_status_view(transaction_id=None, next_url=None):
+    url = reverse('payment-status')
+    params = []
+    if transaction_id:
+        params.append(('transaction-id', signing.dumps(transaction_id)))
+    if next_url:
+        params.append(('next', next_url))
+    return "%s?%s" % (url, urlencode(params))
+
+
 class BasketItemPayView(PayMixin, View):
+
     def get(self, request, *args, **kwargs):
-        next_url = request.GET.get('next_url') or '/'
-        if not (request.user and request.user.is_authenticated):
-            return HttpResponseForbidden(_('No item payment allowed for anonymous users.'))
+        next_url = request.GET.get('next_url')
+        email = request.GET.get('email', '')
+        firstname = request.GET.get('firstname', '')
+        lastname = request.GET.get('lastname', '')
 
-        item = BasketItem.objects.get(pk=kwargs['item_id'])
+        item_signature = kwargs.get('item_signature')
+        try:
+            item_id = signing.loads(item_signature)
+        except signing.BadSignature:
+            return HttpResponseForbidden(_('Invalid payment request.'))
+
+        item = BasketItem.objects.get(pk=item_id)
         regie = item.regie
         if regie.extra_fees_ws_url:
             return HttpResponseForbidden(_('No item payment allowed as extra fees set.'))
 
-        if item.user != request.user:
+        if item.user and item.user != request.user:
             return HttpResponseForbidden(_('Wrong item: payment not allowed.'))
 
-        return self.handle_payment(request, regie, [item], [], next_url)
+        if not next_url:
+            next_url = item.source_url
+
+        return self.handle_payment(
+            request=request, regie=regie, items=[item], remote_items=[], next_url=next_url, email=email,
+            firstname=firstname, lastname=lastname
+        )
 
 
 class PaymentException(Exception):
@@ -600,25 +642,34 @@ class ReturnView(PaymentView):
 
     def handle_return(self, request, backend_response, **kwargs):
         transaction = None
+        transaction_id = request.GET.get('lingo-transaction-id')
+        if transaction_id:
+            try:
+                transaction_id = signing.loads(transaction_id)
+            except signing.BadSignature:
+                pass
         try:
             transaction = self.handle_response(request, backend_response, **kwargs)
         except UnsignedPaymentException as e:
             # some payment backends do not sign return URLs, don't mark this as
             # an error, they will provide a notification to the callback
             # endpoint.
-            pass
+            if transaction_id:
+                return HttpResponseRedirect(get_payment_status_view(transaction_id))
+            return HttpResponseRedirect(get_basket_url())
+
         except PaymentException as e:
             messages.error(request, _('We are sorry but the payment service '
                                       'failed to provide a correct answer.'))
+            if transaction_id:
+                return HttpResponseRedirect(get_payment_status_view(transaction_id))
             return HttpResponseRedirect(get_basket_url())
 
         if transaction and transaction.status in (eopayment.PAID, eopayment.ACCEPTED):
             messages.info(request, transaction.regie.get_text_on_success())
 
-        if transaction and request.session.get('lingo_next_url'):
-            redirect_url = request.session['lingo_next_url'].get(transaction.order_id)
-            if redirect_url:
-                return HttpResponseRedirect(redirect_url)
+        if transaction:
+            return HttpResponseRedirect(get_payment_status_view(transaction.pk))
 
         # return to basket page if there are still items to pay
         if request.user.is_authenticated:
@@ -765,3 +816,97 @@ class SelfInvoiceView(View):
             return HttpResponseRedirect(url)
         messages.warning(request, msg)
         return HttpResponseRedirect(request.GET.get('page_path') or '/')
+
+
+class PaymentStatusView(View):
+
+    http_method_names = ['get']
+
+    def get(self, request, *args, **kwargs):
+        page = Page()
+        page.template_name = 'standard'
+        template_name = 'lingo/combo/payment-status.html'
+
+        extra_context_data = getattr(request, 'extra_context_data', {})
+        extra_context_data['transaction_id'] = ''
+
+        transaction_id = request.GET.get('transaction-id')
+
+        if not transaction_id:
+            next_url = request.GET.get('next')
+            if not next_url:
+                next_url = '/'
+            extra_context_data['next_url'] = request.build_absolute_uri(next_url)
+            request.extra_context_data = extra_context_data
+            return publish_page(request, page, template_name=template_name)
+
+        try:
+            transaction_id = signing.loads(transaction_id)
+        except signing.BadSignature:
+            return HttpResponseForbidden(_('Invalid transaction signature.'))
+
+        try:
+            transaction = Transaction.objects.get(pk=transaction_id)
+        except Transaction.DoesNotExist:
+            return HttpResponseForbidden(_('Invalid transaction.'))
+
+        next_url = request.session.get('lingo_next_url', {}).get(str(transaction_id))
+        if not next_url:
+            next_url = get_basket_url()
+            if len(set([item.source_url for item in transaction.items.all()])) == 1:
+                next_url = transaction.items.first().source_url
+        next_url = request.build_absolute_uri(next_url)
+
+        extra_context_data['transaction_id'] = signing.dumps(transaction.pk)
+        extra_context_data['next_url'] = next_url
+        request.extra_context_data = extra_context_data
+        return publish_page(request, page, template_name=template_name)
+
+
+class TransactionStatusApiView(View):
+
+    http_method_names = ['get']
+
+    def get(self, request, *args, **kwargs):
+        transaction_signature = kwargs.get('transaction_signature')
+        try:
+            transaction_id = signing.loads(transaction_signature)
+        except signing.BadSignature:
+            return HttpResponseBadRequest(_('Invalid transaction.'))
+
+        try:
+            transaction = Transaction.objects.get(pk=transaction_id)
+        except (Transaction.DoesNotExist, ValueError):
+            return HttpResponseNotFound(_('Unknown transaction.'))
+
+        user = request.user if request.user.is_authenticated() else None
+        error_msg = _('Transaction does not belong to the requesting user')
+        if user and transaction.user and user != transaction.user:
+            return HttpResponseForbidden(error_msg)
+        if not user and transaction.user:
+            return HttpResponseForbidden(error_msg)
+
+        if transaction.is_paid():
+            data = {
+                'wait': False,
+                'error': False,
+                'error_msg': ''
+            }
+            return JsonResponse(data=data)
+
+        if transaction.status in (
+                eopayment.CANCELLED, eopayment.ERROR, eopayment.DENIED, EXPIRED
+        ):
+            data = {
+                'wait': True,
+                'error': True,
+                'error_msg': _('Payment error, you can continue and make another payment')
+            }
+            return JsonResponse(data=data)
+
+        data = {
+            'wait': True,
+            'error': False,
+            'error_msg': ''
+        }
+        return JsonResponse(data=data)
diff --git a/tests/test_lingo_payment.py b/tests/test_lingo_payment.py
index 0a0e152..09da44f 100644
--- a/tests/test_lingo_payment.py
+++ b/tests/test_lingo_payment.py
@@ -9,11 +9,13 @@ import mock
 
 from django.apps import apps
 from django.contrib.auth.models import User
+from django.core import signing
 from django.core.urlresolvers import reverse
 from django.core.wsgi import get_wsgi_application
 from django.conf import settings
 from django.test import override_settings
 from django.utils import timezone
+from django.utils.http import urlencode
 from django.utils.six.moves.urllib import parse as urlparse
 from django.contrib.messages.storage.session import SessionStorage
 from webtest import TestApp
@@ -22,7 +24,7 @@ from combo.data.models import Page
 from combo.apps.lingo.models import (
     Regie, BasketItem, Transaction, TransactionOperation, RemoteItem, EXPIRED, LingoBasketCell,
     PaymentBackend)
-from combo.utils import sign_url
+from combo.utils import aes_hex_decrypt, sign_url
 
 from .test_manager import login
 
@@ -120,6 +122,20 @@ def key(request, settings):
     else:
         return settings.LINGO_API_SIGN_KEY
 
+
+def assert_payment_status(url, transaction_id=None):
+    if hasattr(url, 'path'):
+        url = url.path
+
+    if transaction_id:
+        url, part = url.split('?')
+        query = urlparse.parse_qs(part)
+        assert 'transaction-id' in query
+        assert signing.loads(query['transaction-id'][0]) == transaction_id
+
+    assert url.startswith('/lingo/payment-status')
+
+
 def test_default_regie():
     payment_backend = PaymentBackend.objects.create(label='foo', slug='foo')
     Regie.objects.all().delete()
@@ -218,10 +234,14 @@ def test_successfull_items_payment(app, basket_page, regie, user, with_payment_b
     assert resp.status_code == 200
     # simulate successful return URL
     resp = app.get(qs['return_url'][0], params=args)
+    # redirect to payment status
     assert resp.status_code == 302
-    assert urlparse.urlparse(resp.url).path == '/test_basket_cell/'
+    assert urlparse.urlparse(resp.url).path.startswith('/lingo/payment-status')
     resp = resp.follow()
     assert 'Your payment has been succesfully registered.' in resp.text
+    assert urlparse.urlparse(resp.html.find('a', {'id': 'next-url'})['href']).path == \
+        '/test_basket_cell/'
+
 
 def test_add_amount_to_basket(app, key, regie, user):
     payment_backend = PaymentBackend.objects.create(
@@ -297,7 +317,9 @@ def test_add_amount_to_basket(app, key, regie, user):
     assert resp.status_code == 200
     response = json.loads(resp.text)
     assert response['result'] == 'success'
-    assert response['payment_url'].endswith('/lingo/item/%s/pay' % item.id)
+    payment_url = urlparse.urlparse(response['payment_url'])
+    assert payment_url.path.startswith('/lingo/item/')
+    assert payment_url.path.endswith('/pay')
     assert BasketItem.objects.filter(amount=Decimal('22.23')).exists()
     assert BasketItem.objects.filter(amount=Decimal('22.23'))[0].regie_id == other_regie.id
 
@@ -417,7 +439,7 @@ def test_pay_single_basket_item(app, key, regie, user, john_doe):
     assert BasketItem.objects.filter(regie=regie, amount=amount, payment_date__isnull=True).exists()
     payment_url = resp.json['payment_url']
     resp = app.get(payment_url, status=403)
-    assert 'No item payment allowed for anonymous users.' in resp.text
+    assert 'Wrong item: payment not allowed.' in resp.text
 
     login(app, username='john.doe', password='john.doe')
     resp = app.get(payment_url, status=403)
@@ -440,7 +462,6 @@ def test_pay_single_basket_item(app, key, regie, user, john_doe):
     assert resp.location.startswith('http://dummy-payment.demo.entrouvert.com/')
     qs = urlparse.parse_qs(urlparse.urlparse(resp.location).query)
     assert qs['amount'] == ['12.00']
-
     # simulate successful payment response from dummy backend
     data = {'transaction_id': qs['transaction_id'][0], 'ok': True,
             'amount': qs['amount'][0], 'signed': True}
@@ -448,9 +469,10 @@ def test_pay_single_basket_item(app, key, regie, user, john_doe):
     # dummy module put that URL in return_url query string parameter).
     resp = app.get(qs['return_url'][0], params=data)
     # check that item is paid
-    assert BasketItem.objects.filter(regie=regie, amount=amount, payment_date__isnull=False).exists()
-    # check that user is redirected to the next_url passed previously
-    assert resp.location == 'http://example.net/form/id/'
+    item  = BasketItem.objects.filter(regie=regie, amount=amount, payment_date__isnull=False).first()
+    # check that user is redirected to the item payment status view
+    assert_payment_status(resp.location, transaction_id=item.transaction_set.last().pk)
+
 
 def test_pay_multiple_regies(app, key, regie, user):
     test_add_amount_to_basket(app, key, regie, user)
@@ -743,6 +765,8 @@ def test_payment_no_callback_just_return(
     data = {'transaction_id': transaction_id,
             'amount': qs['amount'][0], 'ok': True}
     assert data['amount'] == '10.50'
+    return_qs = urlparse.parse_qs(urlparse.urlparse(qs['return_url'][0]).query)
+    lingo_transaction_id = return_qs['lingo-transaction-id'][0]
 
     # call return with unsigned POST
     with check_log(caplog, 'received unsigned payment'):
@@ -768,17 +792,22 @@ def test_payment_no_callback_just_return(
 
     # call return with signed POST
     data['signed'] = True
-    return_url = get_url(with_payment_backend, 'lingo-return', regie)
+    return_url = get_url(with_payment_backend, 'lingo-return', regie) + \
+        '?lingo-transaction-id=%s' % lingo_transaction_id
     with mock.patch('combo.utils.requests_wrapper.RequestsSession.request') as request:
         get_resp = app.post(return_url, params=data)
         url = request.call_args[0][1]
         assert url.startswith('http://example.org/testitem/jump/trigger/paid')
+    # redirect to payment status
     assert get_resp.status_code == 302
-    assert urlparse.urlparse(get_resp['location']).path == '/test_basket_cell/'
-    resp = app.get(get_resp['Location'])
+    assert urlparse.urlparse(get_resp.url).path.startswith('/lingo/payment-status')
+    resp = get_resp.follow()
     assert 'Your payment has been succesfully registered.' in resp.text
+    assert urlparse.urlparse(resp.html.find('a', {'id': 'next-url'})['href']).path == \
+        '/test_basket_cell/'
     assert Transaction.objects.get(order_id=transaction_id).status == eopayment.PAID
 
+
 def test_transaction_expiration():
     t1 = Transaction(status=0)
     t1.save()
@@ -995,3 +1024,207 @@ def test_payment_callback_error(app, basket_page, regie, user, with_payment_back
         assert url.startswith('http://example.org/testitem/jump/trigger/paid')
     assert BasketItem.objects.get(id=item.id).payment_date
     assert BasketItem.objects.get(id=item.id).notification_date
+
+
+@pytest.mark.parametrize("authenticated", [True, False])
+def test_payment_no_basket(app, user, regie, authenticated):
+    url = reverse('api-add-basket-item')
+    source_url = 'http://example.org/item/1'
+    data = {'amount': 10, 'display_name': 'test item', 'url': source_url}
+    if authenticated:
+        data['email'] = user.email
+    url = sign_url(url, settings.LINGO_API_SIGN_KEY)
+    resp = app.post_json(url, params=data)
+    assert resp.status_code == 200
+    payment_url = resp.json['payment_url']
+
+    item = BasketItem.objects.first()
+    assert item.user is None
+    assert item.amount == Decimal('10.00')
+    path = urlparse.urlparse(payment_url).path
+    start = '/lingo/item/'
+    end = '/pay'
+    assert path.startswith(start)
+    assert path.endswith(end)
+    signature = path.replace(start, '').replace(end, '')
+    assert signing.loads(signature) == item.id
+
+    if authenticated:
+        app = login(app)
+
+    # payment error due to too small amount
+    item.amount = Decimal('1.00')
+    item.save()
+    resp = app.get(payment_url)
+    assert_payment_status(resp.location)
+    resp = resp.follow()
+    assert 'Minimal payment amount is 4.50' in resp.text
+    # we can go back to form
+    assert source_url in resp.text
+
+    # amount ok, redirection to payment backend
+    item.amount = Decimal('10.00')
+    item.save()
+    resp = app.get(payment_url)
+    assert resp.location.startswith('http://dummy-payment.demo.entrouvert.com/')
+    qs = urlparse.parse_qs(urlparse.urlparse(resp.location).query)
+    assert qs['amount'] == ['10.00']
+    if authenticated:
+        assert qs['email'] == ['foo@example.com']
+    else:
+        assert 'email' not in qs
+
+    # mail can be specified here for anonymous user
+    resp = app.get(
+        payment_url,
+        params={
+            'email': 'foo@localhost',
+        }
+    )
+    assert resp.location.startswith('http://dummy-payment.demo.entrouvert.com/')
+    qs = urlparse.parse_qs(urlparse.urlparse(resp.location).query)
+    assert qs['amount'] == ['10.00']
+    if authenticated:
+        assert qs['email'] == ['foo@example.com']
+    else:
+        assert qs['email'] == ['foo@localhost']
+
+    # simulate bad responseform payment backend, no transaction id
+    data = {'amount': qs['amount'][0], 'signed': True}
+    return_url = qs['return_url'][0]
+    assert 'lingo-transaction-id' in return_url
+    resp = app.get(return_url, params=data)
+    assert_payment_status(resp.location)
+    resp = resp.follow()
+    assert 'We are sorry but the payment service failed to provide a correct answer.' in resp.text
+    assert 'http://example.org/item/1' in resp.text
+    # check that item is not paid
+    item = BasketItem.objects.get(pk=item.pk)
+    assert not item.payment_date
+
+    # simulate successful payment response from dummy backend
+    data = {
+        'transaction_id': qs['transaction_id'][0], 'ok': True,
+        'amount': qs['amount'][0], 'signed': True
+    }
+    return_url = qs['return_url'][0]
+    assert 'lingo-transaction-id' in return_url
+    resp = app.get(return_url, params=data)
+    assert_payment_status(resp.location, transaction_id=item.transaction_set.last().pk)
+    # check that item is paid
+    item = BasketItem.objects.get(pk=item.pk)
+    assert item.payment_date
+    # accept redirection to item payment status view
+    resp = resp.follow()
+    # which should it self redirect to item.source_url as it is paid
+    assert 'Please wait while your request is being processed' in resp.text
+    assert source_url in resp.text
+
+
+def test_transaction_status_api(app, regie, user):
+    # invalid transaction signature
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps('xxxx')}
+
+    )
+    resp = app.get(url, status=404)
+    assert 'Unknown transaction.' in resp.text
+
+    # unkown transaction identifier
+    transaction_id = 1000
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction_id)}
+    )
+    resp = app.get(url, status=404)
+    assert 'Unknown transaction.' in resp.text
+
+    wait_response = {
+        'wait': True,
+        'error': False,
+        'error_msg': ''
+    }
+    # anonymous user on anonymous transaction: OK
+    transaction = Transaction.objects.create(amount=Decimal('10.0'), regie=regie, status=0)
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction.pk)}
+    )
+    resp = app.get(url)
+    assert resp.json == wait_response
+
+    # authenticated user on anonymous transaction: OK
+    transaction = Transaction.objects.create(amount=Decimal('10.0'), regie=regie, status=0)
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction.pk)}
+    )
+    resp = login(app).get(url)
+    assert resp.json == wait_response
+    app.reset()
+
+    # authenticated user on his transaction: OK
+    transaction = Transaction.objects.create(
+        amount=Decimal('10.0'), regie=regie, status=0, user=user)
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction.pk)}
+    )
+    resp = login(app).get(url)
+    assert resp.json == wait_response
+    app.reset()
+
+    error_msg = 'Transaction does not belong to the requesting user'
+    # anonymous user on other user transaction transaction: NOTOK
+    transaction = Transaction.objects.create(
+        amount=Decimal('10.0'), regie=regie, status=0, user=user)
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction.pk)}
+    )
+    resp = app.get(url, status=403)
+    assert error_msg in resp.text
+
+    # authenticated user on other user transaction transaction: NOTOK
+    user2 = User.objects.create_user(
+        'user2', password='user2', email='user2@example.com'
+    )
+    transaction = Transaction.objects.create(amount=Decimal('10.0'), regie=regie, status=0, user=user2)
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction.pk)}
+    )
+    resp = login(app).get(url, status=403)
+    assert error_msg in resp.text
+    app.reset()
+
+    # transaction error
+    transaction = Transaction.objects.create(
+        amount=Decimal('10.0'), regie=regie, status=eopayment.ERROR
+    )
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction.pk)}
+    )
+    resp = app.get(url)
+    assert resp.json == {
+        'wait': True,
+        'error': True,
+        'error_msg': 'Payment error, you can continue and make another payment'
+    }
+
+    # transaction paid
+    transaction = Transaction.objects.create(
+        amount=Decimal('10.0'), regie=regie, status=eopayment.PAID
+    )
+    url = reverse(
+        'api-transaction-status',
+        kwargs={'transaction_signature': signing.dumps(transaction.pk)}
+    )
+    resp = app.get(url)
+    assert resp.json == {
+        'wait': False,
+        'error': False,
+        'error_msg': ''
+    }
diff --git a/tests/test_lingo_remote_regie.py b/tests/test_lingo_remote_regie.py
index d4e2c08..3cd6b6f 100644
--- a/tests/test_lingo_remote_regie.py
+++ b/tests/test_lingo_remote_regie.py
@@ -211,8 +211,11 @@ def test_anonymous_successful_item_payment(mock_get, mock_pay_invoice, app, remo
                 kwargs={'payment_backend_pk': remote_regie.payment_backend.id}))
     # simulate successful return URL
     resp = app.get(qs['return_url'][0], params=args)
+    # redirect to payment status
     assert resp.status_code == 302
-    assert urlparse.urlparse(resp.url).path == '/'
+    assert urlparse.urlparse(resp.url).path.startswith('/lingo/payment-status')
+    resp = resp.follow()
+    assert urlparse.urlparse(resp.html.find('a', {'id': 'next-url'})['href']).path == '/'
     # simulate successful call to callback URL
     resp = app.get(reverse('lingo-callback', kwargs={'regie_pk': remote_regie.id}), params=args)
     trans = Transaction.objects.all()
@@ -335,8 +338,13 @@ def test_remote_item_payment_failure(mock_post, mock_get, mock_pay_invoice, app,
     # simulate payment failure
     mock_get.side_effect = ConnectionError('where is my hostname?')
     resp = app.get(qs['return_url'][0], params=args)
+    # redirect to payment status
     assert resp.status_code == 302
-    assert urlparse.urlparse(resp.url).path == '/active-remote-invoices-page/'
+    assert urlparse.urlparse(resp.url).path.startswith('/lingo/payment-status')
+    resp = resp.follow()
+    assert urlparse.urlparse(resp.html.find('a', {'id': 'next-url'})['href']).path == \
+        '/active-remote-invoices-page/'
+
     # simulate successful call to callback URL
     resp = app.get(reverse('lingo-callback', kwargs={'regie_pk': remote_regie.id}), params=args)
     trans = Transaction.objects.all()
@@ -383,10 +391,13 @@ def test_remote_invoice_successfull_payment_redirect(mock_get, mock_pay_invoice,
     qs = urlparse.parse_qs(parsed.query)
     args = {'transaction_id': qs['transaction_id'][0], 'signed': True,
             'ok': True, 'reason': 'Paid'}
-
     resp = app.get(qs['return_url'][0], params=args)
+    # redirect to payment status
     assert resp.status_code == 302
-    assert urlparse.urlparse(resp.location).path == '/active-remote-invoices-page/'
+    assert urlparse.urlparse(resp.url).path.startswith('/lingo/payment-status')
+    resp = resp.follow()
+    assert urlparse.urlparse(resp.html.find('a', {'id': 'next-url'})['href']).path == \
+        '/active-remote-invoices-page/'
 
 
 @mock.patch('combo.apps.lingo.models.UserSAMLIdentifier')
-- 
2.20.1

