Projet

Général

Profil

0003-lingo-add-poll_backend-method-to-PaymentBackend-and-.patch

Benjamin Dauvergne, 06 mai 2021 15:23

Télécharger (44,6 ko)

Voir les différences:

Subject: [PATCH 3/3] lingo: add poll_backend method to PaymentBackend and
 Transaction (#49149)

Some payment backends in eopayment (like PayFiP) allow polling the
status of currently running transaction, and can signal if a running
transaction has expired. The new can_poll_backend() and poll_backend()
method on Transaction implement this conditional behaviour in lingo.
 combo/apps/lingo/forms.py                     |   8 +-
 .../management/commands/lingo-poll-backend.py |  96 ++++++
 combo/apps/lingo/models.py                    | 174 +++++++++--
 .../lingo/templates/lingo/combo/item.html     |   5 +-
 .../lingo/templates/lingo/combo/items.html    |   2 +-
 combo/apps/lingo/views.py                     |  64 ++--
 debian/combo.cron.d                           |   1 +
 tests/test_lingo_manager.py                   |   6 +-
 tests/test_lingo_payment.py                   | 279 ++++++++++++++----
 tests/test_lingo_remote_regie.py              |  98 ++++++
 10 files changed, 619 insertions(+), 114 deletions(-)
 create mode 100644 combo/apps/lingo/management/commands/lingo-poll-backend.py
combo/apps/lingo/forms.py
100 100
    def __init__(self, *args, **kwargs):
101 101
        super(RegieForm, self).__init__(*args, **kwargs)
102 102
        fields, initial = create_form_fields(
103
            self.instance.payment_backend.get_payment().get_parameters(scope='transaction'),
103
            self.instance.eopayment.get_parameters(scope='transaction'),
104 104
            self.instance.transaction_options,
105 105
        )
106 106
        self.fields.update(fields)
......
109 109
    def save(self):
110 110
        instance = super(RegieForm, self).save()
111 111
        instance.transaction_options = compute_json_field(
112
            self.instance.payment_backend.get_payment().get_parameters(scope='transaction'), self.cleaned_data
112
            self.instance.eopayment.get_parameters(scope='transaction'), self.cleaned_data
113 113
        )
114 114
        instance.save()
115 115
        return instance
......
123 123
    def __init__(self, *args, **kwargs):
124 124
        super(PaymentBackendForm, self).__init__(*args, **kwargs)
125 125
        fields, initial = create_form_fields(
126
            self.instance.get_payment().get_parameters(scope='global'), self.instance.service_options
126
            self.instance.eopayment.get_parameters(scope='global'), self.instance.service_options
127 127
        )
128 128
        self.fields.update(fields)
129 129
        self.initial.update(initial)
......
133 133
    def save(self):
134 134
        instance = super(PaymentBackendForm, self).save()
135 135
        instance.service_options = compute_json_field(
136
            self.instance.get_payment().get_parameters(scope='global'), self.cleaned_data
136
            self.instance.eopayment.get_parameters(scope='global'), self.cleaned_data
137 137
        )
138 138
        instance.save()
139 139
        return instance
combo/apps/lingo/management/commands/lingo-poll-backend.py
1
# lingo - basket and payment system
2
# Copyright (C) 2021  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 datetime
18
import logging
19

  
20
from django.core.management.base import BaseCommand, CommandError
21

  
22
from combo.apps.lingo.models import PaymentBackend, PaymentException
23

  
24
logger = logging.getLogger('combo.apps.lingo')
25

  
26

  
27
class Command(BaseCommand):
28
    def add_arguments(self, parser):
29
        parser.add_argument('--backend', default=None, help='slug of the backend')
30

  
31
        parser.add_argument('--all-backends', default=False, action='store_true', help='target all backends')
32

  
33
        parser.add_argument(
34
            '--noinput',
35
            '--no-input',
36
            action='store_false',
37
            dest='interactive',
38
            help='Tells Django to NOT prompt the user for input of any kind.',
39
        )
40

  
41
        parser.add_argument(
42
            '--max-age-in-days', default=3, type=int, help='max age of the transaction in days'
43
        )
44

  
45
    def handle(
46
        self,
47
        *args,
48
        backend=None,
49
        all_backends=False,
50
        max_age_in_days=None,
51
        interactive=True,
52
        verbosity=1,
53
        **options,
54
    ):
55
        qs = PaymentBackend.objects.all()
56
        if backend and all_backends:
57
            raise CommandError('--backend and --all-baskends cannot be used together')
58
        elif backend:
59
            try:
60
                backend = qs.get(slug=backend)
61
            except PaymentBackend.DoesNotExist:
62
                raise CommandError('no backend with slug "%s".' % backend)
63
            else:
64
                if not backend.can_poll_backend():
65
                    raise CommandError('backend "%s" cannot be polled.' % backend)
66
                backends = [backend]
67
        else:
68
            backends = [backend for backend in qs if backend.can_poll_backend()]
69
            if not backends:
70
                raise CommandError('no backend found.')
71
            if not all_backends and interactive:
72
                print('Choose backend by slug:')
73
                while True:
74
                    for backend in backends:
75
                        print(' - %s: %s' % (backend.slug, backend))
76
                    print('> ', end=' ')
77
                    slug = input().strip()
78
                    if not slug:
79
                        continue
80
                    filtered_backends = qs.filter(slug__icontains=slug)
81
                    if filtered_backends:
82
                        backends = filtered_backends
83
                        break
84

  
85
        for backend in backends:
86
            if verbosity >= 1:
87
                print('Polling backend', backend, '... ', end='')
88
            try:
89
                backend.poll_backend(max_age=max_age_in_days and datetime.timedelta(days=max_age_in_days))
90
            except PaymentException:
91
                logger.exception('polling failed')
92
                if interactive:
93
                    # show error
94
                    raise
95
            if verbosity >= 1:
96
                print('DONE')
combo/apps/lingo/models.py
27 27
from dateutil import parser
28 28
from django import template
29 29
from django.conf import settings
30
from django.contrib.auth.models import User
31 30
from django.core import serializers
32
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
31
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
33 32
from django.core.mail import EmailMultiAlternatives
34 33
from django.db import models
34
from django.db.transaction import atomic
35 35
from django.forms import Select
36 36
from django.forms import models as model_forms
37 37
from django.template.loader import render_to_string
......
39 39
from django.utils import dateparse, six, timezone
40 40
from django.utils.encoding import force_bytes, python_2_unicode_compatible
41 41
from django.utils.formats import localize
42
from django.utils.http import urlencode
43 42
from django.utils.six.moves.urllib import parse as urlparse
44
from django.utils.timezone import make_aware, utc
43
from django.utils.timezone import make_aware, now, utc
45 44
from django.utils.translation import ugettext_lazy as _
46 45
from jsonfield import JSONField
47 46
from requests import RequestException
......
156 155
    def __str__(self):
157 156
        return self.label
158 157

  
159
    def get_payment(self):
158
    @property
159
    def eopayment(self):
160
        return self.make_eopayment()
161

  
162
    def make_eopayment(self, *, request=None, automatic_return_url=None, normal_return_url=None, **kwargs):
160 163
        options = self.service_options or {}
161 164
        if isinstance(options, six.string_types):
162 165
            # backward compatibility when used againt postgresql < 9.4 and
......
167 170
                pass
168 171
        if not isinstance(options, dict):
169 172
            options = {}
173
        if request:
174
            if not automatic_return_url:
175
                automatic_return_url = reverse(
176
                    'lingo-callback-payment-backend', kwargs={'payment_backend_pk': self.id}
177
                )
178

  
179
            if automatic_return_url:
180
                automatic_return_url = request.build_absolute_uri(automatic_return_url)
181
            if normal_return_url:
182
                normal_return_url = request.build_absolute_uri(normal_return_url)
183
            options['automatic_return_url'] = automatic_return_url
184
            options['normal_return_url'] = normal_return_url
185
        else:
186
            assert (
187
                not automatic_return_url and not normal_return_url
188
            ), 'make_eopayment must be used with a request to set automatic_return_url or normal_return_url'
189
        options.update(**kwargs)
170 190
        return eopayment.Payment(self.service, options)
171 191

  
172 192
    def natural_key(self):
......
231 251
        transaction.handle_backend_response(response, callback=callback)
232 252
        return transaction
233 253

  
254
    def can_poll_backend(self):
255
        return self.eopayment.has_payment_status
256

  
257
    def poll_backend(self, min_age=None, max_age=None):
258
        if not self.can_poll_backend():
259
            return
260
        current_time = now()
261
        # poll transactions linked to the current backend
262
        # aged between 5 minutes and 3 hours, max_age can be overriden
263
        min_age = min_age or datetime.timedelta(minutes=5)
264
        not_after = current_time - min_age
265
        max_age = max_age or datetime.timedelta(hours=3)
266
        not_before = current_time - max_age
267
        transactions = Transaction.objects.filter(
268
            regie__payment_backend=self,
269
            start_date__lt=not_after,
270
            start_date__gt=not_before,
271
            status__in=Transaction.RUNNING_STATUSES,
272
        ).order_by('pk')
273
        last_pk = -1
274
        while True:
275
            # lock each transaction before trying to poll it
276
            with atomic():
277
                transaction = transactions.filter(pk__gt=last_pk).select_for_update(skip_locked=True).first()
278
                if not transaction:
279
                    break
280
                last_pk = transaction.pk
281
                transaction.poll_backend(ignore_errors=False)
282

  
234 283

  
235 284
@python_2_unicode_compatible
236 285
class Regie(models.Model):
......
547 596
        regie = next(serializers.deserialize('json', json.dumps([json_regie]), ignorenonexistent=True))
548 597
        regie.save()
549 598

  
599
    def can_poll_backend(self):
600
        return self.payment_backend.can_poll_backend()
601

  
602
    @property
603
    def eopayment(self):
604
        return self.make_eopayment()
605

  
606
    def make_eopayment(self, **kwargs):
607
        return self.payment_backend.make_eopayment(**kwargs)
608

  
550 609

  
551 610
class BasketItem(models.Model):
552 611
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True)
......
572 631

  
573 632
    @classmethod
574 633
    def get_items_to_be_paid(cls, user):
575
        return cls.objects.filter(
634
        qs = cls.objects.filter(
576 635
            user=user, payment_date__isnull=True, waiting_date__isnull=True, cancellation_date__isnull=True
577 636
        )
637
        for transaction in Transaction.objects.filter(items__in=qs):
638
            if transaction.can_poll_backend():
639
                transaction.poll_backend()
640
        return qs
578 641

  
579 642
    def notify(self, status):
580 643
        if not self.source_url:
......
656 719
        self.reference_id = reference_id
657 720
        if payment_date:
658 721
            self.payment_date = parser.parse(payment_date)
722
        self.waiting_date = None
659 723

  
660 724
    @property
661 725
    def no_online_payment_reason_details(self):
......
673 737
        return aes_hex_encrypt(settings.SECRET_KEY, force_bytes(str(self.id)))
674 738

  
675 739
    @classmethod
676
    def update_paid(cls, regie, remote_items):
677
        remote_item_ids = [remote_item.id for remote_item in remote_items if not remote_item.paid]
740
    def transactions_for_remote_items(cls, queryset, remote_items):
741
        remote_item_ids = set(remote_item.id for remote_item in remote_items if not remote_item.paid)
678 742
        if not remote_item_ids:
679
            return
743
            return Transaction.objects.none()
680 744

  
681
        paid_items = {}
682 745
        # filter transactions by regie, status and contained remote_item id
683
        transaction_qs = Transaction.objects.filter(
684
            regie=regie, status__in=[eopayment.PAID, eopayment.ACCEPTED]
685
        )
686 746
        query = reduce(
687 747
            models.Q.__or__,
688 748
            (models.Q(remote_items__contains=remote_item_id) for remote_item_id in remote_item_ids),
689 749
        )
690 750

  
691 751
        # accumulate in paid_items each remote_item earliest payment_date
692
        for transaction in transaction_qs.filter(query):
752
        for transaction in queryset.filter(query):
753
            for remote_item_id in transaction.remote_items.split(','):
754
                if remote_item_id in remote_item_ids:
755
                    yield transaction
756
                    break
757

  
758
    @classmethod
759
    def update_paid(cls, regie, remote_items):
760
        paid_items = {}
761
        waiting_items = {}
762
        transaction_qs = Transaction.objects.filter(regie=regie)
763

  
764
        can_poll_backend = regie.can_poll_backend()
765

  
766
        # accumulate in paid_items each remote_item earliest payment_date
767
        for transaction in cls.transactions_for_remote_items(transaction_qs, remote_items):
768
            if transaction.is_running() and can_poll_backend:
769
                transaction.poll_backend()
693 770
            for remote_item in transaction.remote_items.split(','):
694
                if remote_item not in paid_items:
695
                    paid_items[remote_item] = transaction.end_date
696
                else:
697
                    paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item])
771
                if transaction.end_date and transaction.is_paid():
772
                    if remote_item not in paid_items:
773
                        paid_items[remote_item] = transaction.end_date
774
                    else:
775
                        paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item])
776
                elif transaction.status == eopayment.WAITING and can_poll_backend:
777
                    waiting_items[remote_item] = transaction.start_date
698 778

  
699 779
        # update remote_item.paid using paid_items
700 780
        for remote_item in remote_items:
......
703 783
            if remote_item.id in paid_items:
704 784
                remote_item.paid = True
705 785
                remote_item.payment_date = paid_items[remote_item.id]
786
            elif remote_item.id in waiting_items:
787
                remote_item.waiting_date = waiting_items[remote_item.id]
706 788

  
707 789

  
708 790
def status_label(status):
......
731 813
    status = models.IntegerField(null=True)
732 814
    amount = models.DecimalField(default=0, max_digits=7, decimal_places=2)
733 815

  
816
    RUNNING_STATUSES = [0, eopayment.WAITING, eopayment.RECEIVED]
817
    PAID_STATUSES = [eopayment.PAID, eopayment.ACCEPTED]
818

  
734 819
    def is_remote(self):
735 820
        return self.remote_items != ''
736 821

  
......
740 825
        return _('Anonymous User')
741 826

  
742 827
    def is_paid(self):
743
        return self.status in (eopayment.PAID, eopayment.ACCEPTED)
828
        return self.status in self.PAID_STATUSES
744 829

  
745 830
    def is_running(self):
746
        return self.status in [0, eopayment.WAITING, eopayment.RECEIVED]
831
        return self.status in self.RUNNING_STATUSES
747 832

  
748 833
    def get_status_label(self):
749 834
        return status_label(self.status)
......
890 975
        if self.remote_items:
891 976
            self.first_notify_remote_items_of_payments()
892 977

  
978
    @property
979
    def eopayment(self):
980
        return self.regie.eopayment
981

  
982
    def make_eopayment(self, **kwargs):
983
        normal_return_url = reverse(
984
            'lingo-return-payment-backend',
985
            kwargs={
986
                'payment_backend_pk': self.regie.payment_backend.id,
987
                'transaction_signature': signing_dumps(self.pk),
988
            },
989
        )
990
        return self.regie.make_eopayment(normal_return_url=normal_return_url, **kwargs)
991

  
992
    def can_poll_backend(self):
993
        return self.regie and self.regie.can_poll_backend()
994

  
995
    def poll_backend(self, ignore_errors=True):
996
        with atomic():
997
            # lock the transaction
998
            Transaction.objects.filter(pk=self.pk).select_for_update().first()
999
            try:
1000
                response = self.eopayment.payment_status(self.order_id, transaction_date=self.start_date)
1001
            except eopayment.PaymentException:
1002
                if ignore_errors:
1003
                    logger.warning(
1004
                        'lingo: regie "%s" polling backend for transaction "%%s(%%s)" failed' % self.regie,
1005
                        self.order_id,
1006
                        self.id,
1007
                        exc_info=True,
1008
                    )
1009
                    return
1010
                raise PaymentException('polling failed')
1011

  
1012
            logger.debug(
1013
                'lingo: regie "%s" polling backend for transaction "%%s(%%s)"' % self.regie,
1014
                self.order_id,
1015
                self.id,
1016
            )
1017

  
1018
            if self.status != response.result:
1019
                self.handle_backend_response(response)
1020

  
893 1021

  
894 1022
class TransactionOperation(models.Model):
895 1023
    OPERATIONS = [
......
968 1096
        # list transactions :
969 1097
        # * paid by the user
970 1098
        # * or linked to a BasketItem of the user
971
        return Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter(
1099
        qs = Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter(
972 1100
            start_date__gte=timezone.now() - datetime.timedelta(days=7)
973 1101
        )
1102
        for transaction in qs:
1103
            if transaction.can_poll_backend() and transaction.is_running():
1104
                transaction.poll_backend()
1105
        return qs
974 1106

  
975 1107
    def is_relevant(self, context):
976 1108
        if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated):
combo/apps/lingo/templates/lingo/combo/item.html
48 48
        {% if item.no_online_payment_reason_details %}
49 49
        <div class="no-online-payment-reason"><span>{{ item.no_online_payment_reason_details }}</span></div>
50 50
        {% endif %}
51
        {% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount %}
51
        {% if item.waiting_date and not item.paid %}
52
        <div class="paid paid-info">{% trans "Waiting for payment." %}</div>
53
        {% endif %}
54
        {% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount and not item.waiting_date %}
52 55
        {% csrf_token %}
53 56
        {% if not user.is_authenticated %}
54 57
          <div class="email">
combo/apps/lingo/templates/lingo/combo/items.html
51 51
    {% if item.regie.is_remote %}
52 52
    <td>
53 53
      <a href="{% url 'view-item' regie_id=item.regie.pk item_crypto_id=item.crypto_id %}?page={{ cell.page.pk }}" rel="popup" class="icon-view">{% trans "View" %}
54
        {% if item.online_payment and item.amount >= item.regie.payment_min_amount %}{% trans "and pay" %}{% endif %}
54
          {% if item.online_payment and item.amount >= item.regie.payment_min_amount and not item.waiting_date %}{% trans "and pay" %}{% endif %}
55 55
      </a>
56 56
      {% if item.has_pdf %}
57 57
      <br/><a href="{% url 'download-item-pdf' regie_id=item.regie.pk item_crypto_id=item.crypto_id %}" class="icon-pdf"
combo/apps/lingo/views.py
58 58
    PaymentException,
59 59
    Regie,
60 60
    RemoteInvoiceException,
61
    RemoteItem,
61 62
    SelfDeclaredInvoicePayment,
62 63
    Transaction,
63 64
    TransactionOperation,
......
79 80
    status_code = 400
80 81

  
81 82

  
82
def get_eopayment_object(request, regie_or_payment_backend, transaction_id=None):
83
    payment_backend = regie_or_payment_backend
84
    if isinstance(regie_or_payment_backend, Regie):
85
        payment_backend = regie_or_payment_backend.payment_backend
86
    options = payment_backend.service_options
87
    options.update(
88
        {
89
            'automatic_return_url': request.build_absolute_uri(
90
                reverse('lingo-callback-payment-backend', kwargs={'payment_backend_pk': payment_backend.id})
91
            ),
92
        }
93
    )
94

  
95
    if transaction_id:
96
        options['normal_return_url'] = request.build_absolute_uri(
97
            reverse(
98
                'lingo-return-payment-backend',
99
                kwargs={
100
                    'payment_backend_pk': payment_backend.id,
101
                    'transaction_signature': signing_dumps(transaction_id),
102
                },
103
            )
104
        )
105
    return eopayment.Payment(payment_backend.service, options)
106

  
107

  
108 83
def get_basket_url():
109 84
    basket_cell = LingoBasketCell.objects.filter(page__snapshot__isnull=True).first()
110 85
    if basket_cell:
......
323 298
            )
324 299
            raise Http404
325 300

  
326
        payment = get_eopayment_object(request, transaction.regie)
327 301
        amount = LocaleDecimal(request.GET['amount'])
328 302

  
329 303
        logger.info(u'validating amount %s for transaction %s', amount, smart_text(transaction.id))
330 304
        try:
331
            result = payment.backend.validate(amount, transaction.bank_data)
305
            result = transaction.make_eopayment(request=request).backend.validate(
306
                amount, transaction.bank_data
307
            )
332 308
        except eopayment.ResponseError as e:
333 309
            logger.error(u'failed in validation operation: %s', e)
334 310
            return JsonResponse({'err': 1, 'e': force_text(e)})
......
361 337
            )
362 338
            raise Http404
363 339

  
364
        payment = get_eopayment_object(request, transaction.regie)
365 340
        amount = LocaleDecimal(request.GET['amount'])
366 341

  
367 342
        logger.info(u'cancelling amount %s for transaction %s', amount, smart_text(transaction.id))
368 343
        try:
369
            result = payment.backend.cancel(amount, transaction.bank_data)
344
            result = transaction.make_eopayment(request=request).backend.cancel(amount, transaction.bank_data)
370 345
        except eopayment.ResponseError as e:
371 346
            logger.error(u'failed in cancel operation: %s', e)
372 347
            return JsonResponse({'err': 1, 'e': force_text(e)})
......
389 364
        if bool(len(items)) == bool(len(remote_items)):
390 365
            messages.error(request, _('Items to pay are missing or are not of the same type (local/remote).'))
391 366
            return HttpResponseRedirect(next_url)
367

  
368
        if (
369
            regie.payment_backend.can_poll_backend()
370
            and self.poll_for_newly_paid_or_still_running_transactions(regie, items, remote_items)
371
        ):
372
            messages.error(request, _('Some items are already paid or are being paid.'))
373
            return HttpResponseRedirect(next_url)
374

  
392 375
        if regie.can_pay_only_one_basket_item and (len(items) > 1 or len(remote_items) > 1):
393 376
            messages.error(request, _('This regie allows to pay only one item.'))
394 377
            return HttpResponseRedirect(next_url)
......
425 408
        transaction.status = 0
426 409
        transaction.amount = total_amount
427 410

  
428
        payment = get_eopayment_object(request, regie, transaction.pk)
429 411
        kwargs = {'email': email, 'first_name': firstname, 'last_name': lastname}
430 412
        kwargs['merchant_name'] = settings.TEMPLATE_VARS.get('global_title') or 'Compte Citoyen'
431 413
        kwargs['items_info'] = []
......
463 445
        if regie.transaction_options:
464 446
            kwargs.update(regie.transaction_options)
465 447
        try:
466
            (order_id, kind, data) = payment.request(total_amount, **kwargs)
448
            (order_id, kind, data) = transaction.make_eopayment(request=request).request(
449
                total_amount, **kwargs
450
            )
467 451
        except eopayment.PaymentException as e:
468 452
            logger.error('failed to initiate payment request: %s', e)
469 453
            messages.error(request, _('Failed to initiate payment request'))
......
491 475

  
492 476
        raise NotImplementedError()
493 477

  
478
    def poll_for_newly_paid_or_still_running_transactions(self, regie, items, remote_items):
479
        '''Verify if any open transaction is not already paid.'''
480
        qs = Transaction.objects.filter(regie=regie, status__in=Transaction.RUNNING_STATUSES)
481
        if items:
482
            transactions = qs.filter(items__in=items)
483
        else:
484
            transactions = RemoteItem.transactions_for_remote_items(qs, remote_items)
485

  
486
        newly_paid_or_still_running = False
487
        for transaction in transactions:
488
            transaction.poll_backend()
489
            newly_paid_or_still_running |= transaction.is_paid() or transaction.is_running()
490
        return newly_paid_or_still_running
491

  
494 492

  
495 493
class PayView(PayMixin, View):
496 494
    def post(self, request, *args, **kwargs):
......
619 617
        else:
620 618
            return HttpResponseBadRequest("A payment backend or regie primary key must be specified")
621 619

  
622
        payment = get_eopayment_object(request, payment_backend)
620
        payment = payment_backend.make_eopayment(request=request)
623 621
        logger.info(u'received payment response: %r', backend_response)
624 622
        try:
625 623
            eopayment_response_kwargs = {'redirect': not callback}
debian/combo.cron.d
1 1
MAILTO=root
2 2

  
3 3
0 8 * * * combo /usr/bin/combo-manage tenant_command notify_new_remote_invoices --all-tenants -v0
4
*/10 * * * * combo /usr/bin/combo-manage tenant_command lingo-poll-backends --all-tenants -v0 --no-input
tests/test_lingo_manager.py
675 675

  
676 676

  
677 677
def test_use_old_service_options_safely(app, admin_user):
678
    PaymentBackend(service='dummy', service_options='xx').get_payment()
679
    PaymentBackend(service='dummy', service_options='"xx"').get_payment()
680
    PaymentBackend(service='dummy', service_options=None).get_payment()
678
    PaymentBackend(service='dummy', service_options='xx').eopayment
679
    PaymentBackend(service='dummy', service_options='"xx"').eopayment
680
    PaymentBackend(service='dummy', service_options=None).eopayment
tests/test_lingo_payment.py
11 11
from django.apps import apps
12 12
from django.conf import settings
13 13
from django.contrib.auth.models import User
14
from django.core.management import CommandError, call_command
15
from django.db import transaction
14 16
from django.http.request import QueryDict
15 17
from django.test import override_settings
16 18
from django.urls import reverse
17 19
from django.utils import timezone
18 20
from django.utils.six.moves.urllib import parse as urlparse
19
from django.utils.timezone import utc
21
from django.utils.timezone import now, utc
20 22
from mellon.models import UserSAMLIdentifier
21 23
from requests.exceptions import ConnectionError
22
from requests.models import Response
23 24

  
24 25
from combo.apps.lingo.models import (
25 26
    EXPIRED,
26 27
    BasketItem,
27 28
    LingoBasketCell,
29
    LingoRecentTransactionsCell,
28 30
    PaymentBackend,
31
    PaymentException,
29 32
    Regie,
30 33
    Transaction,
31 34
    TransactionOperation,
......
57 60

  
58 61

  
59 62
@pytest.fixture
60
def regie():
61
    try:
62
        payment_backend = PaymentBackend.objects.get(slug='test1')
63
    except PaymentBackend.DoesNotExist:
64
        payment_backend = PaymentBackend.objects.create(
65
            label='test1', slug='test1', service='dummy', service_options={'siret': '1234'}
66
        )
67
    try:
68
        regie = Regie.objects.get(slug='test')
69
    except Regie.DoesNotExist:
70
        regie = Regie()
71
        regie.label = 'Test'
72
        regie.slug = 'test'
73
        regie.description = 'test'
74
        regie.can_pay_only_one_basket_item = False
75
        regie.payment_min_amount = Decimal(4.5)
76
        regie.payment_backend = payment_backend
77
        regie.save()
63
def payment_backend():
64
    return PaymentBackend.objects.create(
65
        label='test1', slug='test1', service='dummy', service_options={'siret': '1234'}
66
    )
67

  
68

  
69
@pytest.fixture
70
def regie(payment_backend):
71
    regie = Regie()
72
    regie.label = 'Test'
73
    regie.slug = 'test'
74
    regie.description = 'test'
75
    regie.can_pay_only_one_basket_item = False
76
    regie.payment_min_amount = Decimal(4.5)
77
    regie.payment_backend = payment_backend
78
    regie.save()
78 79
    return regie
79 80

  
80 81

  
81 82
@pytest.fixture
82
def remote_regie():
83
    try:
84
        payment_backend = PaymentBackend.objects.get(slug='test1')
85
    except PaymentBackend.DoesNotExist:
86
        payment_backend = PaymentBackend.objects.create(
87
            label='test1', slug='test1', service='dummy', service_options={'siret': '1234'}
88
        )
89
    try:
90
        regie = Regie.objects.get(slug='remote')
91
    except Regie.DoesNotExist:
92
        regie = Regie(can_pay_only_one_basket_item=False)
93
        regie.label = 'Remote'
94
        regie.slug = 'remote'
95
        regie.description = 'remote'
96
        regie.payment_min_amount = Decimal(2.0)
97
        regie.payment_backend = payment_backend
98
        regie.webservice_url = 'http://example.org/regie'  # is_remote
99
        regie.save()
83
def remote_regie(payment_backend):
84
    regie = Regie()
85
    regie.label = 'Remote'
86
    regie.slug = 'remote'
87
    regie.description = 'remote'
88
    regie.payment_min_amount = Decimal(2.0)
89
    regie.payment_backend = payment_backend
90
    regie.webservice_url = 'http://example.org/regie'  # is_remote
91
    regie.save()
100 92
    return regie
101 93

  
102 94

  
......
217 209
    )
218 210
    BasketItem.objects.create(user=user, regie=regie, subject='item1', amount='1.5', source_url='/item/1')
219 211

  
220
    class MockPayment(object):
221
        request = mock.Mock(return_value=(9876, 3, {}))
222

  
223
    def get_eopayment_object(*args, **kwargs):
224
        return MockPayment
225

  
226
    import combo.apps.lingo.views
227

  
228
    monkeypatch.setattr(combo.apps.lingo.views, 'get_eopayment_object', get_eopayment_object)
229

  
230
    resp = login(app).get('/test_basket_cell/')
231
    resp = resp.form.submit()
232
    assert MockPayment.request.call_args[1]['manual_validation'] is True
212
    with mock.patch('eopayment.Payment') as MockPayment:
213
        MockPayment.return_value.request.return_value = (9876, 3, {})
214
        resp = login(app).get('/test_basket_cell/')
215
        resp = resp.form.submit()
216
        assert MockPayment.return_value.request.call_args[1]['manual_validation'] is True
233 217

  
234 218

  
235 219
@pytest.mark.parametrize('with_payment_backend', [False, True])
......
2039 2023
        assert response.location.startswith('http://dummy-payment.demo.entrouvert.com/')
2040 2024
        qs = parse_qs(response.location)
2041 2025
        assert qs['email'] == 'user1@example.com'
2026

  
2027

  
2028
class TestPolling:
2029
    @pytest.fixture
2030
    def payment_backend(self, payment_backend):
2031
        with mock.patch(
2032
            'eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/')
2033
        ):
2034
            payment_backend.service = 'payfip_ws'
2035
            payment_backend.save()
2036
            yield payment_backend
2037

  
2038
    class TestPollBackendCommand:
2039
        @pytest.fixture(autouse=True)
2040
        def setup(self, regie, payment_backend):
2041
            item = BasketItem.objects.create(amount=10, regie=regie)
2042

  
2043
            transaction = Transaction.objects.create(order_id='1234', status=0, amount=10, regie=regie)
2044
            transaction.items.set([item])
2045

  
2046
        @pytest.fixture
2047
        def payment_status(self):
2048
            with mock.patch('eopayment.Payment.payment_status') as payment_status:
2049
                payment_status.return_value = eopayment.common.PaymentResponse(
2050
                    order_id='1234',
2051
                    result=eopayment.PAID,
2052
                    transaction_date=now(),
2053
                    transaction_id='4567',
2054
                    bank_data={'abcd': 'xyz'},
2055
                    signed=True,
2056
                )
2057
                yield payment_status
2058

  
2059
        @pytest.mark.parametrize('cmd_options', [{'all_backends': True}, {'backend': 'test1'}])
2060
        def test_ok(self, payment_status, freezer, cmd_options):
2061
            # transactions are polled after 5 minutes.
2062
            freezer.move_to(timedelta(minutes=4))
2063
            call_command('lingo-poll-backend', **cmd_options)
2064
            assert payment_status.call_count == 0
2065
            freezer.move_to(timedelta(minutes=1, seconds=1))
2066
            call_command('lingo-poll-backend', **cmd_options)
2067

  
2068
            transaction = Transaction.objects.get()
2069
            payment_status.assert_called_once_with('1234', transaction_date=transaction.start_date)
2070

  
2071
            transaction.refresh_from_db()
2072
            assert transaction.status == eopayment.PAID
2073
            assert transaction.bank_transaction_date is not None
2074
            assert transaction.bank_data == {'abcd': 'xyz'}
2075

  
2076
        def test_max_age(self, payment_status, freezer):
2077
            # transaction older than 1 day are ignored
2078
            freezer.move_to(timedelta(days=1, minutes=1))
2079
            call_command('lingo-poll-backend', all_backends=True, max_age_in_days=1)
2080
            assert payment_status.call_count == 0
2081

  
2082
            # default is 3 days
2083
            freezer.move_to(timedelta(days=2))
2084
            call_command('lingo-poll-backend', all_backends=True)
2085
            assert payment_status.call_count == 0
2086

  
2087
        def test_payment_exception(self, payment_status, freezer):
2088
            payment_status.side_effect = eopayment.PaymentException('boom!!')
2089
            # transactions are polled after 5 minutes.
2090
            freezer.move_to(timedelta(minutes=5, seconds=1))
2091
            call_command('lingo-poll-backend', interactive=False, all_backends=True)
2092
            assert payment_status.call_count == 1
2093
            transaction = Transaction.objects.get()
2094
            assert transaction.status != eopayment.PAID
2095

  
2096
            with pytest.raises(PaymentException):  # from combo.apps.lingo.models
2097
                call_command('lingo-poll-backend', all_backends=True)
2098

  
2099
        def test_cli_ok(self):
2100
            with mock.patch('combo.apps.lingo.models.PaymentBackend.poll_backend') as mock_poll_backend:
2101
                call_command('lingo-poll-backend', backend='test1')
2102
                assert mock_poll_backend.call_count == 1
2103

  
2104
            with mock.patch('combo.apps.lingo.models.PaymentBackend.poll_backend') as mock_poll_backend:
2105
                with mock.patch(
2106
                    'combo.apps.lingo.management.commands.lingo-poll-backend.input'
2107
                ) as mock_input:
2108
                    mock_input.return_value = 'test1'
2109
                    call_command('lingo-poll-backend')
2110
                assert mock_poll_backend.call_count == 1
2111

  
2112
        def test_cli_errors(self):
2113
            call_command('lingo-poll-backend', backend='test1')
2114

  
2115
            with mock.patch('combo.apps.lingo.management.commands.lingo-poll-backend.input') as mock_input:
2116
                mock_input.return_value = 'test1'
2117
                call_command('lingo-poll-backend')
2118

  
2119
            with pytest.raises(CommandError):
2120
                call_command('lingo-poll-backend', all_backends=True, backend='test1')
2121

  
2122
            with pytest.raises(CommandError):
2123
                call_command('lingo-poll-backend', backend='coin')
2124

  
2125
            with transaction.atomic():
2126
                PaymentBackend.objects.all().delete()
2127
                with pytest.raises(CommandError):
2128
                    call_command('lingo-poll-backend', all_backends=True)
2129

  
2130
    class TestRecentTransactionsCell:
2131
        @pytest.fixture(autouse=True)
2132
        def setup(self, app, user, basket_page, mono_regie):
2133
            BasketItem.objects.create(
2134
                user=user,
2135
                regie=mono_regie,
2136
                amount=42,
2137
                subject='foo item',
2138
                request_data={'refdet': 'F20201030', 'exer': '2020'},
2139
            )
2140
            cell = LingoRecentTransactionsCell(page=basket_page, placeholder='content', order=1)
2141
            cell.save()
2142

  
2143
        @pytest.fixture
2144
        def app(self, app, user):
2145
            login(app)
2146
            return app
2147

  
2148
        @mock.patch('eopayment.payfip_ws.Payment.payment_status')
2149
        def test_refresh_status_through_polling(
2150
            self,
2151
            payment_status,
2152
            app,
2153
        ):
2154
            # Try to pay
2155
            pay_resp = app.get('/test_basket_cell/')
2156
            assert 'foo item' in pay_resp
2157
            assert 'Running' not in pay_resp
2158
            resp = pay_resp.click('Pay')
2159
            # we are redirect to payfip
2160
            assert resp.location == 'https://payfip/'
2161

  
2162
            transaction = Transaction.objects.get()
2163

  
2164
            # Simulate still running status on polling
2165
            payment_status.return_value = eopayment.common.PaymentResponse(
2166
                signed=True,
2167
                result=eopayment.WAITING,
2168
                order_id=transaction.order_id,
2169
            )
2170

  
2171
            # Try to pay again
2172
            resp = app.get('/test_basket_cell/')
2173
            assert 'foo item' not in resp
2174
            assert 'Pay' not in resp
2175
            assert 'Running' in resp
2176
            resp = pay_resp.click('Pay').follow()
2177
            assert 'Some items are already paid or' in resp
2178
            assert 'foo item' not in resp
2179
            assert 'Running' in resp
2180

  
2181
            # Simulate paid status on polling
2182
            payment_status.return_value = eopayment.common.PaymentResponse(
2183
                signed=True,
2184
                result=eopayment.PAID,
2185
                order_id=transaction.order_id,
2186
            )
2187

  
2188
            # Try to pay again
2189
            resp = app.get('/test_basket_cell/')
2190
            assert 'foo item: 42.00' in resp
2191
            assert 'Pay' not in resp
2192
            assert 'Running' not in resp
2193

  
2194
        @mock.patch('eopayment.payfip_ws.Payment.payment_status')
2195
        def test_exception_during_polling(
2196
            self,
2197
            payment_status,
2198
            app,
2199
            caplog,
2200
        ):
2201
            # Try to pay
2202
            pay_resp = app.get('/test_basket_cell/')
2203
            assert 'foo item' in pay_resp
2204
            assert 'Running' not in pay_resp
2205
            resp = pay_resp.click('Pay')
2206
            # we are redirect to payfip
2207
            assert resp.location == 'https://payfip/'
2208

  
2209
            # Simulate polling failure
2210
            payment_status.side_effect = eopayment.PaymentException('boom!')
2211

  
2212
            # Try to pay again
2213
            resp = app.get('/test_basket_cell/')
2214
            assert 'foo item' in pay_resp
2215
            assert 'Running' not in pay_resp
2216
            last_record = caplog.records[-1]
2217
            assert last_record.levelname == 'WARNING'
2218
            assert 'polling backend for transaction' in last_record.message
tests/test_lingo_remote_regie.py
5 5
from decimal import Decimal
6 6

  
7 7
import eopayment
8
import httmock
8 9
import mock
9 10
import pytest
10 11
from django.apps import apps
......
32 33
from combo.data.models import Page
33 34
from combo.utils import aes_hex_encrypt, check_query
34 35

  
36
from .test_manager import login
37

  
35 38
pytestmark = pytest.mark.django_db
36 39

  
37 40

  
......
742 745
    assert 'http://localhost' in html_message
743 746
    assert mailoutbox[0].attachments[0][0] == '01.pdf'
744 747
    assert mailoutbox[0].attachments[0][2] == 'application/pdf'
748

  
749

  
750
@pytest.fixture
751
def remote_invoices_httmock():
752
    invoices = []
753
    invoice = {}
754

  
755
    netloc = 'remote.regie.example.com'
756

  
757
    @httmock.urlmatch(netloc=netloc, path='^/invoice/')
758
    def invoice_mock(url, request):
759
        return json.dumps({'err': 0, 'data': invoice})
760

  
761
    @httmock.urlmatch(netloc=netloc, path='^/invoices/')
762
    def invoices_mock(url, request):
763
        return json.dumps({'err': 0, 'data': invoices})
764

  
765
    context_manager = httmock.HTTMock(invoices_mock, invoice_mock)
766
    context_manager.url = 'https://%s/' % netloc
767
    context_manager.invoices = invoices
768
    context_manager.invoice = invoice
769
    with context_manager:
770
        yield context_manager
771

  
772

  
773
class TestPolling:
774
    @mock.patch('eopayment.payfip_ws.Payment.payment_status')
775
    @mock.patch('eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/'))
776
    def test_in_active_items_cell(
777
        self,
778
        payment_request,
779
        payment_status,
780
        app,
781
        remote_regie,
782
        settings,
783
        remote_invoices_httmock,
784
        synchronous_cells,
785
    ):
786

  
787
        remote_invoices_httmock.invoices.extend(INVOICES)
788
        remote_invoices_httmock.invoice.update(INVOICES[0])
789
        remote_regie.webservice_url = remote_invoices_httmock.url
790
        remote_regie.save()
791
        # use payfip
792
        remote_regie.payment_backend.service = 'payfip_ws'
793
        remote_regie.payment_backend.save()
794

  
795
        User.objects.create_user('admin', password='admin', email='foo@example.com')
796
        page = Page.objects.create(title='xxx', slug='test_basket_cell', template_name='standard')
797
        ActiveItems.objects.create(regie='remote', page=page, placeholder='content', order=0)
798

  
799
        login(app)
800

  
801
        assert Transaction.objects.count() == 0
802

  
803
        resp = app.get('/test_basket_cell/')
804
        assert 'F-2016-One' in resp
805

  
806
        resp = resp.click('pay', index=0)
807
        pay_resp = resp
808
        resp = resp.form.submit('Pay')
809

  
810
        transaction = Transaction.objects.get()
811
        assert transaction.status == 0
812

  
813
        payment_status.return_value = eopayment.common.PaymentResponse(
814
            signed=True,
815
            result=eopayment.WAITING,
816
            order_id=transaction.order_id,
817
        )
818

  
819
        assert payment_status.call_count == 0
820
        resp = app.get('/test_basket_cell/')
821
        assert 'F-2016-One' in resp
822
        assert payment_status.call_count == 1
823
        transaction.refresh_from_db()
824
        assert transaction.status == eopayment.WAITING
825

  
826
        resp = resp.click('pay', index=0)
827
        assert 'Waiting for payment' in resp
828
        assert 'button' not in resp
829

  
830
        resp = pay_resp.form.submit('Pay').follow()
831
        assert 'Some items are already paid' in resp
832

  
833
        payment_status.return_value = eopayment.common.PaymentResponse(
834
            signed=True,
835
            result=eopayment.PAID,
836
            order_id=transaction.order_id,
837
        )
838

  
839
        resp = app.get('/test_basket_cell/')
840
        assert 'F-2016-One' not in resp
841
        transaction.refresh_from_db()
842
        assert transaction.status == eopayment.PAID
745
-