From b291c1cf1d253e02390d77e00a2f866aaddd8f5b Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Sat, 5 Dec 2020 09:24:33 +0100 Subject: [PATCH 2/5] 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/__init__.py | 8 +++ combo/apps/lingo/models.py | 51 +++++++++++++- tests/test_lingo_payment.py | 125 ++++++++++++++++++++++++----------- 3 files changed, 145 insertions(+), 39 deletions(-) diff --git a/combo/apps/lingo/__init__.py b/combo/apps/lingo/__init__.py index 400a3036..53835a98 100644 --- a/combo/apps/lingo/__init__.py +++ b/combo/apps/lingo/__init__.py @@ -39,6 +39,7 @@ class AppConfig(django.apps.AppConfig): def hourly(self): self.update_transactions() self.notify_payments() + self.poll_transaction_status() def update_transactions(self): from .models import EXPIRED, Transaction @@ -77,5 +78,12 @@ class AppConfig(django.apps.AppConfig): except: logger.exception('error in async notification for basket item %s', item.id) + def poll_transaction_status(self): + from .models import PaymentBackend + + for payment_backend in PaymentBackend.objects.all(): + if payment_backend.can_poll_backend(): + payment_backend.poll_backend() + default_app_config = 'combo.apps.lingo.AppConfig' diff --git a/combo/apps/lingo/models.py b/combo/apps/lingo/models.py index 93f20eb8..9413a944 100644 --- a/combo/apps/lingo/models.py +++ b/combo/apps/lingo/models.py @@ -41,7 +41,7 @@ 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 @@ -231,6 +231,32 @@ class PaymentBackend(models.Model): transaction.handle_backend_response(response, callback=callback) return transaction + @property + def backend(self): + return self.get_payment() + + def can_poll_backend(self): + return self.backend.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 20 minutes and 3 hours, max_age can be overriden + min_age = min_age or datetime.timedelta(minutes=20) + 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, + ) + for transaction in transactions: + transaction.poll_backend() + @python_2_unicode_compatible class Regie(models.Model): @@ -547,6 +573,9 @@ 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() + class BasketItem(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True) @@ -731,6 +760,8 @@ 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] + def is_remote(self): return self.remote_items != '' @@ -743,7 +774,7 @@ class Transaction(models.Model): return self.status in (eopayment.PAID, eopayment.ACCEPTED) 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 +921,22 @@ class Transaction(models.Model): if self.remote_items: self.first_notify_remote_items_of_payments() + def can_poll_backend(self): + return self.regie and self.regie.can_poll_backend() + + def poll_backend(self): + response = self.regie.payment_backend.backend.payment_status( + self.order_id, transaction_date=self.start_date + ) + 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 = [ diff --git a/tests/test_lingo_payment.py b/tests/test_lingo_payment.py index dea05239..e73f7704 100644 --- a/tests/test_lingo_payment.py +++ b/tests/test_lingo_payment.py @@ -1,4 +1,5 @@ import json +import types import uuid from contextlib import contextmanager from datetime import datetime, timedelta @@ -16,7 +17,7 @@ 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 @@ -57,46 +58,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 @@ -2039,3 +2029,64 @@ 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 TestPayfip: + @pytest.fixture + def payment_backend(self, payment_backend): + payment_backend.service = 'payfip_ws' + payment_backend.save() + return payment_backend + + def test_transaction_poll_backend(self, db, regie): + 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]) + + 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, + ) + transaction.poll_backend() + 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_payment_backend_poll_backend(self, db, payment_backend, regie, freezer): + 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]) + + # move one hour in the future + freezer.move_to(timedelta(hours=1)) + + with mock.patch( + 'combo.apps.lingo.models.Transaction.poll_backend', autospec=True + ) as transaction_poll_backend: + payment_backend.poll_backend() + transaction_poll_backend.assert_called_once_with(transaction) + + def test_command_poll_backend(self, db, payment_backend, regie, freezer): + 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]) + + # move one hour in the future + freezer.move_to(timedelta(hours=1)) + + with mock.patch( + 'combo.apps.lingo.models.Transaction.poll_backend', autospec=True + ) as transaction_poll_backend: + payment_backend.poll_backend() + transaction_poll_backend.assert_called_once_with(transaction) -- 2.31.1