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.
This commit is contained in:
Benjamin Dauvergne 2020-12-05 09:24:33 +01:00
parent 5b5d046414
commit 760ccb41fd
10 changed files with 619 additions and 114 deletions

View File

@ -100,7 +100,7 @@ class RegieForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(RegieForm, self).__init__(*args, **kwargs)
fields, initial = create_form_fields(
self.instance.payment_backend.get_payment().get_parameters(scope='transaction'),
self.instance.eopayment.get_parameters(scope='transaction'),
self.instance.transaction_options,
)
self.fields.update(fields)
@ -109,7 +109,7 @@ class RegieForm(forms.ModelForm):
def save(self):
instance = super(RegieForm, self).save()
instance.transaction_options = compute_json_field(
self.instance.payment_backend.get_payment().get_parameters(scope='transaction'), self.cleaned_data
self.instance.eopayment.get_parameters(scope='transaction'), self.cleaned_data
)
instance.save()
return instance
@ -123,7 +123,7 @@ class PaymentBackendForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(PaymentBackendForm, self).__init__(*args, **kwargs)
fields, initial = create_form_fields(
self.instance.get_payment().get_parameters(scope='global'), self.instance.service_options
self.instance.eopayment.get_parameters(scope='global'), self.instance.service_options
)
self.fields.update(fields)
self.initial.update(initial)
@ -133,7 +133,7 @@ class PaymentBackendForm(forms.ModelForm):
def save(self):
instance = super(PaymentBackendForm, self).save()
instance.service_options = compute_json_field(
self.instance.get_payment().get_parameters(scope='global'), self.cleaned_data
self.instance.eopayment.get_parameters(scope='global'), self.cleaned_data
)
instance.save()
return instance

View File

@ -0,0 +1,96 @@
# lingo - basket and payment system
# Copyright (C) 2021 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 <http://www.gnu.org/licenses/>.
import datetime
import logging
from django.core.management.base import BaseCommand, CommandError
from combo.apps.lingo.models import PaymentBackend, PaymentException
logger = logging.getLogger('combo.apps.lingo')
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument('--backend', default=None, help='slug of the backend')
parser.add_argument('--all-backends', default=False, action='store_true', help='target all backends')
parser.add_argument(
'--noinput',
'--no-input',
action='store_false',
dest='interactive',
help='Tells Django to NOT prompt the user for input of any kind.',
)
parser.add_argument(
'--max-age-in-days', default=3, type=int, help='max age of the transaction in days'
)
def handle(
self,
*args,
backend=None,
all_backends=False,
max_age_in_days=None,
interactive=True,
verbosity=1,
**options,
):
qs = PaymentBackend.objects.all()
if backend and all_backends:
raise CommandError('--backend and --all-baskends cannot be used together')
elif backend:
try:
backend = qs.get(slug=backend)
except PaymentBackend.DoesNotExist:
raise CommandError('no backend with slug "%s".' % backend)
else:
if not backend.can_poll_backend():
raise CommandError('backend "%s" cannot be polled.' % backend)
backends = [backend]
else:
backends = [backend for backend in qs if backend.can_poll_backend()]
if not backends:
raise CommandError('no backend found.')
if not all_backends and interactive:
print('Choose backend by slug:')
while True:
for backend in backends:
print(' - %s: %s' % (backend.slug, backend))
print('> ', end=' ')
slug = input().strip()
if not slug:
continue
filtered_backends = qs.filter(slug__icontains=slug)
if filtered_backends:
backends = filtered_backends
break
for backend in backends:
if verbosity >= 1:
print('Polling backend', backend, '... ', end='')
try:
backend.poll_backend(max_age=max_age_in_days and datetime.timedelta(days=max_age_in_days))
except PaymentException:
logger.exception('polling failed')
if interactive:
# show error
raise
if verbosity >= 1:
print('DONE')

View File

@ -27,11 +27,11 @@ import eopayment
from dateutil import parser
from django import template
from django.conf import settings
from django.contrib.auth.models import User
from django.core import serializers
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.mail import EmailMultiAlternatives
from django.db import models
from django.db.transaction import atomic
from django.forms import Select
from django.forms import models as model_forms
from django.template.loader import render_to_string
@ -39,9 +39,8 @@ from django.urls import reverse
from django.utils import dateparse, six, timezone
from django.utils.encoding import force_bytes, python_2_unicode_compatible
from django.utils.formats import localize
from django.utils.http import urlencode
from django.utils.six.moves.urllib import parse as urlparse
from django.utils.timezone import make_aware, utc
from django.utils.timezone import make_aware, now, utc
from django.utils.translation import ugettext_lazy as _
from jsonfield import JSONField
from requests import RequestException
@ -156,7 +155,11 @@ class PaymentBackend(models.Model):
def __str__(self):
return self.label
def get_payment(self):
@property
def eopayment(self):
return self.make_eopayment()
def make_eopayment(self, *, request=None, automatic_return_url=None, normal_return_url=None, **kwargs):
options = self.service_options or {}
if isinstance(options, six.string_types):
# backward compatibility when used againt postgresql < 9.4 and
@ -167,6 +170,23 @@ class PaymentBackend(models.Model):
pass
if not isinstance(options, dict):
options = {}
if request:
if not automatic_return_url:
automatic_return_url = reverse(
'lingo-callback-payment-backend', kwargs={'payment_backend_pk': self.id}
)
if automatic_return_url:
automatic_return_url = request.build_absolute_uri(automatic_return_url)
if normal_return_url:
normal_return_url = request.build_absolute_uri(normal_return_url)
options['automatic_return_url'] = automatic_return_url
options['normal_return_url'] = normal_return_url
else:
assert (
not automatic_return_url and not normal_return_url
), 'make_eopayment must be used with a request to set automatic_return_url or normal_return_url'
options.update(**kwargs)
return eopayment.Payment(self.service, options)
def natural_key(self):
@ -231,6 +251,35 @@ class PaymentBackend(models.Model):
transaction.handle_backend_response(response, callback=callback)
return transaction
def can_poll_backend(self):
return self.eopayment.has_payment_status
def poll_backend(self, min_age=None, max_age=None):
if not self.can_poll_backend():
return
current_time = now()
# poll transactions linked to the current backend
# aged between 5 minutes and 3 hours, max_age can be overriden
min_age = min_age or datetime.timedelta(minutes=5)
not_after = current_time - min_age
max_age = max_age or datetime.timedelta(hours=3)
not_before = current_time - max_age
transactions = Transaction.objects.filter(
regie__payment_backend=self,
start_date__lt=not_after,
start_date__gt=not_before,
status__in=Transaction.RUNNING_STATUSES,
).order_by('pk')
last_pk = -1
while True:
# lock each transaction before trying to poll it
with atomic():
transaction = transactions.filter(pk__gt=last_pk).select_for_update(skip_locked=True).first()
if not transaction:
break
last_pk = transaction.pk
transaction.poll_backend(ignore_errors=False)
@python_2_unicode_compatible
class Regie(models.Model):
@ -547,6 +596,16 @@ class Regie(models.Model):
regie = next(serializers.deserialize('json', json.dumps([json_regie]), ignorenonexistent=True))
regie.save()
def can_poll_backend(self):
return self.payment_backend.can_poll_backend()
@property
def eopayment(self):
return self.make_eopayment()
def make_eopayment(self, **kwargs):
return self.payment_backend.make_eopayment(**kwargs)
class BasketItem(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True)
@ -572,9 +631,13 @@ class BasketItem(models.Model):
@classmethod
def get_items_to_be_paid(cls, user):
return cls.objects.filter(
qs = cls.objects.filter(
user=user, payment_date__isnull=True, waiting_date__isnull=True, cancellation_date__isnull=True
)
for transaction in Transaction.objects.filter(items__in=qs):
if transaction.can_poll_backend():
transaction.poll_backend()
return qs
def notify(self, status):
if not self.source_url:
@ -656,6 +719,7 @@ class RemoteItem(object):
self.reference_id = reference_id
if payment_date:
self.payment_date = parser.parse(payment_date)
self.waiting_date = None
@property
def no_online_payment_reason_details(self):
@ -673,28 +737,44 @@ class RemoteItem(object):
return aes_hex_encrypt(settings.SECRET_KEY, force_bytes(str(self.id)))
@classmethod
def update_paid(cls, regie, remote_items):
remote_item_ids = [remote_item.id for remote_item in remote_items if not remote_item.paid]
def transactions_for_remote_items(cls, queryset, remote_items):
remote_item_ids = set(remote_item.id for remote_item in remote_items if not remote_item.paid)
if not remote_item_ids:
return
return Transaction.objects.none()
paid_items = {}
# filter transactions by regie, status and contained remote_item id
transaction_qs = Transaction.objects.filter(
regie=regie, status__in=[eopayment.PAID, eopayment.ACCEPTED]
)
query = reduce(
models.Q.__or__,
(models.Q(remote_items__contains=remote_item_id) for remote_item_id in remote_item_ids),
)
# accumulate in paid_items each remote_item earliest payment_date
for transaction in transaction_qs.filter(query):
for transaction in queryset.filter(query):
for remote_item_id in transaction.remote_items.split(','):
if remote_item_id in remote_item_ids:
yield transaction
break
@classmethod
def update_paid(cls, regie, remote_items):
paid_items = {}
waiting_items = {}
transaction_qs = Transaction.objects.filter(regie=regie)
can_poll_backend = regie.can_poll_backend()
# accumulate in paid_items each remote_item earliest payment_date
for transaction in cls.transactions_for_remote_items(transaction_qs, remote_items):
if transaction.is_running() and can_poll_backend:
transaction.poll_backend()
for remote_item in transaction.remote_items.split(','):
if remote_item not in paid_items:
paid_items[remote_item] = transaction.end_date
else:
paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item])
if transaction.end_date and transaction.is_paid():
if remote_item not in paid_items:
paid_items[remote_item] = transaction.end_date
else:
paid_items[remote_item] = min(transaction.end_date, paid_items[remote_item])
elif transaction.status == eopayment.WAITING and can_poll_backend:
waiting_items[remote_item] = transaction.start_date
# update remote_item.paid using paid_items
for remote_item in remote_items:
@ -703,6 +783,8 @@ class RemoteItem(object):
if remote_item.id in paid_items:
remote_item.paid = True
remote_item.payment_date = paid_items[remote_item.id]
elif remote_item.id in waiting_items:
remote_item.waiting_date = waiting_items[remote_item.id]
def status_label(status):
@ -731,6 +813,9 @@ class Transaction(models.Model):
status = models.IntegerField(null=True)
amount = models.DecimalField(default=0, max_digits=7, decimal_places=2)
RUNNING_STATUSES = [0, eopayment.WAITING, eopayment.RECEIVED]
PAID_STATUSES = [eopayment.PAID, eopayment.ACCEPTED]
def is_remote(self):
return self.remote_items != ''
@ -740,10 +825,10 @@ class Transaction(models.Model):
return _('Anonymous User')
def is_paid(self):
return self.status in (eopayment.PAID, eopayment.ACCEPTED)
return self.status in self.PAID_STATUSES
def is_running(self):
return self.status in [0, eopayment.WAITING, eopayment.RECEIVED]
return self.status in self.RUNNING_STATUSES
def get_status_label(self):
return status_label(self.status)
@ -890,6 +975,49 @@ class Transaction(models.Model):
if self.remote_items:
self.first_notify_remote_items_of_payments()
@property
def eopayment(self):
return self.regie.eopayment
def make_eopayment(self, **kwargs):
normal_return_url = reverse(
'lingo-return-payment-backend',
kwargs={
'payment_backend_pk': self.regie.payment_backend.id,
'transaction_signature': signing_dumps(self.pk),
},
)
return self.regie.make_eopayment(normal_return_url=normal_return_url, **kwargs)
def can_poll_backend(self):
return self.regie and self.regie.can_poll_backend()
def poll_backend(self, ignore_errors=True):
with atomic():
# lock the transaction
Transaction.objects.filter(pk=self.pk).select_for_update().first()
try:
response = self.eopayment.payment_status(self.order_id, transaction_date=self.start_date)
except eopayment.PaymentException:
if ignore_errors:
logger.warning(
'lingo: regie "%s" polling backend for transaction "%%s(%%s)" failed' % self.regie,
self.order_id,
self.id,
exc_info=True,
)
return
raise PaymentException('polling failed')
logger.debug(
'lingo: regie "%s" polling backend for transaction "%%s(%%s)"' % self.regie,
self.order_id,
self.id,
)
if self.status != response.result:
self.handle_backend_response(response)
class TransactionOperation(models.Model):
OPERATIONS = [
@ -968,9 +1096,13 @@ class LingoRecentTransactionsCell(CellBase):
# list transactions :
# * paid by the user
# * or linked to a BasketItem of the user
return Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter(
qs = Transaction.objects.filter(models.Q(user=user) | models.Q(items__user=user)).filter(
start_date__gte=timezone.now() - datetime.timedelta(days=7)
)
for transaction in qs:
if transaction.can_poll_backend() and transaction.is_running():
transaction.poll_backend()
return qs
def is_relevant(self, context):
if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated):

View File

@ -48,7 +48,10 @@
{% if item.no_online_payment_reason_details %}
<div class="no-online-payment-reason"><span>{{ item.no_online_payment_reason_details }}</span></div>
{% endif %}
{% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount %}
{% if item.waiting_date and not item.paid %}
<div class="paid paid-info">{% trans "Waiting for payment." %}</div>
{% endif %}
{% if not item.paid and item.online_payment and item.amount >= regie.payment_min_amount and not item.waiting_date %}
{% csrf_token %}
{% if not user.is_authenticated %}
<div class="email">

View File

@ -51,7 +51,7 @@
{% if item.regie.is_remote %}
<td>
<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" %}
{% if item.online_payment and item.amount >= item.regie.payment_min_amount %}{% trans "and pay" %}{% endif %}
{% if item.online_payment and item.amount >= item.regie.payment_min_amount and not item.waiting_date %}{% trans "and pay" %}{% endif %}
</a>
{% if item.has_pdf %}
<br/><a href="{% url 'download-item-pdf' regie_id=item.regie.pk item_crypto_id=item.crypto_id %}" class="icon-pdf"

View File

@ -58,6 +58,7 @@ from .models import (
PaymentException,
Regie,
RemoteInvoiceException,
RemoteItem,
SelfDeclaredInvoicePayment,
Transaction,
TransactionOperation,
@ -79,32 +80,6 @@ class BadRequestJsonResponse(ErrorJsonResponse):
status_code = 400
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
options.update(
{
'automatic_return_url': request.build_absolute_uri(
reverse('lingo-callback-payment-backend', kwargs={'payment_backend_pk': payment_backend.id})
),
}
)
if transaction_id:
options['normal_return_url'] = request.build_absolute_uri(
reverse(
'lingo-return-payment-backend',
kwargs={
'payment_backend_pk': payment_backend.id,
'transaction_signature': signing_dumps(transaction_id),
},
)
)
return eopayment.Payment(payment_backend.service, options)
def get_basket_url():
basket_cell = LingoBasketCell.objects.filter(page__snapshot__isnull=True).first()
if basket_cell:
@ -323,12 +298,13 @@ class ValidateTransactionApiView(View):
)
raise Http404
payment = get_eopayment_object(request, transaction.regie)
amount = LocaleDecimal(request.GET['amount'])
logger.info(u'validating amount %s for transaction %s', amount, smart_text(transaction.id))
try:
result = payment.backend.validate(amount, transaction.bank_data)
result = transaction.make_eopayment(request=request).backend.validate(
amount, transaction.bank_data
)
except eopayment.ResponseError as e:
logger.error(u'failed in validation operation: %s', e)
return JsonResponse({'err': 1, 'e': force_text(e)})
@ -361,12 +337,11 @@ class CancelTransactionApiView(View):
)
raise Http404
payment = get_eopayment_object(request, transaction.regie)
amount = LocaleDecimal(request.GET['amount'])
logger.info(u'cancelling amount %s for transaction %s', amount, smart_text(transaction.id))
try:
result = payment.backend.cancel(amount, transaction.bank_data)
result = transaction.make_eopayment(request=request).backend.cancel(amount, transaction.bank_data)
except eopayment.ResponseError as e:
logger.error(u'failed in cancel operation: %s', e)
return JsonResponse({'err': 1, 'e': force_text(e)})
@ -389,6 +364,14 @@ class PayMixin(object):
if bool(len(items)) == bool(len(remote_items)):
messages.error(request, _('Items to pay are missing or are not of the same type (local/remote).'))
return HttpResponseRedirect(next_url)
if (
regie.payment_backend.can_poll_backend()
and self.poll_for_newly_paid_or_still_running_transactions(regie, items, remote_items)
):
messages.error(request, _('Some items are already paid or are being paid.'))
return HttpResponseRedirect(next_url)
if regie.can_pay_only_one_basket_item and (len(items) > 1 or len(remote_items) > 1):
messages.error(request, _('This regie allows to pay only one item.'))
return HttpResponseRedirect(next_url)
@ -425,7 +408,6 @@ class PayMixin(object):
transaction.status = 0
transaction.amount = total_amount
payment = get_eopayment_object(request, regie, transaction.pk)
kwargs = {'email': email, 'first_name': firstname, 'last_name': lastname}
kwargs['merchant_name'] = settings.TEMPLATE_VARS.get('global_title') or 'Compte Citoyen'
kwargs['items_info'] = []
@ -463,7 +445,9 @@ class PayMixin(object):
if regie.transaction_options:
kwargs.update(regie.transaction_options)
try:
(order_id, kind, data) = payment.request(total_amount, **kwargs)
(order_id, kind, data) = transaction.make_eopayment(request=request).request(
total_amount, **kwargs
)
except eopayment.PaymentException as e:
logger.error('failed to initiate payment request: %s', e)
messages.error(request, _('Failed to initiate payment request'))
@ -491,6 +475,20 @@ class PayMixin(object):
raise NotImplementedError()
def poll_for_newly_paid_or_still_running_transactions(self, regie, items, remote_items):
'''Verify if any open transaction is not already paid.'''
qs = Transaction.objects.filter(regie=regie, status__in=Transaction.RUNNING_STATUSES)
if items:
transactions = qs.filter(items__in=items)
else:
transactions = RemoteItem.transactions_for_remote_items(qs, remote_items)
newly_paid_or_still_running = False
for transaction in transactions:
transaction.poll_backend()
newly_paid_or_still_running |= transaction.is_paid() or transaction.is_running()
return newly_paid_or_still_running
class PayView(PayMixin, View):
def post(self, request, *args, **kwargs):
@ -619,7 +617,7 @@ class PaymentView(View):
else:
return HttpResponseBadRequest("A payment backend or regie primary key must be specified")
payment = get_eopayment_object(request, payment_backend)
payment = payment_backend.make_eopayment(request=request)
logger.info(u'received payment response: %r', backend_response)
try:
eopayment_response_kwargs = {'redirect': not callback}

1
debian/combo.cron.d vendored
View File

@ -1,3 +1,4 @@
MAILTO=root
0 8 * * * combo /usr/bin/combo-manage tenant_command notify_new_remote_invoices --all-tenants -v0
*/10 * * * * combo /usr/bin/combo-manage tenant_command lingo-poll-backends --all-tenants -v0 --no-input

View File

@ -675,6 +675,6 @@ def test_edit_payment_backend_validation(app, admin_user):
def test_use_old_service_options_safely(app, admin_user):
PaymentBackend(service='dummy', service_options='xx').get_payment()
PaymentBackend(service='dummy', service_options='"xx"').get_payment()
PaymentBackend(service='dummy', service_options=None).get_payment()
PaymentBackend(service='dummy', service_options='xx').eopayment
PaymentBackend(service='dummy', service_options='"xx"').eopayment
PaymentBackend(service='dummy', service_options=None).eopayment

View File

@ -11,21 +11,24 @@ import pytest
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management import CommandError, call_command
from django.db import transaction
from django.http.request import QueryDict
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from django.utils.six.moves.urllib import parse as urlparse
from django.utils.timezone import utc
from django.utils.timezone import now, utc
from mellon.models import UserSAMLIdentifier
from requests.exceptions import ConnectionError
from requests.models import Response
from combo.apps.lingo.models import (
EXPIRED,
BasketItem,
LingoBasketCell,
LingoRecentTransactionsCell,
PaymentBackend,
PaymentException,
Regie,
Transaction,
TransactionOperation,
@ -57,46 +60,35 @@ def check_log(caplog, message):
@pytest.fixture
def regie():
try:
payment_backend = PaymentBackend.objects.get(slug='test1')
except PaymentBackend.DoesNotExist:
payment_backend = PaymentBackend.objects.create(
label='test1', slug='test1', service='dummy', service_options={'siret': '1234'}
)
try:
regie = Regie.objects.get(slug='test')
except Regie.DoesNotExist:
regie = Regie()
regie.label = 'Test'
regie.slug = 'test'
regie.description = 'test'
regie.can_pay_only_one_basket_item = False
regie.payment_min_amount = Decimal(4.5)
regie.payment_backend = payment_backend
regie.save()
def payment_backend():
return PaymentBackend.objects.create(
label='test1', slug='test1', service='dummy', service_options={'siret': '1234'}
)
@pytest.fixture
def regie(payment_backend):
regie = Regie()
regie.label = 'Test'
regie.slug = 'test'
regie.description = 'test'
regie.can_pay_only_one_basket_item = False
regie.payment_min_amount = Decimal(4.5)
regie.payment_backend = payment_backend
regie.save()
return regie
@pytest.fixture
def remote_regie():
try:
payment_backend = PaymentBackend.objects.get(slug='test1')
except PaymentBackend.DoesNotExist:
payment_backend = PaymentBackend.objects.create(
label='test1', slug='test1', service='dummy', service_options={'siret': '1234'}
)
try:
regie = Regie.objects.get(slug='remote')
except Regie.DoesNotExist:
regie = Regie(can_pay_only_one_basket_item=False)
regie.label = 'Remote'
regie.slug = 'remote'
regie.description = 'remote'
regie.payment_min_amount = Decimal(2.0)
regie.payment_backend = payment_backend
regie.webservice_url = 'http://example.org/regie' # is_remote
regie.save()
def remote_regie(payment_backend):
regie = Regie()
regie.label = 'Remote'
regie.slug = 'remote'
regie.description = 'remote'
regie.payment_min_amount = Decimal(2.0)
regie.payment_backend = payment_backend
regie.webservice_url = 'http://example.org/regie' # is_remote
regie.save()
return regie
@ -217,19 +209,11 @@ def test_transaction_manual_validation(app, basket_page, user, monkeypatch):
)
BasketItem.objects.create(user=user, regie=regie, subject='item1', amount='1.5', source_url='/item/1')
class MockPayment(object):
request = mock.Mock(return_value=(9876, 3, {}))
def get_eopayment_object(*args, **kwargs):
return MockPayment
import combo.apps.lingo.views
monkeypatch.setattr(combo.apps.lingo.views, 'get_eopayment_object', get_eopayment_object)
resp = login(app).get('/test_basket_cell/')
resp = resp.form.submit()
assert MockPayment.request.call_args[1]['manual_validation'] is True
with mock.patch('eopayment.Payment') as MockPayment:
MockPayment.return_value.request.return_value = (9876, 3, {})
resp = login(app).get('/test_basket_cell/')
resp = resp.form.submit()
assert MockPayment.return_value.request.call_args[1]['manual_validation'] is True
@pytest.mark.parametrize('with_payment_backend', [False, True])
@ -2039,3 +2023,196 @@ def test_email_from_basket(app, regie, remote_invoices_httmock):
assert response.location.startswith('http://dummy-payment.demo.entrouvert.com/')
qs = parse_qs(response.location)
assert qs['email'] == 'user1@example.com'
class TestPolling:
@pytest.fixture
def payment_backend(self, payment_backend):
with mock.patch(
'eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/')
):
payment_backend.service = 'payfip_ws'
payment_backend.save()
yield payment_backend
class TestPollBackendCommand:
@pytest.fixture(autouse=True)
def setup(self, regie, payment_backend):
item = BasketItem.objects.create(amount=10, regie=regie)
transaction = Transaction.objects.create(order_id='1234', status=0, amount=10, regie=regie)
transaction.items.set([item])
@pytest.fixture
def payment_status(self):
with mock.patch('eopayment.Payment.payment_status') as payment_status:
payment_status.return_value = eopayment.common.PaymentResponse(
order_id='1234',
result=eopayment.PAID,
transaction_date=now(),
transaction_id='4567',
bank_data={'abcd': 'xyz'},
signed=True,
)
yield payment_status
@pytest.mark.parametrize('cmd_options', [{'all_backends': True}, {'backend': 'test1'}])
def test_ok(self, payment_status, freezer, cmd_options):
# transactions are polled after 5 minutes.
freezer.move_to(timedelta(minutes=4))
call_command('lingo-poll-backend', **cmd_options)
assert payment_status.call_count == 0
freezer.move_to(timedelta(minutes=1, seconds=1))
call_command('lingo-poll-backend', **cmd_options)
transaction = Transaction.objects.get()
payment_status.assert_called_once_with('1234', transaction_date=transaction.start_date)
transaction.refresh_from_db()
assert transaction.status == eopayment.PAID
assert transaction.bank_transaction_date is not None
assert transaction.bank_data == {'abcd': 'xyz'}
def test_max_age(self, payment_status, freezer):
# transaction older than 1 day are ignored
freezer.move_to(timedelta(days=1, minutes=1))
call_command('lingo-poll-backend', all_backends=True, max_age_in_days=1)
assert payment_status.call_count == 0
# default is 3 days
freezer.move_to(timedelta(days=2))
call_command('lingo-poll-backend', all_backends=True)
assert payment_status.call_count == 0
def test_payment_exception(self, payment_status, freezer):
payment_status.side_effect = eopayment.PaymentException('boom!!')
# transactions are polled after 5 minutes.
freezer.move_to(timedelta(minutes=5, seconds=1))
call_command('lingo-poll-backend', interactive=False, all_backends=True)
assert payment_status.call_count == 1
transaction = Transaction.objects.get()
assert transaction.status != eopayment.PAID
with pytest.raises(PaymentException): # from combo.apps.lingo.models
call_command('lingo-poll-backend', all_backends=True)
def test_cli_ok(self):
with mock.patch('combo.apps.lingo.models.PaymentBackend.poll_backend') as mock_poll_backend:
call_command('lingo-poll-backend', backend='test1')
assert mock_poll_backend.call_count == 1
with mock.patch('combo.apps.lingo.models.PaymentBackend.poll_backend') as mock_poll_backend:
with mock.patch(
'combo.apps.lingo.management.commands.lingo-poll-backend.input'
) as mock_input:
mock_input.return_value = 'test1'
call_command('lingo-poll-backend')
assert mock_poll_backend.call_count == 1
def test_cli_errors(self):
call_command('lingo-poll-backend', backend='test1')
with mock.patch('combo.apps.lingo.management.commands.lingo-poll-backend.input') as mock_input:
mock_input.return_value = 'test1'
call_command('lingo-poll-backend')
with pytest.raises(CommandError):
call_command('lingo-poll-backend', all_backends=True, backend='test1')
with pytest.raises(CommandError):
call_command('lingo-poll-backend', backend='coin')
with transaction.atomic():
PaymentBackend.objects.all().delete()
with pytest.raises(CommandError):
call_command('lingo-poll-backend', all_backends=True)
class TestRecentTransactionsCell:
@pytest.fixture(autouse=True)
def setup(self, app, user, basket_page, mono_regie):
BasketItem.objects.create(
user=user,
regie=mono_regie,
amount=42,
subject='foo item',
request_data={'refdet': 'F20201030', 'exer': '2020'},
)
cell = LingoRecentTransactionsCell(page=basket_page, placeholder='content', order=1)
cell.save()
@pytest.fixture
def app(self, app, user):
login(app)
return app
@mock.patch('eopayment.payfip_ws.Payment.payment_status')
def test_refresh_status_through_polling(
self,
payment_status,
app,
):
# Try to pay
pay_resp = app.get('/test_basket_cell/')
assert 'foo item' in pay_resp
assert 'Running' not in pay_resp
resp = pay_resp.click('Pay')
# we are redirect to payfip
assert resp.location == 'https://payfip/'
transaction = Transaction.objects.get()
# Simulate still running status on polling
payment_status.return_value = eopayment.common.PaymentResponse(
signed=True,
result=eopayment.WAITING,
order_id=transaction.order_id,
)
# Try to pay again
resp = app.get('/test_basket_cell/')
assert 'foo item' not in resp
assert 'Pay' not in resp
assert 'Running' in resp
resp = pay_resp.click('Pay').follow()
assert 'Some items are already paid or' in resp
assert 'foo item' not in resp
assert 'Running' in resp
# Simulate paid status on polling
payment_status.return_value = eopayment.common.PaymentResponse(
signed=True,
result=eopayment.PAID,
order_id=transaction.order_id,
)
# Try to pay again
resp = app.get('/test_basket_cell/')
assert 'foo item: 42.00' in resp
assert 'Pay' not in resp
assert 'Running' not in resp
@mock.patch('eopayment.payfip_ws.Payment.payment_status')
def test_exception_during_polling(
self,
payment_status,
app,
caplog,
):
# Try to pay
pay_resp = app.get('/test_basket_cell/')
assert 'foo item' in pay_resp
assert 'Running' not in pay_resp
resp = pay_resp.click('Pay')
# we are redirect to payfip
assert resp.location == 'https://payfip/'
# Simulate polling failure
payment_status.side_effect = eopayment.PaymentException('boom!')
# Try to pay again
resp = app.get('/test_basket_cell/')
assert 'foo item' in pay_resp
assert 'Running' not in pay_resp
last_record = caplog.records[-1]
assert last_record.levelname == 'WARNING'
assert 'polling backend for transaction' in last_record.message

View File

@ -5,6 +5,7 @@ import json
from decimal import Decimal
import eopayment
import httmock
import mock
import pytest
from django.apps import apps
@ -32,6 +33,8 @@ from combo.apps.lingo.models import (
from combo.data.models import Page
from combo.utils import aes_hex_encrypt, check_query
from .test_manager import login
pytestmark = pytest.mark.django_db
@ -742,3 +745,98 @@ def test_send_new_remote_invoices_by_email(mock_get, user_saml, admin, app, remo
assert 'http://localhost' in html_message
assert mailoutbox[0].attachments[0][0] == '01.pdf'
assert mailoutbox[0].attachments[0][2] == 'application/pdf'
@pytest.fixture
def remote_invoices_httmock():
invoices = []
invoice = {}
netloc = 'remote.regie.example.com'
@httmock.urlmatch(netloc=netloc, path='^/invoice/')
def invoice_mock(url, request):
return json.dumps({'err': 0, 'data': invoice})
@httmock.urlmatch(netloc=netloc, path='^/invoices/')
def invoices_mock(url, request):
return json.dumps({'err': 0, 'data': invoices})
context_manager = httmock.HTTMock(invoices_mock, invoice_mock)
context_manager.url = 'https://%s/' % netloc
context_manager.invoices = invoices
context_manager.invoice = invoice
with context_manager:
yield context_manager
class TestPolling:
@mock.patch('eopayment.payfip_ws.Payment.payment_status')
@mock.patch('eopayment.payfip_ws.Payment.request', return_value=(1, eopayment.URL, 'https://payfip/'))
def test_in_active_items_cell(
self,
payment_request,
payment_status,
app,
remote_regie,
settings,
remote_invoices_httmock,
synchronous_cells,
):
remote_invoices_httmock.invoices.extend(INVOICES)
remote_invoices_httmock.invoice.update(INVOICES[0])
remote_regie.webservice_url = remote_invoices_httmock.url
remote_regie.save()
# use payfip
remote_regie.payment_backend.service = 'payfip_ws'
remote_regie.payment_backend.save()
User.objects.create_user('admin', password='admin', email='foo@example.com')
page = Page.objects.create(title='xxx', slug='test_basket_cell', template_name='standard')
ActiveItems.objects.create(regie='remote', page=page, placeholder='content', order=0)
login(app)
assert Transaction.objects.count() == 0
resp = app.get('/test_basket_cell/')
assert 'F-2016-One' in resp
resp = resp.click('pay', index=0)
pay_resp = resp
resp = resp.form.submit('Pay')
transaction = Transaction.objects.get()
assert transaction.status == 0
payment_status.return_value = eopayment.common.PaymentResponse(
signed=True,
result=eopayment.WAITING,
order_id=transaction.order_id,
)
assert payment_status.call_count == 0
resp = app.get('/test_basket_cell/')
assert 'F-2016-One' in resp
assert payment_status.call_count == 1
transaction.refresh_from_db()
assert transaction.status == eopayment.WAITING
resp = resp.click('pay', index=0)
assert 'Waiting for payment' in resp
assert 'button' not in resp
resp = pay_resp.form.submit('Pay').follow()
assert 'Some items are already paid' in resp
payment_status.return_value = eopayment.common.PaymentResponse(
signed=True,
result=eopayment.PAID,
order_id=transaction.order_id,
)
resp = app.get('/test_basket_cell/')
assert 'F-2016-One' not in resp
transaction.refresh_from_db()
assert transaction.status == eopayment.PAID