Repository: pretix/pretix

Issue:
order_free email send with non-free payment provider
When only buying free products, pretix skips the payment checkout step and uses the `FreePaymentProvider` (identifier `free`). It then sends the email `event.settings.mail_text_order_free`. So far so good.

What if someone (read "I") is using a payment provider with `-100%` payment fee?  
(e.g. if one is a member of a club, they get in for free)  
When the customer has a cart with value > 0, you can select a payment provider. If that payment provider reduces your order total to 0, inconsistencies show:

- On the order confirmation page, the customer sees the text defined by the chosen payment provider
- The email template used is `event.settings.mail_text_order_free`, stating something else than the potential payment provider wants the user to know (e.g. "please confirm their club membership")

What I would expect, is that the email is also of type `event.settings.mail_text_order_placed` in which the payment provider can insert information, just like every normal payment mail. 

To do that, I think only this line should be changed to check the payment method, not order total:
https://github.com/pretix/pretix/blob/d622f38e1da6a98935c2c8720685fdee9cde85f1/src/pretix/base/services/orders.py#L516

Also, are there any problems with negative product prices and order totals?


Discussion:
> What if someone (read "I") is using a payment provider with -100% payment fee?

Then, that someone, is kind-of abusing the payment provider system, as we discussed in person 😉 I believe in this case vouchers or separate products (and then maybe payment providers limited to certain products etc.) should be used, but I do understand your reasons and if it is really solvable by an easy change, I think we'll do the change.

> To do that, I think only this line should be changed to check the payment method, not order total:

I think we can do that change. I'm a bit reluctant since checking for the payment provider here feels less "robust",  but since in the end it is only about the log and email text, there is only so much damage to be caused here.

This will solve your email inconsistency, but I fear you'll run into harder-to-solve problems in other places as well, for example when it comes to changing orders (e.g. changing the payment provider later or changing the order's content as an admin). I haven't looked at it in detail, though.

> Also, are there any problems with negative product prices and order totals?

Negative product prices *should be* fine, I never tested it. Negative order totals are certainly unknown territory and not officially supported ;)

Resolving patch:
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
@@ -10,7 +10,7 @@
 from celery.exceptions import MaxRetriesExceededError
 from django.conf import settings
 from django.db import transaction
-from django.db.models import F, Max, Q
+from django.db.models import F, Max, Q, Sum
 from django.dispatch import receiver
 from django.utils.formats import date_format
 from django.utils.timezone import make_aware, now
@@ -513,7 +513,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
             invoice = generate_invoice(order, trigger_pdf=not event.settings.invoice_email_attachment)
             # send_mail will trigger PDF generation later
 
-    if order.total == Decimal('0.00'):
+    if order.payment_provider == 'free':
         email_template = event.settings.mail_text_order_free
         log_entry = 'pretix.event.order.email.order_free'
     else:
@@ -680,6 +680,7 @@ def __init__(self, order: Order, user, notify=True):
         self.order = order
         self.user = user
         self.split_order = None
+        self._committed = False
         self._totaldiff = 0
         self._quotadiff = Counter()
         self._operations = []
@@ -824,7 +825,10 @@ def _check_paid_price_change(self):
             raise OrderError(self.error_messages['paid_price_change'])
 
     def _check_paid_to_free(self):
-        if self.order.total == 0:
+        if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
+            # if the order becomes free, mark it paid using the 'free' provider
+            # this could happen if positions have been made cheaper or removed (_totaldiff < 0)
+            # or positions got split off to a new order (split_order with positive total)
             try:
                 mark_order_paid(
                     self.order, 'free', send_mail=False, count_waitinglist=False,
@@ -1015,6 +1019,16 @@ def _recalculate_total_and_payment_fee(self):
         self.order.total += sum([f.value for f in self.order.fees.all()])
         self.order.save()
 
+    def _payment_fee_diff(self):
+        prov = self._get_payment_provider()
+        if self.order.status != Order.STATUS_PAID and prov:
+            # payment fees of paid orders do not change
+            old_fee = OrderFee.objects.filter(order=self.order, fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or 0
+            new_total = sum([p.price for p in self.order.positions.all()]) + self._totaldiff
+            if new_total != 0:
+                new_fee = prov.calculate_fee(new_total)
+                self._totaldiff += new_fee - old_fee
+
     def _reissue_invoice(self):
         i = self.order.invoices.filter(is_cancellation=False).last()
         if i and self._invoice_dirty:
@@ -1062,9 +1076,18 @@ def _notify_user(self, order):
                 logger.exception('Order changed email could not be sent')
 
     def commit(self):
+        if self._committed:
+            # an order change can only be committed once
+            raise OrderError(error_messages['internal'])
+        self._committed = True
+
         if not self._operations:
             # Do nothing
             return
+
+        # finally, incorporate difference in payment fees
+        self._payment_fee_diff()
+
         with transaction.atomic():
             with self.order.event.lock():
                 if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
@@ -1078,6 +1101,7 @@ def commit(self):
             self._reissue_invoice()
             self._clear_tickets_cache()
         self._check_paid_to_free()
+
         if self.notify:
             self._notify_user(self.order)
             if self.split_order:
diff --git a/src/pretix/plugins/stripe/views.py b/src/pretix/plugins/stripe/views.py
--- a/src/pretix/plugins/stripe/views.py
+++ b/src/pretix/plugins/stripe/views.py
@@ -204,7 +204,6 @@ def get(self, request, *args, **kwargs):
         prov = self.pprov
         prov._init_api()
         src = stripe.Source.retrieve(request.GET.get('source'))
-        print(src.client_secret, request.GET.get('client_secret'))
         if src.client_secret != request.GET.get('client_secret'):
             messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link '
                                            'in your emails to continue.'))
@@ -232,7 +231,6 @@ def get(self, request, *args, **kwargs):
         return self._redirect_to_order()
 
     def _redirect_to_order(self):
-        print(self.request.session.get('payment_stripe_order_secret'), self.order.secret)
         if self.request.session.get('payment_stripe_order_secret') != self.order.secret:
             messages.error(self.request, _('Sorry, there was an error in the payment process. Please check the link '
                                            'in your emails to continue.'))


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
@@ -278,6 +278,13 @@ def _enable_reverse_charge(self):
             country=Country('AT')
         )
 
+    def test_multiple_commits_forbidden(self):
+        self.ocm.change_price(self.op1, Decimal('10.00'))
+        self.ocm.commit()
+        self.ocm.change_price(self.op1, Decimal('42.00'))
+        with self.assertRaises(OrderError):
+            self.ocm.commit()
+
     def test_change_subevent_quota_required(self):
         self.event.has_subevents = True
         self.event.save()
@@ -545,6 +552,21 @@ def test_payment_fee_calculation(self):
         assert fee.tax_rate == Decimal('19.00')
         assert round_decimal(fee.value * (1 - 100 / (100 + fee.tax_rate))) == fee.tax_value
 
+    def test_pending_free_order_stays_pending(self):
+        self.event.settings.set('tax_rate_default', self.tr19.pk)
+        self.ocm.change_price(self.op1, Decimal('0.00'))
+        self.ocm.change_price(self.op2, Decimal('0.00'))
+        self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
+        self.order.refresh_from_db()
+        assert self.order.total == Decimal('0.00')
+        assert self.order.status == Order.STATUS_PAID
+        self.order.status = Order.STATUS_PENDING
+        self.ocm.cancel(self.op2)
+        self.ocm.commit()
+        self.order.refresh_from_db()
+        assert self.order.status == Order.STATUS_PENDING
+
     def test_require_pending(self):
         self.order.status = Order.STATUS_PAID
         self.order.save()
@@ -734,6 +756,7 @@ def test_recalculate_reverse_charge(self):
         ia = self._enable_reverse_charge()
         self.ocm.recalculate_taxes()
         self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
         ops = list(self.order.positions.all())
         for op in ops:
             assert op.price == Decimal('21.50')
@@ -791,6 +814,7 @@ def test_split_pending_payment_fees(self):
         prov.settings.set('_fee_reverse_calc', False)
         self.ocm.change_price(self.op1, Decimal('23.00'))
         self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
         self.order.refresh_from_db()
         assert self.order.total == Decimal('47.92')
         fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
@@ -879,6 +903,7 @@ def test_split_reverse_charge(self):
         prov.settings.set('_fee_reverse_calc', False)
         self.ocm.recalculate_taxes()
         self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
         self.order.refresh_from_db()
 
         # Check if reverse charge is active
@@ -911,7 +936,6 @@ def test_split_reverse_charge(self):
         # New order
         assert self.op2.order != self.order
         o2 = self.op2.order
-        print([p.price for p in o2.positions.all()], [p.value for p in o2.fees.all()])
         assert o2.total == Decimal('21.93')
         fee = o2.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
         assert fee.value == Decimal('0.43')
@@ -971,6 +995,7 @@ def test_split_paid_payment_fees(self):
         prov.settings.set('_fee_reverse_calc', False)
         self.ocm.change_price(self.op1, Decimal('23.00'))
         self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
         self.order.refresh_from_db()
         assert self.order.total == Decimal('47.92')
         fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
@@ -1019,6 +1044,7 @@ def test_split_to_free_invoice(self):
         self.event.settings.invoice_include_free = False
         self.ocm.change_price(self.op2, Decimal('0.00'))
         self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
         self.op2.refresh_from_db()
         self.ocm._invoice_dirty = False
 
@@ -1038,6 +1064,7 @@ def test_split_to_free_invoice(self):
     def test_split_to_original_free(self):
         self.ocm.change_price(self.op2, Decimal('0.00'))
         self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
         self.op2.refresh_from_db()
 
         self.ocm.split(self.op1)
@@ -1054,6 +1081,7 @@ def test_split_to_original_free(self):
     def test_split_to_new_free(self):
         self.ocm.change_price(self.op2, Decimal('0.00'))
         self.ocm.commit()
+        self.ocm = OrderChangeManager(self.order, None)
         self.op2.refresh_from_db()
 
         self.ocm.split(self.op2)