diff --git a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html index fe9ee5cec6..b2526f8ee1 100644 --- a/src/pretix/control/templates/pretixcontrol/order/refund_choose.html +++ b/src/pretix/control/templates/pretixcontrol/order/refund_choose.html @@ -192,6 +192,7 @@ +
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 46dadd0fcb..175a1b188c 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -49,7 +49,7 @@ from django.core.exceptions import PermissionDenied, ValidationError from django.core.files import File from django.db import transaction from django.db.models import ( - Count, Exists, F, IntegerField, OuterRef, Prefetch, ProtectedError, Q, + Count, Exists, F, IntegerField, Max, OuterRef, Prefetch, ProtectedError, Q, QuerySet, Subquery, Sum, ) from django.forms import formset_factory @@ -1104,249 +1104,9 @@ class OrderRefundView(OrderView): p.propose_refund = proposals.get(p, 0) if 'perform' in self.request.POST: - refund_selected = Decimal('0.00') - refunds = [] - - is_valid = True - manual_value = self.request.POST.get('refund-manual', '0') or '0' - manual_value = formats.sanitize_separators(manual_value) - try: - manual_value = Decimal(manual_value) - except (DecimalException, TypeError): - messages.error(self.request, _('You entered an invalid number.')) - is_valid = False - else: - refund_selected += manual_value - if manual_value: - refunds.append(OrderRefund( - order=self.order, - payment=None, - source=OrderRefund.REFUND_SOURCE_ADMIN, - state=( - OrderRefund.REFUND_STATE_DONE - if self.request.POST.get('manual_state') == 'done' - else OrderRefund.REFUND_STATE_CREATED - ), - execution_date=( - now() - if self.request.POST.get('manual_state') == 'done' - else None - ), - amount=manual_value, - comment=comment, - provider='manual' - )) - - giftcard_value = self.request.POST.get('refund-new-giftcard', '0') or '0' - giftcard_value = formats.sanitize_separators(giftcard_value) - try: - giftcard_value = Decimal(giftcard_value) - except (DecimalException, TypeError): - messages.error(self.request, _('You entered an invalid number.')) - is_valid = False - else: - if giftcard_value: - refund_selected += giftcard_value - - if self.request.POST.get('giftcard-expires'): - try: - expires = forms.DateField().to_python(self.request.POST.get('giftcard-expires')) - expires = make_aware(datetime.combine( - expires, - time(hour=23, minute=59, second=59) - ), self.request.event.timezone) - except ValidationError as e: - messages.error(self.request, e.message) - is_valid = False - else: - expires = None - - giftcard = self.request.organizer.issued_gift_cards.create( - expires=expires, - currency=self.request.event.currency, - testmode=self.order.testmode - ) - giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={}) - refunds.append(OrderRefund( - order=self.order, - payment=None, - source=OrderRefund.REFUND_SOURCE_ADMIN, - state=OrderRefund.REFUND_STATE_CREATED, - execution_date=now(), - amount=giftcard_value, - provider='giftcard', - comment=comment, - info=json.dumps({ - 'gift_card': giftcard.pk - }) - )) - - offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0' - offsetting_value = formats.sanitize_separators(offsetting_value) - try: - offsetting_value = Decimal(offsetting_value) - except (DecimalException, TypeError): - messages.error(self.request, _('You entered an invalid number.')) - is_valid = False - else: - if offsetting_value: - refund_selected += offsetting_value - try: - order = Order.objects.get(code=self.request.POST.get('order-offsetting'), - event__organizer=self.request.organizer) - except Order.DoesNotExist: - messages.error(self.request, _('You entered an order that could not be found.')) - is_valid = False - else: - if order.event.currency != self.request.event.currency: - messages.error(self.request, _('You entered an order in an event with a different currency.')) - is_valid = False - refunds.append(OrderRefund( - order=self.order, - payment=None, - source=OrderRefund.REFUND_SOURCE_ADMIN, - state=OrderRefund.REFUND_STATE_DONE, - execution_date=now(), - amount=offsetting_value, - provider='offsetting', - comment=comment, - info=json.dumps({ - 'orders': [order.code] - }) - )) - - for identifier, prov in self.request.event.get_payment_providers().items(): - prof_value = self.request.POST.get(f'newrefund-{identifier}', '0') or '0' - prof_value = formats.sanitize_separators(prof_value) - try: - prof_value = Decimal(prof_value) - except (DecimalException, TypeError): - messages.error(self.request, _('You entered an invalid number.')) - is_valid = False - continue - if prof_value > Decimal('0.00'): - try: - refund = prov.new_refund_control_form_process(self.request, prof_value, self.order) - except ValidationError as e: - for err in e: - messages.error(self.request, err) - is_valid = False - continue - if refund: - refund_selected += refund.amount - refund.comment = comment - refund.source = OrderRefund.REFUND_SOURCE_ADMIN - refunds.append(refund) - - for p in payments: - value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0' - value = formats.sanitize_separators(value) - try: - value = Decimal(value) - except (DecimalException, TypeError): - messages.error(self.request, _('You entered an invalid number.')) - is_valid = False - else: - if value == 0: - continue - elif value > p.available_amount: - messages.error(self.request, _('You can not refund more than the amount of a ' - 'payment that is not yet refunded.')) - is_valid = False - break - elif value != p.amount and not p.partial_refund_possible: - messages.error(self.request, _('You selected a partial refund for a payment method that ' - 'only supports full refunds.')) - is_valid = False - break - elif (p.partial_refund_possible or p.full_refund_possible) and value > 0: - refund_selected += value - refunds.append(OrderRefund( - order=self.order, - payment=p, - source=OrderRefund.REFUND_SOURCE_ADMIN, - state=OrderRefund.REFUND_STATE_CREATED, - amount=value, - comment=comment, - provider=p.provider - )) - - any_success = False - if refund_selected == full_refund and is_valid: - for r in refunds: - r.save() - self.order.log_action('pretix.event.order.refund.created', { - 'local_id': r.local_id, - 'provider': r.provider, - }, user=self.request.user) - if r.provider != "manual": - try: - r.payment_provider.execute_refund(r) - except PaymentException as e: - r.state = OrderRefund.REFUND_STATE_FAILED - r.save() - messages.error(self.request, _('One of the refunds failed to be processed. You should ' - 'retry to refund in a different way. The error message ' - 'was: {}').format(str(e))) - else: - any_success = True - if r.state == OrderRefund.REFUND_STATE_DONE: - messages.success(self.request, _('A refund of {} has been processed.').format( - money_filter(r.amount, self.request.event.currency) - )) - elif r.state == OrderRefund.REFUND_STATE_CREATED: - messages.info(self.request, _('A refund of {} has been saved, but not yet ' - 'fully executed. You can mark it as complete ' - 'below.').format( - money_filter(r.amount, self.request.event.currency) - )) - else: - any_success = True - - if r.state == OrderRefund.REFUND_STATE_DONE: - self.order.log_action('pretix.event.order.refund.done', { - 'local_id': r.local_id, - 'provider': r.provider, - }, user=self.request.user) - - if any_success: - if self.start_form.cleaned_data.get('action') == 'mark_refunded': - if self.order.cancel_allowed(): - mark_order_refunded(self.order, user=self.request.user) - elif self.start_form.cleaned_data.get('action') == 'mark_pending': - if not (self.order.status == Order.STATUS_PAID and self.order.pending_sum <= 0): - self.order.status = Order.STATUS_PENDING - self.order.set_expires( - now(), - self.order.event.subevents.filter( - id__in=self.order.positions.values_list('subevent_id', flat=True)) - ) - self.order.save(update_fields=['status', 'expires']) - - if giftcard_value and self.order.email: - messages.success(self.request, _('A new gift card was created. You can now send the user their ' - 'gift card code.')) - with language(self.order.locale, self.request.event.settings.region): - return redirect(reverse('control:event.order.sendmail', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - 'code': self.order.code - }) + '?' + urlencode({ - 'subject': gettext('Your gift card code'), - 'message': gettext( - 'Hello,\n\nwe have refunded you {amount} for your order.\n\nYou can use the gift ' - 'card code {giftcard} to pay for future ticket purchases in our shop.\n\n' - 'Your {event} team' - ).format( - event="{event}", - amount=money_filter(giftcard_value, self.request.event.currency), - giftcard=giftcard.secret, - ) - })) - return redirect(self.get_order_url()) - else: - messages.error(self.request, _('The refunds you selected do not match the selected total refund ' - 'amount.')) + r = self.perform_refund(comment, full_refund, payments) + if r: + return r new_refunds = [] for identifier, prov in self.request.event.get_payment_providers().items(): @@ -1376,9 +1136,264 @@ class OrderRefundView(OrderView): self.request.POST.get('start-partial_amount') if self.request.method == 'POST' else self.request.GET.get('start-partial_amount') ), - 'start_form': self.start_form + 'start_form': self.start_form, + 'last_known_refund_id': self.order.refunds.aggregate(m=Max("id"))["m"] or 0, }) + @transaction.atomic() + def perform_refund(self, comment, full_refund, payments): + order = Order.objects.select_for_update(of=OF_SELF).get(pk=self.order.pk) + + if self.request.POST.get("last_known_refund_id", "0") != str(self.order.refunds.aggregate(m=Max("id"))["m"] or 0): + messages.error(self.request, _('The refund was prevented due to a refund already being processed at the ' + 'same time. Please have a look at the order details and check if your ' + 'refund is still necessary.')) + return redirect(self.get_order_url()) + + refund_selected = Decimal('0.00') + refunds = [] + + is_valid = True + manual_value = self.request.POST.get('refund-manual', '0') or '0' + manual_value = formats.sanitize_separators(manual_value) + try: + manual_value = Decimal(manual_value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False + else: + refund_selected += manual_value + if manual_value: + refunds.append(OrderRefund( + order=order, + payment=None, + source=OrderRefund.REFUND_SOURCE_ADMIN, + state=( + OrderRefund.REFUND_STATE_DONE + if self.request.POST.get('manual_state') == 'done' + else OrderRefund.REFUND_STATE_CREATED + ), + execution_date=( + now() + if self.request.POST.get('manual_state') == 'done' + else None + ), + amount=manual_value, + comment=comment, + provider='manual' + )) + + giftcard_value = self.request.POST.get('refund-new-giftcard', '0') or '0' + giftcard_value = formats.sanitize_separators(giftcard_value) + try: + giftcard_value = Decimal(giftcard_value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False + else: + if giftcard_value: + refund_selected += giftcard_value + + if self.request.POST.get('giftcard-expires'): + try: + expires = forms.DateField().to_python(self.request.POST.get('giftcard-expires')) + expires = make_aware(datetime.combine( + expires, + time(hour=23, minute=59, second=59) + ), self.request.event.timezone) + except ValidationError as e: + messages.error(self.request, e.message) + is_valid = False + else: + expires = None + + giftcard = self.request.organizer.issued_gift_cards.create( + expires=expires, + currency=self.request.event.currency, + testmode=order.testmode + ) + giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={}) + refunds.append(OrderRefund( + order=order, + payment=None, + source=OrderRefund.REFUND_SOURCE_ADMIN, + state=OrderRefund.REFUND_STATE_CREATED, + execution_date=now(), + amount=giftcard_value, + provider='giftcard', + comment=comment, + info=json.dumps({ + 'gift_card': giftcard.pk + }) + )) + + offsetting_value = self.request.POST.get('refund-offsetting', '0') or '0' + offsetting_value = formats.sanitize_separators(offsetting_value) + try: + offsetting_value = Decimal(offsetting_value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False + else: + if offsetting_value: + refund_selected += offsetting_value + try: + offset_order = Order.objects.get(code=self.request.POST.get('order-offsetting'), + event__organizer=self.request.organizer) + except Order.DoesNotExist: + messages.error(self.request, _('You entered an order that could not be found.')) + is_valid = False + else: + if offset_order.event.currency != self.request.event.currency: + messages.error(self.request, _('You entered an order in an event with a different currency.')) + is_valid = False + refunds.append(OrderRefund( + order=order, + payment=None, + source=OrderRefund.REFUND_SOURCE_ADMIN, + state=OrderRefund.REFUND_STATE_DONE, + execution_date=now(), + amount=offsetting_value, + provider='offsetting', + comment=comment, + info=json.dumps({ + 'orders': [offset_order.code] + }) + )) + + for identifier, prov in self.request.event.get_payment_providers().items(): + prof_value = self.request.POST.get(f'newrefund-{identifier}', '0') or '0' + prof_value = formats.sanitize_separators(prof_value) + try: + prof_value = Decimal(prof_value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False + continue + if prof_value > Decimal('0.00'): + try: + refund = prov.new_refund_control_form_process(self.request, prof_value, order) + except ValidationError as e: + for err in e: + messages.error(self.request, err) + is_valid = False + continue + if refund: + refund_selected += refund.amount + refund.comment = comment + refund.source = OrderRefund.REFUND_SOURCE_ADMIN + refunds.append(refund) + + for p in payments: + value = self.request.POST.get('refund-{}'.format(p.pk), '0') or '0' + value = formats.sanitize_separators(value) + try: + value = Decimal(value) + except (DecimalException, TypeError): + messages.error(self.request, _('You entered an invalid number.')) + is_valid = False + else: + if value == 0: + continue + elif value > p.available_amount: + messages.error(self.request, _('You can not refund more than the amount of a ' + 'payment that is not yet refunded.')) + is_valid = False + break + elif value != p.amount and not p.partial_refund_possible: + messages.error(self.request, _('You selected a partial refund for a payment method that ' + 'only supports full refunds.')) + is_valid = False + break + elif (p.partial_refund_possible or p.full_refund_possible) and value > 0: + refund_selected += value + refunds.append(OrderRefund( + order=order, + payment=p, + source=OrderRefund.REFUND_SOURCE_ADMIN, + state=OrderRefund.REFUND_STATE_CREATED, + amount=value, + comment=comment, + provider=p.provider + )) + + any_success = False + if refund_selected == full_refund and is_valid: + for r in refunds: + r.save() + order.log_action('pretix.event.order.refund.created', { + 'local_id': r.local_id, + 'provider': r.provider, + }, user=self.request.user) + if r.provider != "manual": + try: + r.payment_provider.execute_refund(r) + except PaymentException as e: + r.state = OrderRefund.REFUND_STATE_FAILED + r.save() + messages.error(self.request, _('One of the refunds failed to be processed. You should ' + 'retry to refund in a different way. The error message ' + 'was: {}').format(str(e))) + else: + any_success = True + if r.state == OrderRefund.REFUND_STATE_DONE: + messages.success(self.request, _('A refund of {} has been processed.').format( + money_filter(r.amount, self.request.event.currency) + )) + elif r.state == OrderRefund.REFUND_STATE_CREATED: + messages.info(self.request, _('A refund of {} has been saved, but not yet ' + 'fully executed. You can mark it as complete ' + 'below.').format( + money_filter(r.amount, self.request.event.currency) + )) + else: + any_success = True + + if r.state == OrderRefund.REFUND_STATE_DONE: + order.log_action('pretix.event.order.refund.done', { + 'local_id': r.local_id, + 'provider': r.provider, + }, user=self.request.user) + + if any_success: + if self.start_form.cleaned_data.get('action') == 'mark_refunded': + if order.cancel_allowed(): + mark_order_refunded(order, user=self.request.user) + elif self.start_form.cleaned_data.get('action') == 'mark_pending': + if not (order.status == Order.STATUS_PAID and self.order.pending_sum <= 0): + order.status = Order.STATUS_PENDING + order.set_expires( + now(), + order.event.subevents.filter( + id__in=order.positions.values_list('subevent_id', flat=True)) + ) + order.save(update_fields=['status', 'expires']) + + if giftcard_value and order.email: + messages.success(self.request, _('A new gift card was created. You can now send the user their ' + 'gift card code.')) + with language(order.locale, self.request.event.settings.region): + return redirect(reverse('control:event.order.sendmail', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + 'code': order.code + }) + '?' + urlencode({ + 'subject': gettext('Your gift card code'), + 'message': gettext( + 'Hello,\n\nwe have refunded you {amount} for your order.\n\nYou can use the gift ' + 'card code {giftcard} to pay for future ticket purchases in our shop.\n\n' + 'Your {event} team' + ).format( + event="{event}", + amount=money_filter(giftcard_value, self.request.event.currency), + giftcard=giftcard.secret, + ) + })) + return redirect(self.get_order_url()) + else: + messages.error(self.request, _('The refunds you selected do not match the selected total refund ' + 'amount.')) + def post(self, *args, **kwargs): if self.start_form.is_valid(): return self.choose_form() diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index d427b22e72..25c15c6c7e 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -1890,6 +1890,7 @@ def test_refund_paid_order_fully_mark_as_refunded(client, env): 'start-action': 'mark_refunded', 'refund-manual': '14.00', 'manual_state': 'done', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) p.refresh_from_db() @@ -1918,6 +1919,7 @@ def test_refund_paid_order_fully_mark_as_pending(client, env): 'start-action': 'mark_pending', 'refund-manual': '14.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) p.refresh_from_db() @@ -1951,6 +1953,7 @@ def test_refund_paid_order_partially_mark_as_pending(client, env): 'start-action': 'mark_pending', 'refund-manual': '7.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) p.refresh_from_db() @@ -2040,6 +2043,7 @@ def test_refund_amount_does_not_match_or_invalid(client, env): 'refund-manual': '4.00', 'refund-{}'.format(p.pk): '4.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) assert b'alert-danger' in resp.content @@ -2051,6 +2055,7 @@ def test_refund_amount_does_not_match_or_invalid(client, env): 'refund-manual': '0.00', 'refund-{}'.format(p.pk): '15.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) assert b'alert-danger' in resp.content @@ -2062,6 +2067,7 @@ def test_refund_amount_does_not_match_or_invalid(client, env): 'refund-manual': '-3.00', 'refund-{}'.format(p.pk): '10.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) assert b'alert-danger' in resp.content @@ -2073,6 +2079,7 @@ def test_refund_amount_does_not_match_or_invalid(client, env): 'refund-manual': 'AA', 'refund-{}'.format(p.pk): '10.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) assert b'alert-danger' in resp.content @@ -2109,6 +2116,7 @@ def test_refund_paid_order_automatically_failed(client, env, monkeypatch): 'start-action': 'mark_pending', 'refund-{}'.format(p.pk): '7.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) assert b'This failed.' in r.content @@ -2156,6 +2164,7 @@ def test_refund_paid_order_automatically(client, env, monkeypatch): 'start-action': 'mark_pending', 'refund-{}'.format(p.pk): '7.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) p.refresh_from_db() @@ -2183,6 +2192,7 @@ def test_refund_paid_order_offsetting_to_unknown(client, env): 'refund-offsetting': '5.00', 'order-offsetting': 'BAZ', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) assert b'alert-danger' in r.content @@ -2217,6 +2227,7 @@ def test_refund_paid_order_offsetting_to_wrong_currency(client, env): 'refund-offsetting': '5.00', 'order-offsetting': 'BAZ', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) assert b'alert-danger' in r.content @@ -2243,6 +2254,7 @@ def test_refund_paid_order_offsetting(client, env): 'refund-offsetting': '5.00', 'order-offsetting': 'BAZ', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) p.refresh_from_db() @@ -2262,6 +2274,35 @@ def test_refund_paid_order_offsetting(client, env): assert p2.state == OrderPayment.PAYMENT_STATE_CONFIRMED +@pytest.mark.django_db +def test_refund_prevent_duplicate_submit(client, env): + with scopes_disabled(): + p = env[2].payments.last() + p.confirm() + client.login(email='dummy@dummy.dummy', password='dummy') + Order.objects.create( + code='BAZ', event=env[0], email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=5, locale='en' + ) + env[2].refunds.create(provider="manual", amount=Decimal("2.00"), state=OrderRefund.REFUND_STATE_CREATED) + + r = client.post('/control/event/dummy/dummy/orders/FOO/refund', { + 'start-partial_amount': '5.00', + 'start-mode': 'partial', + 'start-action': 'mark_pending', + 'refund-offsetting': '5.00', + 'order-offsetting': 'BAZ', + 'manual_state': 'pending', + 'last_known_refund_id': 0, + 'perform': 'on' + }, follow=True) + assert b'alert-danger' in r.content + with scopes_disabled(): + assert env[2].refunds.count() == 1 + + @pytest.mark.django_db def test_refund_paid_order_giftcard(client, env): with scopes_disabled(): @@ -2275,6 +2316,7 @@ def test_refund_paid_order_giftcard(client, env): 'start-action': 'mark_pending', 'refund-new-giftcard': '5.00', 'manual_state': 'pending', + 'last_known_refund_id': 0, 'perform': 'on' }, follow=True) p.refresh_from_db() diff --git a/src/tests/plugins/banktransfer/test_refund.py b/src/tests/plugins/banktransfer/test_refund.py index 62d8050c93..a718cc72a8 100644 --- a/src/tests/plugins/banktransfer/test_refund.py +++ b/src/tests/plugins/banktransfer/test_refund.py @@ -72,6 +72,7 @@ def test_perform_refund(client, env): r = client.post(url, { f"refund-{payment.id}": "23.00", "start-mode": "full", + "last_known_refund_id": 0, "perform": True, }) assert r.status_code == 302 @@ -102,6 +103,7 @@ def test_cannot_perform_refund_with_invalid_iban(client, env): r = client.post(url, { f"refund-{payment.id}": "23.00", "start-mode": "full", + "last_known_refund_id": 0, "perform": True, }) assert r.status_code == 200 # no successfull POST