# # lingo - basket and payment system # Copyright (C) 2015 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 . import datetime import json import logging import re import urllib.parse from decimal import Decimal from functools import reduce import eopayment from dateutil import parser from django import template from django.conf import settings from django.core import serializers from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.core.mail import EmailMultiAlternatives from django.db import models from django.db.models import JSONField 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 from django.urls import reverse from django.utils import dateparse, timezone from django.utils.encoding import force_bytes from django.utils.formats import localize from django.utils.timezone import make_aware, now, utc from django.utils.translation import gettext_lazy as _ from requests import RequestException from combo.apps.notifications.models import Notification from combo.data.fields import RichTextField from combo.data.library import register_cell_class from combo.data.models import CellBase from combo.utils import NothingInCacheException, aes_hex_encrypt, requests from .utils import signing_dumps try: from mellon.models import UserSAMLIdentifier except ImportError: UserSAMLIdentifier = None logger = logging.getLogger('combo.apps.lingo') class LingoException(Exception): def __init__(self, msg=None, *, transaction=None): self.transaction = transaction super().__init__(msg) class UnsignedPaymentException(LingoException): pass class UnknownPaymentException(LingoException): pass EXPIRED = 9999 SERVICES = [ (eopayment.DUMMY, _('Dummy (for tests)')), (eopayment.SYSTEMPAY, 'systempay (Banque Populaire)'), (eopayment.SIPS2, _('SIPS (Atos, other countries)')), (eopayment.OGONE, _('Ingenico (formerly Ogone)')), (eopayment.PAYBOX, 'Paybox'), (eopayment.PAYZEN, 'PayZen'), (eopayment.PAYFIP_WS, 'PayFiP Régie Web-Service'), (eopayment.KEYWARE, 'Keyware'), (eopayment.MOLLIE, 'Mollie'), (eopayment.SAGA, 'Saga/PayFiP Régie (Futur System)'), (eopayment.TIPI, 'PayFiP/TIPI Régie (deprecated)'), # hidden in creation form (see forms.py) ] def eopayment_response_to_extra_info(response, **kwargs): extra_info = dict(kwargs) extra_info.update( { 'eopayment_order_id': response.order_id, 'eopayment_response': repr(response), } ) for k, v in response.bank_data.items(): extra_info['eopayment_bank_data_' + k] = v return extra_info class RegieException(Exception): pass class RemoteInvoiceException(Exception): pass def build_remote_item(data, regie): return RemoteItem( id=data.get('id'), regie=regie, creation_date=data['created'], payment_limit_date=data['pay_limit_date'], display_id=data.get('display_id'), total_amount=data.get('total_amount'), amount=data.get('amount'), amount_paid=data.get('amount_paid'), subject=data.get('label'), has_pdf=data.get('has_pdf'), online_payment=data.get('online_payment'), paid=data.get('paid'), payment_date=data.get('payment_date'), no_online_payment_reason=data.get('no_online_payment_reason'), reference_id=data.get('reference_id'), ) class PaymentBackendManager(models.Manager): def get_by_natural_key(self, slug): return self.get(slug=slug) class PaymentBackend(models.Model): label = models.CharField(verbose_name=_('Label'), max_length=64) slug = models.SlugField( unique=True, verbose_name=_('Identifier'), help_text=_('The identifier is used in webservice calls and callback URLs for the payment backend.'), ) service = models.CharField(verbose_name=_('Payment Service'), max_length=64, choices=SERVICES) service_options = JSONField(blank=True, default=dict, verbose_name=_('Payment Service Options')) objects = PaymentBackendManager() def __str__(self): return str(self.label) @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, str): # backward compatibility when used againt postgresql < 9.4 and # service_options is received as a string. try: options = json.loads(options) except ValueError: pass if not isinstance(options, dict): options = {} if request: if not automatic_return_url: automatic_return_url = self.callback_url 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['normal_return_url'] = normal_return_url options['automatic_return_url'] = automatic_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): return (self.slug,) @classmethod def export_all_for_json(cls): return [x.get_as_serialized_object() for x in cls.objects.all()] def get_as_serialized_object(self): serialized_backend = json.loads(serializers.serialize('json', [self], use_natural_primary_keys=True))[ 0 ] del serialized_backend['model'] return serialized_backend @classmethod def load_serialized_objects(cls, json_site): for json_backend in json_site: cls.load_serialized_object(json_backend) @classmethod def load_serialized_object(cls, json_backend): json_backend['model'] = str(cls._meta) try: backend = cls.objects.get_by_natural_key(json_backend['fields']['slug']) json_backend['pk'] = backend.pk except cls.DoesNotExist: pass backend = next(serializers.deserialize('json', json.dumps([json_backend]), ignorenonexistent=True)) backend.save() def handle_backend_response(self, response, callback=True): try: transaction = Transaction.objects.get(order_id=response.order_id) except Transaction.DoesNotExist: raise UnknownPaymentException('Received unknown payment response') else: logger.debug( 'lingo: backend "%s" received payment response with id %s', self, response.order_id, extra=eopayment_response_to_extra_info( response, lingo_transaction_id=transaction.pk, user=transaction.user ), ) # check if transaction belong to the right payment backend if not transaction.regie.payment_backend == self: raise LingoException('Invalid payment backend', transaction=transaction) 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 # skip fast in order to save some resources on database if not transactions.exists(): return 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) @property def callback_url(self): return reverse('lingo-callback-payment-backend', kwargs={'payment_backend_pk': self.slug}) class Regie(models.Model): label = models.CharField(verbose_name=_('Label'), max_length=64) slug = models.SlugField( unique=True, verbose_name=_('Identifier'), help_text=_('The identifier is used in webservice calls.') ) description = models.TextField(verbose_name=_('Description')) is_default = models.BooleanField(verbose_name=_('Default Regie'), default=False) webservice_url = models.URLField(_('Webservice URL to retrieve remote items'), blank=True) extra_fees_ws_url = models.URLField(_('Webservice URL to compute extra fees'), blank=True) payment_min_amount = models.DecimalField( _('Minimal payment amount'), max_digits=7, decimal_places=2, default=0 ) text_on_success = models.TextField( verbose_name=_('Custom text displayed on success'), blank=True, null=True ) payment_backend = models.ForeignKey( PaymentBackend, on_delete=models.CASCADE, verbose_name=_('Payment backend') ) transaction_options = JSONField(blank=True, default=dict, verbose_name=_('Transaction Options')) can_pay_only_one_basket_item = models.BooleanField( default=True, verbose_name=_('Basket items must be paid individually') ) def is_remote(self): return self.webservice_url != '' class Meta: verbose_name = _('Regie') ordering = ( '-is_default', 'label', ) def save(self, *args, **kwargs): if self.webservice_url and self.webservice_url.endswith('/'): self.webservice_url = self.webservice_url.strip('/') if self.is_default: qs = self.__class__.objects.filter(is_default=True) if self.pk: qs = qs.exclude(pk=self.pk) qs.update(is_default=False) elif not self.__class__.objects.filter(is_default=True).exists(): self.is_default = True super().save(*args, **kwargs) def natural_key(self): return (self.slug,) def __str__(self): return str(self.label) def get_text_on_success(self): if self.text_on_success: return self.text_on_success return _('Your payment has been succesfully registered.') def get_invoices(self, user, history=False, update_paid=False): if not self.is_remote(): return [] if user: url = self.webservice_url + '/invoices/' if history: url += 'history/' regie_exc_msg = _('Regie "%(label)s" is unavailable, please retry later.') % { 'label': self.label, } try: response = requests.get(url, user=user, remote_service='auto', cache_duration=0) response.raise_for_status() except RequestException as e: raise RegieException(regie_exc_msg) from e try: items = response.json() except ValueError as e: raise RegieException(regie_exc_msg) from e if items.get('err'): raise RegieException(regie_exc_msg) if items.get('data'): if not isinstance(items['data'], list): raise RegieException(regie_exc_msg) remote_items = [build_remote_item(item, self) for item in items['data']] if not history and update_paid: # update paid status using known transactions RemoteItem.update_paid(self, remote_items) return remote_items return [] return [] def get_invoice(self, user, invoice_id, log_errors=True, raise_4xx=False, update_paid=False): if not self.is_remote(): return self.basketitem_set.get(pk=invoice_id) url = self.webservice_url + '/invoice/%s/' % invoice_id response = requests.get( url, user=user, remote_service='auto', cache_duration=0, log_errors=log_errors ) if raise_4xx and 400 <= response.status_code < 500: raise ObjectDoesNotExist() if response.status_code == 404: raise ObjectDoesNotExist() response.raise_for_status() if response.json().get('err'): raise RemoteInvoiceException() if response.json().get('data') is None: raise ObjectDoesNotExist() remote_item = build_remote_item(response.json().get('data'), self) if update_paid: # update paid status using known transactions RemoteItem.update_paid(self, [remote_item]) return remote_item def get_invoice_pdf(self, user, invoice_id): """ downloads item's file """ if self.is_remote() and user: url = self.webservice_url + '/invoice/%s/pdf/' % invoice_id return requests.get(url, user=user, remote_service='auto', cache_duration=0) raise PermissionDenied def pay_invoice(self, invoice_id, transaction_id, transaction_date): assert timezone.is_aware(transaction_date), 'transaction_date must be an aware date' url = self.webservice_url + '/invoice/%s/pay/' % invoice_id transaction_date = transaction_date.astimezone(utc) data = { 'transaction_id': transaction_id, 'transaction_date': transaction_date.strftime('%Y-%m-%dT%H:%M:%S'), } headers = {'content-type': 'application/json'} try: response = requests.post(url, remote_service='auto', data=json.dumps(data), headers=headers) if 400 <= response.status_code < 500: raise ObjectDoesNotExist() response.raise_for_status() except RequestException as e: raise RemoteInvoiceException from e try: resp = response.json() except ValueError as e: raise RemoteInvoiceException from e if resp.get('err'): raise RemoteInvoiceException return resp def as_api_dict(self): return {'id': self.slug, 'text': self.label, 'description': self.description} def compute_extra_fees(self, user): if not self.extra_fees_ws_url: return post_data = {'data': []} basketitems = BasketItem.get_items_to_be_paid(user).filter(regie=self) for basketitem in basketitems.filter(extra_fee=False): basketitem_data = { 'subject': basketitem.subject, 'source_url': basketitem.source_url, 'details': basketitem.details, 'amount': str(basketitem.amount), 'request_data': basketitem.request_data, } post_data['data'].append(basketitem_data) if not post_data['data']: basketitems.filter(extra_fee=True).delete() return response = requests.post( self.extra_fees_ws_url, remote_service='auto', data=json.dumps(post_data), headers={'content-type': 'application/json'}, ) if response.status_code != 200 or response.json().get('err'): logger.error('failed to compute extra fees (user: %r)', user) return basketitems.filter(extra_fee=True).delete() for extra_fee in response.json().get('data'): BasketItem( user=user, regie=self, subject=extra_fee.get('subject'), amount=extra_fee.get('amount'), extra_fee=True, user_cancellable=False, ).save() def get_remote_pending_invoices(self): if not self.is_remote() or UserSAMLIdentifier is None: return {} url = self.webservice_url + '/users/with-pending-invoices/' response = requests.get( url, remote_service='auto', cache_duration=0, log_errors=False, without_user=True ) if not response.ok: return {} return response.json()['data'] def get_notification_namespace(self): return 'invoice-%s' % self.slug def get_notification_id(self, invoice): return '%s:%s' % (self.get_notification_namespace(), invoice.id) def get_notification_reminder_id(self, invoice): return '%s:reminder-%s' % (self.get_notification_namespace(), invoice.id) def notify_invoice(self, user, invoice): today = timezone.now().date() remind_delta = timezone.timedelta(days=settings.LINGO_NEW_INVOICES_REMIND_DELTA) active_items_cell = ActiveItems.objects.first() if active_items_cell: items_page_url = active_items_cell.page.get_online_url() else: items_page_url = '' notification_id = self.get_notification_id(invoice) notification_reminder_id = self.get_notification_reminder_id(invoice) if invoice.payment_limit_date < today: # invoice is out of date Notification.objects.find(user, notification_id).forget() Notification.objects.find(user, notification_reminder_id).forget() else: # invoice can be paid if invoice.payment_limit_date >= today + remind_delta: message = _('Invoice %s to pay') % invoice.subject else: message = _('Reminder: invoice %s to pay') % invoice.subject notification_id = notification_reminder_id if not Notification.objects.find(user, notification_id).exists(): self.notify_remote_invoice_by_email(user, invoice) payment_limit_date = datetime.datetime( invoice.payment_limit_date.year, invoice.payment_limit_date.month, invoice.payment_limit_date.day, ) Notification.notify( user, summary=message, id=notification_id, url=items_page_url, end_timestamp=make_aware(payment_limit_date), ) return notification_id def notify_new_remote_invoices(self): if UserSAMLIdentifier is None: # remote invoices retrieval requires SAML return pending_invoices = self.get_remote_pending_invoices() notification_ids = [] for uuid, items in pending_invoices.items(): try: user = UserSAMLIdentifier.objects.get(name_id=uuid).user except UserSAMLIdentifier.DoesNotExist: continue for invoice in items['invoices']: remote_invoice = build_remote_item(invoice, self) if remote_invoice.total_amount >= self.payment_min_amount: notification_ids.append(self.notify_invoice(user, remote_invoice)) # clear old notifications for invoice not in the source anymore Notification.objects.namespace(self.get_notification_namespace()).exclude( external_id__in=notification_ids ).forget() def notify_remote_invoice_by_email(self, user, invoice): subject_template = 'lingo/combo/invoice_email_notification_subject.txt' text_body_template = 'lingo/combo/invoice_email_notification_body.txt' html_body_template = 'lingo/combo/invoice_email_notification_body.html' payment_url = reverse('view-item', kwargs={'regie_id': self.id, 'item_crypto_id': invoice.crypto_id}) ctx = settings.TEMPLATE_VARS.copy() ctx['invoice'] = invoice ctx['payment_url'] = urllib.parse.urljoin(settings.SITE_BASE_URL, payment_url) ctx['portal_url'] = settings.SITE_BASE_URL subject = render_to_string([subject_template], ctx).strip() text_body = render_to_string([text_body_template], ctx) html_body = render_to_string([html_body_template], ctx) message = EmailMultiAlternatives(subject, text_body, to=[user.email]) message.attach_alternative(html_body, 'text/html') if invoice.has_pdf: invoice_pdf = self.get_invoice_pdf(user, invoice.id) message.attach('%s.pdf' % invoice.id, invoice_pdf.content, 'application/pdf') message.send() @classmethod def export_all_for_json(cls): return [x.get_as_serialized_object() for x in cls.objects.all()] def get_as_serialized_object(self): serialized_regie = serializers.serialize( 'json', [self], use_natural_primary_keys=True, use_natural_foreign_keys=True ) serialized_regie = json.loads(serialized_regie)[0] del serialized_regie['model'] return serialized_regie @classmethod def load_serialized_objects(cls, json_site): for json_regie in json_site: cls.load_serialized_object(json_regie) @classmethod def load_serialized_object(cls, json_regie): json_regie['model'] = str(cls._meta) try: regie = cls.objects.get(slug=json_regie['fields']['slug']) json_regie['pk'] = regie.pk except cls.DoesNotExist: pass 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) @property def callback_url(self): return self.payment_backend.callback_url class BasketItem(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True) regie = models.ForeignKey(Regie, on_delete=models.CASCADE) subject = models.CharField(verbose_name=_('Subject'), max_length=200) source_url = models.URLField(_('Source URL'), blank=True) details = models.TextField(verbose_name=_('Details'), blank=True) amount = models.DecimalField(verbose_name=_('Amount'), decimal_places=2, max_digits=8) request_data = JSONField(blank=True, default=dict) extra_fee = models.BooleanField(default=False) user_cancellable = models.BooleanField(default=True) creation_date = models.DateTimeField(auto_now_add=True) cancellation_date = models.DateTimeField(null=True) waiting_date = models.DateTimeField(null=True) payment_date = models.DateTimeField(null=True) notification_date = models.DateTimeField(null=True) capture_date = models.DateField(null=True) email = models.EmailField(null=True) reference_id = models.CharField(max_length=200) remote_item_id = models.TextField(null=True, unique=True) class Meta: ordering = ['regie', 'extra_fee', 'subject'] @classmethod def get_items_to_be_paid(cls, user, poll=False, raise_on_poll=False): qs = cls.objects.filter( user=user, payment_date__isnull=True, waiting_date__isnull=True, cancellation_date__isnull=True ) if poll: for transaction in Transaction.objects.filter(items__in=qs): if transaction.can_poll_backend(): if raise_on_poll: raise NothingInCacheException transaction.poll_backend() return qs def is_notifiable(self, status='paid'): if not self.source_url: return True url = self.source_url + 'jump/trigger/%s' % status # make a GET request to trigger, it will not actually trigger it (it requires a GET) # but it will tell us if the trigger doesn't exist (by returning a 404, vs 403 access # denied). req = requests.get(url, remote_service='auto', timeout=5, log_errors=False) if req.status_code == 404: return False return True def notify(self, status): if not self.source_url: return url = self.source_url + 'jump/trigger/%s' % status message = {'result': 'ok'} if status == 'paid': transaction = self.transaction_set.filter(status__in=(eopayment.ACCEPTED, eopayment.PAID))[0] message['transaction_id'] = transaction.id message['order_id'] = transaction.order_id message['bank_transaction_id'] = transaction.bank_transaction_id bank_transaction_date = transaction.bank_transaction_date or transaction.end_date bank_transaction_date = bank_transaction_date.astimezone(utc) message['bank_transaction_date'] = bank_transaction_date.strftime('%Y-%m-%dT%H:%M:%S') message['bank_data'] = transaction.bank_data headers = {'content-type': 'application/json'} r = requests.post(url, remote_service='auto', data=json.dumps(message), headers=headers, timeout=15) r.raise_for_status() def notify_payment(self, notify_origin=True): if notify_origin: self.notify('paid') self.notification_date = timezone.now() self.save() self.regie.compute_extra_fees(user=self.user) def notify_cancellation(self, notify_origin=False): if notify_origin: self.notify('cancelled') self.cancellation_date = timezone.now() self.save() self.regie.compute_extra_fees(user=self.user) @property def total_amount(self): return self.amount @property def payment_url(self): signature = signing_dumps(self.pk) return reverse('basket-item-pay-view', kwargs={'item_signature': signature}) class RemoteItem: payment_date = None def __init__( self, id, regie, creation_date, payment_limit_date, total_amount, amount, amount_paid, display_id, subject, has_pdf, online_payment, paid, payment_date, no_online_payment_reason, reference_id, ): self.id = id self.regie = regie self.creation_date = dateparse.parse_date(creation_date or '') self.payment_limit_date = dateparse.parse_date(payment_limit_date or '') self.total_amount = Decimal(total_amount) self.amount = Decimal(amount) if amount_paid: self.amount_paid = Decimal(amount_paid) self.display_id = display_id or self.id self.subject = subject self.has_pdf = has_pdf self.online_payment = online_payment self.paid = paid self.no_online_payment_reason = no_online_payment_reason 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): reasons = { 'litigation': _('This invoice is in litigation.'), 'autobilling': _('Autobilling has been set for this invoice.'), 'past-due-date': _('Due date is over.'), } return settings.LINGO_NO_ONLINE_PAYMENT_REASONS.get( self.no_online_payment_reason, reasons.get(self.no_online_payment_reason) ) @property def crypto_id(self): return aes_hex_encrypt(settings.SECRET_KEY, force_bytes(str(self.id))) @classmethod def transactions_for_remote_items(cls, queryset, remote_items): remote_item_ids = {remote_item.id for remote_item in remote_items if not remote_item.paid} if not remote_item_ids: return Transaction.objects.none() # filter transactions by regie, status and contained remote_item id 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 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 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: if remote_item.paid: continue 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): return { 0: _('Running'), eopayment.WAITING: _('Running'), eopayment.PAID: _('Paid'), eopayment.ACCEPTED: _('Paid (accepted)'), eopayment.CANCELLED: _('Cancelled'), EXPIRED: _('Expired'), }.get(status) or _('Unknown') class Transaction(models.Model): regie = models.ForeignKey(Regie, on_delete=models.CASCADE, null=True) items = models.ManyToManyField(BasketItem, blank=True) remote_items = models.CharField(max_length=512) to_be_paid_remote_items = models.CharField(max_length=512, null=True) start_date = models.DateTimeField(auto_now_add=True, db_index=True) end_date = models.DateTimeField(null=True) bank_data = JSONField(blank=True, default=dict) order_id = models.CharField(max_length=200) bank_transaction_id = models.CharField(max_length=200, null=True) bank_transaction_date = models.DateTimeField(blank=True, null=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True) 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 != '' def get_user_name(self): if self.user: return self.user.get_full_name() return _('Anonymous User') def is_paid(self): return self.status in self.PAID_STATUSES def is_running(self): return self.status in self.RUNNING_STATUSES def get_status_label(self): return status_label(self.status) def first_notify_remote_items_of_payments(self): self.notify_remote_items_of_payments(self.remote_items) def retry_notify_remote_items_of_payments(self): self.notify_remote_items_of_payments(self.to_be_paid_remote_items) def notify_remote_items_of_payments(self, items): if not items: return if not self.is_paid(): return regie = self.regie to_be_paid_remote_items = [] for item_id in items.split(','): try: remote_item = regie.get_invoice(user=self.user, invoice_id=item_id, raise_4xx=True) with atomic(savepoint=False): self.items.add(self.create_paid_invoice_basket_item(item_id, remote_item)) regie.pay_invoice(item_id, self.order_id, self.bank_transaction_date or self.end_date) except ObjectDoesNotExist: # 4xx error logger.error( 'unable to retrieve or pay remote item %s from transaction %s, ignore it', item_id, self ) except (RequestException, RemoteInvoiceException): # 5xx, err or requests error to_be_paid_remote_items.append(item_id) logger.warning( 'unable to notify payment for remote item %s from transaction %s, retry later', item_id, self, ) except Exception: # unknown error to_be_paid_remote_items.append(item_id) logger.exception( 'unable to notify payment for remote item %s from transaction %s', item_id, self ) else: logger.info('notified payment for remote item %s from transaction %s', item_id, self) self.to_be_paid_remote_items = ','.join(to_be_paid_remote_items) or None self.save(update_fields=['to_be_paid_remote_items']) def create_paid_invoice_basket_item(self, item_id, remote_item): subject = _('Invoice #%s') % remote_item.display_id basket_item, dummy = BasketItem.objects.get_or_create( remote_item_id=item_id, defaults={ 'user': self.user, 'regie': remote_item.regie, 'source_url': '', 'subject': subject, 'amount': remote_item.amount, 'payment_date': self.end_date, }, ) return basket_item def handle_backend_response(self, response, callback=True): logger.debug('lingo: regie "%s" handling response for transaction "%s"', self.regie, self.order_id) if self.status == response.result: # return early if self status didn't change (it means the # payment service sent the response both as server to server and # via the user browser and we already handled one). return if not self.is_running(): logger.info( 'lingo: regie "%s" received payment notification on existing ' 'transaction, status changed, "%s" (%s) -> "%s" (%s)', self.regie, status_label(self.status), self.status, status_label(response.result), response.result, ) if not response.signed and not response.result == eopayment.CANCELLED: raise UnsignedPaymentException('Received unsigned payment response', transaction=self) self.status = response.result self.bank_transaction_id = response.transaction_id self.bank_data = response.bank_data self.end_date = timezone.now() # store transaction_date but prevent multiple updates if response.transaction_date is None: logger.warning('lingo: no transaction date') elif self.bank_transaction_date is None: self.bank_transaction_date = response.transaction_date elif response.transaction_date != self.bank_transaction_date: # XXX: don't know if it can happen, but we would like to know when it does # as for differed payments there can be multiple notifications. logger.error( 'lingo: regie "%s" new transaction_date for transaction %s(%s) was %s, received %s', self.regie, self.order_id, self.id, self.bank_transaction_date, response.transaction_date, ) self.save() if response.result == eopayment.WAITING: # mark basket items as waiting for payment confirmation self.items.all().update(waiting_date=timezone.now()) return if response.result == eopayment.CANCELLED: # mark basket items as no longer waiting so the user can restart a # payment. self.items.all().update(waiting_date=None) return if response.result not in (eopayment.PAID, eopayment.ACCEPTED): return self.items.update(payment_date=self.end_date) for item in self.items.all(): try: item.notify_payment() except Exception as e: # ignore errors, it will be retried later on if it fails logger.warning( 'lingo: regie "%s" error in sync notification for basket item %s ' 'and transaction %s, %s', self.regie, item.id, self.order_id, e, ) 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 LingoException('polling failed', transaction=self) 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) def __str__(self): return f'transaction "{self.order_id}" of regie "{self.regie}"' class TransactionOperation(models.Model): OPERATIONS = [ ('validation', _('Validation')), ('cancellation', _('Cancellation')), ] transaction = models.ForeignKey(Transaction, on_delete=models.CASCADE) kind = models.CharField(max_length=65, choices=OPERATIONS) amount = models.DecimalField(decimal_places=2, max_digits=8) creation_date = models.DateTimeField(auto_now_add=True) bank_result = JSONField(blank=True, default=dict) @register_cell_class class LingoBasketCell(CellBase): user_dependant = True class Meta: verbose_name = _('Basket') class Media: js = ( 'xstatic/jquery-ui.min.js', 'js/gadjo.js', ) @classmethod def is_enabled(cls): return Regie.objects.exists() def is_relevant(self, context): if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated): return False return BasketItem.get_items_to_be_paid(context['request'].user).exists() def get_badge(self, context): if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated): return items = BasketItem.get_items_to_be_paid(context['request'].user) if not items: return total = sum(x.amount for x in items) if total == int(total): total = int(total) return {'badge': _('%s€') % localize(total)} def render(self, context): basket_template = template.loader.get_template('lingo/combo/basket.html') items = BasketItem.get_items_to_be_paid( context['request'].user, poll=True, raise_on_poll=not context.get('synchronous') ) regies = {} for item in items: if not item.regie_id in regies: regies[item.regie_id] = {'items': [], 'regie': item.regie} regies[item.regie_id]['items'].append(item) for items in regies.values(): items['total'] = sum(x.amount for x in items['items']) context['regies'] = sorted(regies.values(), key=lambda x: x['regie'].label) return basket_template.render(context) @register_cell_class class LingoRecentTransactionsCell(CellBase): user_dependant = True class Meta: verbose_name = _('Recent Transactions') @classmethod def is_enabled(cls): return Regie.objects.exists() def get_transactions_queryset(self, context, poll=False): user = context['request'].user # list transactions : # * paid by the user # * or linked to a BasketItem of the user qs_recent_txn = Transaction.objects.filter( start_date__gte=timezone.now() - datetime.timedelta(days=7) ) qs = qs_recent_txn.filter(user=user).union(qs_recent_txn.filter(items__user=user)) if poll: for transaction in qs: if transaction.can_poll_backend() and transaction.is_running(): if not context.get('synchronous'): raise NothingInCacheException transaction.poll_backend() return qs def is_relevant(self, context): if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated): return False return self.get_transactions_queryset(context).exists() def render(self, context): recent_transactions_template = template.loader.get_template('lingo/combo/recent_transactions.html') context['transactions'] = self.get_transactions_queryset(context, poll=True).order_by('-start_date') return recent_transactions_template.render(context) @register_cell_class class LingoBasketLinkCell(CellBase): user_dependant = True class Meta: verbose_name = _('Basket Link') @classmethod def is_enabled(cls): return Regie.objects.exists() def is_relevant(self, context): if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated): return False return BasketItem.get_items_to_be_paid(context['request'].user).exists() def render(self, context): if not (getattr(context['request'], 'user', None) and context['request'].user.is_authenticated): return '' try: context['basket_url'] = LingoBasketCell.objects.all()[0].page.get_online_url() except IndexError: return '' basket_template = template.loader.get_template('lingo/combo/basket_link.html') context['items'] = BasketItem.get_items_to_be_paid(user=context['request'].user) context['total'] = sum(x.amount for x in context['items']) return basket_template.render(context) class Items(CellBase): regie = models.CharField(_('Regie'), max_length=50, blank=True) title = models.CharField(_('Title'), max_length=200, blank=True) text = RichTextField(_('Text'), blank=True, null=True) user_dependant = True default_template_name = 'lingo/combo/items.html' loading_message = _('Loading invoices...') class Meta: abstract = True class Media: js = ( 'xstatic/jquery-ui.min.js', 'js/gadjo.js', ) @classmethod def is_enabled(cls): return Regie.objects.exclude(webservice_url='').exists() def is_relevant(self, context): return getattr(context['request'], 'user', None) and context['request'].user.is_authenticated def get_default_form_class(self): fields = ['text'] if hasattr(self, 'hide_if_empty'): fields.append('hide_if_empty') widgets = {} regie_qs = Regie.objects.exclude(webservice_url='') if len(regie_qs) > 1: regies = [('', _('All'))] regies.extend([(r.slug, r.label) for r in regie_qs]) widgets['regie'] = Select(choices=regies) fields.insert(0, 'regie') return model_forms.modelform_factory(self.__class__, fields=fields, widgets=widgets) def get_regies(self): if self.regie: return [Regie.objects.get(slug=self.regie)] return Regie.objects.all() def get_invoices(self, user): return [], [] def get_cell_extra_context(self, context): ctx = super().get_cell_extra_context(context) if context.get('placeholder_search_mode'): # don't call webservices when we're just looking for placeholders return ctx ctx.update({'title': self.title, 'text': self.text}) items, errors = self.get_invoices(user=context['user']) none_date = datetime.datetime(1900, 1, 1) # to avoid None-None comparison errors items.sort(key=lambda i: i.creation_date or none_date, reverse=True) ctx.update( { 'items': items, 'errors': errors, 'with_payment_limit_date': any(i.payment_limit_date for i in items), 'with_amount_paid': any(getattr(i, 'amount_paid', None) for i in items), } ) return ctx def render(self, context): self.context = context if not context.get('synchronous'): raise NothingInCacheException() return super().render(context) @register_cell_class class ItemsHistory(Items): class Meta: verbose_name = _('Items History Cell') def get_invoices(self, user): items = [] errors = [] for r in self.get_regies(): try: items.extend(r.get_invoices(user, history=True)) except RegieException as e: errors.append(e) return items, errors @register_cell_class class ActiveItems(Items): hide_if_empty = models.BooleanField(_('Hide if no items'), default=False) class Meta: verbose_name = _('Active Items Cell') def get_invoices(self, user): items = [] errors = [] for r in self.get_regies(): try: for remote_item in r.get_invoices(user, update_paid=True): if not remote_item.paid: items.append(remote_item) except RegieException as e: errors.append(e) return items, errors @register_cell_class class SelfDeclaredInvoicePayment(Items): user_dependant = False default_template_name = 'lingo/combo/self-declared-invoice-payment.html' class Meta: verbose_name = _('Self declared invoice payment') def is_relevant(self, context): return self.is_enabled() def render(self, context): context['synchronous'] = True context['page_path'] = context['request'].path return super().render(context) TIPI_CONTROL_PROCOTOLS = ( ('pesv2', _('Indigo/PES v2')), ('rolmre', _('ROLMRE')), ) @register_cell_class class TipiPaymentFormCell(CellBase): title = models.CharField(_('Title'), max_length=150, blank=True) regies = models.CharField( _('Regies'), help_text=_( 'Values separated by commas. It is possible to add a label after a regie identifier. ' 'Example: "1234 - Regie A,5678 - Regie B"' ), max_length=256, ) control_protocol = models.CharField( _('Control protocol'), max_length=8, choices=TIPI_CONTROL_PROCOTOLS, default='pesv2' ) exer = models.CharField('Exer', max_length=4, blank=True, help_text=_('Default value to be used in form')) idpce = models.CharField( 'IDPCE', max_length=8, blank=True, help_text=_('Default value to be used in form') ) idligne = models.CharField( 'IDLIGNE', max_length=6, blank=True, help_text=_('Default value to be used in form') ) rolrec = models.CharField( 'ROLREC', max_length=2, blank=True, help_text=_('Default value to be used in form') ) roldeb = models.CharField( 'ROLDEB', max_length=2, blank=True, help_text=_('Default value to be used in form') ) roldet = models.CharField( 'ROLDET', max_length=13, blank=True, help_text=_('Default value to be used in form') ) test_mode = models.BooleanField(_('Test mode'), default=False) default_template_name = 'lingo/tipi_form.html' class Meta: verbose_name = _('TIPI Payment Form') class Media: js = ('js/tipi.js',) @property def url(self): return getattr(settings, 'LINGO_TIPI_CELL_PAYMENT_URL', 'https://www.payfip.gouv.fr/tpa/paiement.web') def get_cell_extra_context(self, context): extra_context = super().get_cell_extra_context(context) form_fields = self.get_default_form_class().base_fields field_definitions = ( {'protocol': 'any', 'fields': ['exer']}, {'protocol': 'pesv2', 'fields': ['idpce', 'idligne']}, {'protocol': 'rolmre', 'fields': ['rolrec', 'roldeb', 'roldet']}, ) reference_fields = [] for definition in field_definitions: for field in definition['fields']: field_pattern = '[0-9]+' # special pattern for rolrec if field == 'rolrec': field_pattern = '[A-Z0-9]+' reference_fields.append( { 'name': field, 'length': form_fields[field].max_length, 'placeholder': '0' * form_fields[field].max_length, 'pattern': field_pattern, 'protocol': definition['protocol'], } ) context['title'] = self.title context['url'] = self.url context['mode'] = 'T' if self.test_mode else 'M' context['control_protocol'] = self.control_protocol context['regies'] = [] context['pesv2'] = self.control_protocol == 'pesv2' for field in reference_fields: if getattr(self, field['name']): field['default'] = getattr(self, field['name']) context['reference_fields'] = reference_fields for regie in self.regies.split(','): regie = regie.strip() id_search = re.search(r'(\d+)', regie) if not id_search: continue regie_id = id_search.group(1) context['regies'].append((regie_id, regie)) return extra_context