diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py index 19a5377ee7..c324e9a63f 100644 --- a/src/pretix/base/services/cancelevent.py +++ b/src/pretix/base/services/cancelevent.py @@ -9,8 +9,8 @@ from pretix.base.decimal import round_decimal from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import ( - Event, InvoiceAddress, Order, OrderFee, OrderPosition, SubEvent, User, - WaitingListEntry, + Event, InvoiceAddress, Order, OrderFee, OrderPosition, OrderRefund, + SubEvent, User, WaitingListEntry, ) from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import SendMailException, TolerantDict, mail @@ -83,7 +83,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_fixed: str, - keep_fee_percentage: str, keep_fees: list=None, + keep_fee_percentage: str, keep_fees: list=None, manual_refund: bool=False, send: bool=False, send_subject: dict=None, send_message: dict=None, send_waitinglist: bool=False, send_waitinglist_subject: dict={}, send_waitinglist_message: dict={}, user: int=None): @@ -167,7 +167,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_ refund_amount = o.payment_refund_sum if auto_refund: - _try_auto_refund(o.pk) + _try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True, source=OrderRefund.REFUND_SOURCE_ADMIN) if send: _send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all()) @@ -211,7 +211,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, keep_fee_ refund_amount = o.payment_refund_sum - o.total if auto_refund: - _try_auto_refund(o.pk) + _try_auto_refund(o.pk, manual_refund=manual_refund, allow_partial=True, source=OrderRefund.REFUND_SOURCE_ADMIN) if send: _send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index bd55f90c52..3a21572075 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1888,7 +1888,7 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str raise OrderError(str(error_messages['busy'])) -def _try_auto_refund(order): +def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER): notify_admin = False error = False if isinstance(order, int): @@ -1898,13 +1898,14 @@ def _try_auto_refund(order): return proposals = order.propose_auto_refunds(refund_amount) - can_auto_refund = sum(proposals.values()) == refund_amount + can_auto_refund_sum = sum(proposals.values()) + can_auto_refund = (allow_partial and can_auto_refund_sum) or can_auto_refund_sum == refund_amount if can_auto_refund: for p, value in proposals.items(): with transaction.atomic(): r = order.refunds.create( payment=p, - source=OrderRefund.REFUND_SOURCE_BUYER, + source=source, state=OrderRefund.REFUND_STATE_CREATED, amount=value, provider=p.provider @@ -1930,8 +1931,22 @@ def _try_auto_refund(order): else: if r.state != OrderRefund.REFUND_STATE_DONE: notify_admin = True - elif refund_amount != Decimal('0.00'): - notify_admin = True + + if refund_amount - can_auto_refund_sum > Decimal('0.00'): + if manual_refund: + with transaction.atomic(): + r = order.refunds.create( + source=source, + state=OrderRefund.REFUND_STATE_CREATED, + amount=refund_amount - can_auto_refund_sum, + provider='manual' + ) + order.log_action('pretix.event.order.refund.created', { + 'local_id': r.local_id, + 'provider': r.provider, + }) + else: + notify_admin = True if notify_admin: order.log_action('pretix.event.order.refund.requested') diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index dec99146e8..27ef75b2f5 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -534,6 +534,15 @@ class EventCancelForm(forms.Form): initial=True, required=False ) + manual_refund = forms.BooleanField( + label=_('Create manual refund if the payment method odes not support automatic refunds'), + widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_auto_refund'}), + initial=True, + required=False, + help_text=_('If checked, all payments with a payment method not supporting automatic refunds will be on your ' + 'manual refund to-do list. Do not check if you want to refund some of the orders by offsetting ' + 'with different orders or issuing gift cards.') + ) keep_fee_fixed = forms.DecimalField( label=_("Keep a fixed cancellation fee"), max_digits=10, decimal_places=2, diff --git a/src/pretix/control/templates/pretixcontrol/orders/cancel.html b/src/pretix/control/templates/pretixcontrol/orders/cancel.html index 55de49bb34..a3b823e333 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/orders/cancel.html @@ -33,6 +33,7 @@
{% trans "Refund options" %} {% bootstrap_field form.auto_refund layout="control" %} + {% bootstrap_field form.manual_refund layout="control" %} {% bootstrap_field form.keep_fee_fixed layout="control" %} {% bootstrap_field form.keep_fee_percentage layout="control" %} {% bootstrap_field form.keep_fees layout="control" %} diff --git a/src/tests/base/test_cancelevent.py b/src/tests/base/test_cancelevent.py index 1688b49679..dcda2d8816 100644 --- a/src/tests/base/test_cancelevent.py +++ b/src/tests/base/test_cancelevent.py @@ -99,7 +99,7 @@ class EventCancelTests(TestCase): r = self.order.refunds.get() assert r.state == OrderRefund.REFUND_STATE_DONE assert r.amount == Decimal('46.00') - assert r.source == OrderRefund.REFUND_SOURCE_BUYER + assert r.source == OrderRefund.REFUND_SOURCE_ADMIN assert r.payment == p1 assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists() assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() @@ -150,7 +150,7 @@ class EventCancelTests(TestCase): r = self.order.refunds.get() assert r.state == OrderRefund.REFUND_STATE_DONE assert r.amount == Decimal('31.40') - assert r.source == OrderRefund.REFUND_SOURCE_BUYER + assert r.source == OrderRefund.REFUND_SOURCE_ADMIN assert r.payment == p1 assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists() assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() @@ -207,7 +207,7 @@ class EventCancelTests(TestCase): r = self.order.refunds.get() assert r.state == OrderRefund.REFUND_STATE_DONE assert r.amount == Decimal('36.90') - assert r.source == OrderRefund.REFUND_SOURCE_BUYER + assert r.source == OrderRefund.REFUND_SOURCE_ADMIN assert r.payment == p1 assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists() assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() @@ -247,6 +247,73 @@ class EventCancelTests(TestCase): assert not self.order.all_fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT).canceled assert self.order.all_fees.get(fee_type=OrderFee.FEE_TYPE_CANCELLATION).value == Decimal('4.10') + @classscope(attr='o') + def test_cancel_refund_paid_partial_to_manual(self): + gc = self.o.issued_gift_cards.create(currency="EUR") + p1 = self.order.payments.create( + amount=Decimal('20.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='giftcard', + info='{"gift_card": %d}' % gc.pk + ) + self.order.payments.create( + amount=Decimal('26.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='manual', + ) + self.order.status = Order.STATUS_PAID + self.order.save() + + cancel_event( + self.event.pk, subevent=None, manual_refund=True, + auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", + send=False, send_subject="Event canceled", send_message="Event canceled :-(", + user=None + ) + + assert self.order.refunds.count() == 2 + r = self.order.refunds.get(provider='giftcard') + assert r.state == OrderRefund.REFUND_STATE_DONE + assert r.amount == Decimal('20.00') + assert r.source == OrderRefund.REFUND_SOURCE_ADMIN + assert r.payment == p1 + r = self.order.refunds.get(provider='manual') + assert r.state == OrderRefund.REFUND_STATE_CREATED + assert r.amount == Decimal('26.00') + assert r.source == OrderRefund.REFUND_SOURCE_ADMIN + assert r.payment is None + + @classscope(attr='o') + def test_cancel_refund_paid_partial_no_manual(self): + gc = self.o.issued_gift_cards.create(currency="EUR") + p1 = self.order.payments.create( + amount=Decimal('20.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='giftcard', + info='{"gift_card": %d}' % gc.pk + ) + self.order.payments.create( + amount=Decimal('26.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='manual', + ) + self.order.status = Order.STATUS_PAID + self.order.save() + + cancel_event( + self.event.pk, subevent=None, manual_refund=False, + auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", + send=False, send_subject="Event canceled", send_message="Event canceled :-(", + user=None + ) + + assert self.order.refunds.count() == 1 + r = self.order.refunds.get(provider='giftcard') + assert r.state == OrderRefund.REFUND_STATE_DONE + assert r.amount == Decimal('20.00') + assert r.source == OrderRefund.REFUND_SOURCE_ADMIN + assert r.payment == p1 + class SubEventCancelTests(TestCase): def setUp(self): @@ -354,7 +421,7 @@ class SubEventCancelTests(TestCase): r = self.order.refunds.get() assert r.state == OrderRefund.REFUND_STATE_DONE assert r.amount == Decimal('16.20') - assert r.source == OrderRefund.REFUND_SOURCE_BUYER + assert r.source == OrderRefund.REFUND_SOURCE_ADMIN assert r.payment == p1 assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists() assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()