Repository: pretix/pretix

Issue:
Automatically create cancellations for invoices on expiry
…this requires a re-creation of the invoice if the payment is made and accepted later on.


Discussion:

Resolving patch:
diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py
--- a/src/pretix/api/views/order.py
+++ b/src/pretix/api/views/order.py
@@ -21,7 +21,8 @@
 from pretix.base.services.invoices import invoice_pdf
 from pretix.base.services.mail import SendMailException
 from pretix.base.services.orders import (
-    OrderError, cancel_order, extend_order, mark_order_paid,
+    OrderError, cancel_order, extend_order, mark_order_expired,
+    mark_order_paid,
 )
 from pretix.base.services.tickets import (
     get_cachedticket_for_order, get_cachedticket_for_position,
@@ -153,10 +154,8 @@ def mark_expired(self, request, **kwargs):
                 status=status.HTTP_400_BAD_REQUEST
             )
 
-        order.status = Order.STATUS_EXPIRED
-        order.save()
-        order.log_action(
-            'pretix.event.order.expired',
+        mark_order_expired(
+            order,
             user=request.user if request.user.is_authenticated else None,
             api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
         )
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -128,8 +128,14 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date
     order_paid.send(order.event, order=order)
 
     invoice = None
-    if order.event.settings.get('invoice_generate') in ('True', 'paid') and invoice_qualified(order):
-        if not order.invoices.exists():
+    if invoice_qualified(order):
+        invoices = order.invoices.filter(is_cancellation=False).count()
+        cancellations = order.invoices.filter(is_cancellation=True).count()
+        gen_invoice = (
+            (invoices == 0 and order.event.settings.get('invoice_generate') in ('True', 'paid')) or
+            0 < invoices <= cancellations
+        )
+        if gen_invoice:
             invoice = generate_invoice(
                 order,
                 trigger_pdf=not send_mail or not order.event.settings.invoice_email_attachment
@@ -231,6 +237,32 @@ def mark_order_refunded(order, user=None):
     return order
 
 
+@transaction.atomic
+def mark_order_expired(order, user=None, api_token=None):
+    """
+    Mark this order as expired. This sets the payment status and returns the order object.
+    :param order: The order to change
+    :param user: The user that performed the change
+    :param api_token: The API token used to performed the change
+    """
+    if isinstance(order, int):
+        order = Order.objects.get(pk=order)
+    if isinstance(user, int):
+        user = User.objects.get(pk=user)
+    if isinstance(api_token, int):
+        api_token = TeamAPIToken.objects.get(pk=api_token)
+    with order.event.lock():
+        order.status = Order.STATUS_EXPIRED
+        order.save()
+
+    order.log_action('pretix.event.order.expired', user=user, api_token=api_token)
+    i = order.invoices.filter(is_cancellation=False).last()
+    if i:
+        generate_cancellation(i)
+
+    return order
+
+
 @transaction.atomic
 def _cancel_order(order, user=None, send_mail: bool=True, api_token=None):
     """
@@ -562,9 +594,7 @@ def expire_orders(sender, **kwargs):
             expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
             eventcache[o.event.pk] = expire
         if expire:
-            o.status = Order.STATUS_EXPIRED
-            o.log_action('pretix.event.order.expired')
-            o.save()
+            mark_order_expired(o)
 
 
 @receiver(signal=periodic_task)
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py
--- a/src/pretix/control/views/orders.py
+++ b/src/pretix/control/views/orders.py
@@ -38,7 +38,7 @@
 from pretix.base.services.mail import SendMailException, render_mail
 from pretix.base.services.orders import (
     OrderChangeManager, OrderError, cancel_order, extend_order,
-    mark_order_paid,
+    mark_order_expired, mark_order_paid,
 )
 from pretix.base.services.stats import order_overview
 from pretix.base.signals import register_data_exporters
@@ -229,9 +229,7 @@ def post(self, *args, **kwargs):
             self.order.log_action('pretix.event.order.unpaid', user=self.request.user)
             messages.success(self.request, _('The order has been marked as not paid.'))
         elif self.order.status == Order.STATUS_PENDING and to == 'e':
-            self.order.status = Order.STATUS_EXPIRED
-            self.order.save()
-            self.order.log_action('pretix.event.order.expired', user=self.request.user)
+            mark_order_expired(self.order, user=self.request.user)
             messages.success(self.request, _('The order has been marked as expired.'))
         elif self.order.status == Order.STATUS_PAID and to == 'r':
             ret = self.payment_provider.order_control_refund_perform(self.request, self.order)


Test patch:
diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py
--- a/src/tests/base/test_orders.py
+++ b/src/tests/base/test_orders.py
@@ -19,7 +19,7 @@
 from pretix.base.services.invoices import generate_invoice
 from pretix.base.services.orders import (
     OrderChangeManager, OrderError, _create_order, expire_orders,
-    send_download_reminders,
+    mark_order_paid, send_download_reminders,
 )
 
 
@@ -28,7 +28,8 @@ def event():
     o = Organizer.objects.create(name='Dummy', slug='dummy')
     event = Event.objects.create(
         organizer=o, name='Dummy', slug='dummy',
-        date_from=now()
+        date_from=now(),
+        plugins='pretix.plugins.banktransfer'
     )
     return event
 
@@ -144,21 +145,42 @@ def test_expiry_dst(event):
 def test_expiring(event):
     o1 = Order.objects.create(
         code='FOO', event=event, email='dummy@dummy.test',
-        status=Order.STATUS_PENDING,
+        status=Order.STATUS_PENDING, locale='en',
         datetime=now(), expires=now() + timedelta(days=10),
         total=0, payment_provider='banktransfer'
     )
     o2 = Order.objects.create(
         code='FO2', event=event, email='dummy@dummy.test',
-        status=Order.STATUS_PENDING,
+        status=Order.STATUS_PENDING, locale='en',
         datetime=now(), expires=now() - timedelta(days=10),
-        total=0, payment_provider='banktransfer'
+        total=12, payment_provider='banktransfer'
     )
+    generate_invoice(o2)
     expire_orders(None)
     o1 = Order.objects.get(id=o1.id)
     assert o1.status == Order.STATUS_PENDING
     o2 = Order.objects.get(id=o2.id)
     assert o2.status == Order.STATUS_EXPIRED
+    assert o2.invoices.count() == 2
+    assert o2.invoices.last().is_cancellation is True
+
+
+@pytest.mark.django_db
+def test_expiring_paid_invoice(event):
+    o2 = Order.objects.create(
+        code='FO2', event=event, email='dummy@dummy.test',
+        status=Order.STATUS_PENDING, locale='en',
+        datetime=now(), expires=now() - timedelta(days=10),
+        total=12, payment_provider='banktransfer'
+    )
+    generate_invoice(o2)
+    expire_orders(None)
+    o2 = Order.objects.get(id=o2.id)
+    assert o2.status == Order.STATUS_EXPIRED
+    assert o2.invoices.count() == 2
+    mark_order_paid(o2)
+    assert o2.invoices.count() == 3
+    assert o2.invoices.last().is_cancellation is False
 
 
 @pytest.mark.django_db