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:
parent
5b5d046414
commit
760ccb41fd
|
@ -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
|
||||
|
|
|
@ -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')
|
|
@ -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):
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue