From 3eafec9d6e2d681d712f25e5281f0e9cae6d351d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 25 Mar 2020 11:41:40 +0100 Subject: [PATCH] Allow customers to choose to receive their refund as a gift card (#1626) * Minor text adjustments * Allow users to receive their cancellation as a gift card --- src/pretix/api/serializers/event.py | 1 + src/pretix/base/payment.py | 1 + src/pretix/base/services/orders.py | 53 ++++++++++++-- src/pretix/base/settings.py | 23 ++++++ src/pretix/control/forms/event.py | 1 + .../templates/pretixcontrol/event/cancel.html | 1 + .../templates/pretixpresale/event/order.html | 42 +++++++++-- .../pretixpresale/event/order_cancel.html | 70 +++++++++++++++---- src/pretix/presale/views/order.py | 8 ++- src/tests/presale/test_orders.py | 62 ++++++++++++++++ 10 files changed, 233 insertions(+), 29 deletions(-) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index c9bc170dd9..ac766fd640 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -612,6 +612,7 @@ class EventSettingsSerializer(serializers.Serializer): 'cancel_allow_user_paid_keep_fees', 'cancel_allow_user_paid_keep_percentage', 'cancel_allow_user_paid_adjust_fees', + 'cancel_allow_user_paid_refund_as_giftcard', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 1ffb0171f0..9764475f33 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1210,6 +1210,7 @@ class GiftCardPayment(BasePaymentProvider): ) refund.info_data = { 'gift_card': gc.pk, + 'gift_card_code': gc.secret, 'transaction_id': trans.pk, } refund.done() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e0ab3579ce..d9114108f0 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1888,7 +1888,8 @@ def perform_order(self, event: Event, payment_provider: str, positions: List[str raise OrderError(str(error_messages['busy'])) -def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER): +def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=OrderRefund.REFUND_SOURCE_BUYER, + refund_as_giftcard=False): notify_admin = False error = False if isinstance(order, int): @@ -1897,9 +1898,49 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord if refund_amount <= Decimal('0.00'): return - proposals = order.propose_auto_refunds(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 refund_as_giftcard: + proposals = {} + can_auto_refund = True + can_auto_refund_sum = refund_amount + with transaction.atomic(): + giftcard = order.event.organizer.issued_gift_cards.create( + currency=order.event.currency, + testmode=order.testmode + ) + giftcard.log_action('pretix.giftcards.created', data={}) + r = order.refunds.create( + order=order, + payment=None, + source=source, + state=OrderRefund.REFUND_STATE_CREATED, + execution_date=now(), + amount=can_auto_refund_sum, + provider='giftcard', + info=json.dumps({ + 'gift_card': giftcard.pk + }) + ) + try: + r.payment_provider.execute_refund(r) + except PaymentException as e: + with transaction.atomic(): + r.state = OrderRefund.REFUND_STATE_FAILED + r.save() + order.log_action('pretix.event.order.refund.failed', { + 'local_id': r.local_id, + 'provider': r.provider, + 'error': str(e) + }) + error = True + notify_admin = True + else: + if r.state != OrderRefund.REFUND_STATE_DONE: + notify_admin = True + + else: + proposals = order.propose_auto_refunds(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(): @@ -1961,13 +2002,13 @@ def _try_auto_refund(order, manual_refund=False, allow_partial=False, source=Ord @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @scopes_disabled() def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, - device=None, cancellation_fee=None, try_auto_refund=False): + device=None, cancellation_fee=None, try_auto_refund=False, refund_as_giftcard=False): try: try: ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application, cancellation_fee) if try_auto_refund: - _try_auto_refund(order) + _try_auto_refund(order, refund_as_giftcard=refund_as_giftcard) return ret except LockTimeoutException: self.retry() diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 506888a077..6a27202673 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -917,6 +917,29 @@ DEFAULTS = { help_text=_("With this option enabled, your customers can choose to get a smaller refund to support you.") ) }, + 'cancel_allow_user_paid_refund_as_giftcard': { + 'default': 'off', + 'type': str, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=[ + ('off', _('All refunds are issued to the original payment method')), + ('option', _('Customers can choose between a gift card and a refund to their payment method')), + ('force', _('All refunds are issued as gift cards')), + ], + ), + 'form_class': forms.ChoiceField, + 'form_kwargs': dict( + label=_('Refund method'), + choices=[ + ('off', _('All refunds are issued to the original payment method')), + ('option', _('Customers can choose between a gift card and a refund to their payment method')), + ('force', _('All refunds are issued as gift cards')), + ], + widget=forms.RadioSelect, + # When adding a new ordering, remember to also define it in the event model + ) + }, 'cancel_allow_user_paid_until': { 'default': None, 'type': RelativeDateWrapper, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 0ca97b92c9..5fdf5770c8 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -566,6 +566,7 @@ class CancelSettingsForm(SettingsForm): 'cancel_allow_user_paid_keep_fees', 'cancel_allow_user_paid_keep_percentage', 'cancel_allow_user_paid_adjust_fees', + 'cancel_allow_user_paid_refund_as_giftcard', ] diff --git a/src/pretix/control/templates/pretixcontrol/event/cancel.html b/src/pretix/control/templates/pretixcontrol/event/cancel.html index a4eb9e9b75..1550eafe7d 100644 --- a/src/pretix/control/templates/pretixcontrol/event/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -20,6 +20,7 @@ {% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_until layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_adjust_fees layout="control" %} + {% bootstrap_field form.cancel_allow_user_paid_refund_as_giftcard layout="control" %} {% if not gets_notification %}
{% blocktrans trimmed %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index 4a887a7804..b1530bad25 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -102,10 +102,17 @@ A refund of {{ amount }} will be sent out to you soon, please be patient. {% endblocktrans %} {% elif r.state == "done" %} - {% blocktrans trimmed with amount=r.amount|money:request.event.currency %} - A refund of {{ amount }} has been sent to you. Depending on the payment method, please allow for up to 14 days until it shows up - on your statement. - {% endblocktrans %} + {% if r.provider == "giftcard" and "gift_card_code" in r.info_data %} + {% blocktrans trimmed with amount=r.amount|money:request.event.currency code=r.info_data.gift_card_code %} + We've issued your refund of {{ amount }} as a gift card. On your next purchase with + us, you can use the gift card code {{ code }} during payment. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with amount=r.amount|money:request.event.currency %} + A refund of {{ amount }} has been sent to you. Depending on the payment method, please allow for up to 14 days until it shows up + on your statement. + {% endblocktrans %} + {% endif %} {% endif %} {% if not forloop.last %}
{% endif %} {% endfor %} @@ -271,19 +278,40 @@
{% if order.status == "p" and order.total != 0 %} - {% if order.user_cancel_fee %} + {% if order.user_cancel_fee >= order.total %} +

+ {% blocktrans trimmed %} + You can cancel this order, but you will not receive a refund. + {% endblocktrans %} + {% trans "This will invalidate all tickets in this order." %} +

+ {% elif order.user_cancel_fee %}

{% blocktrans trimmed with fee=order.user_cancel_fee|money:request.event.currency %} You can cancel this order. In this case, a cancellation fee of {{ fee }} - will be kept and you will receive a refund of the remainder to your original payment method. + will be kept and you will receive a refund of the remainder. {% endblocktrans %} + {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} + {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} + {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} + {% trans "The refund can be issued to your original payment method or as a gift card." %} + {% else %} + {% trans "The refund will be issued to your original payment method." %} + {% endif %} {% trans "This will invalidate all tickets in this order." %}

{% else %}

{% blocktrans trimmed %} - You can cancel this order and receive a full refund to your original payment method. + You can cancel this order and receive a full refund. {% endblocktrans %} + {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} + {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} + {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} + {% trans "The refund can be issued to your original payment method or as a gift card." %} + {% else %} + {% trans "The refund will be issued to your original payment method." %} + {% endif %} {% trans "This will invalidate all tickets in this order." %}

{% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_cancel.html b/src/pretix/presale/templates/pretixpresale/event/order_cancel.html index d99ba098ad..e9fd9f7c60 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_cancel.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_cancel.html @@ -10,12 +10,15 @@ Cancel order: {{ code }} {% endblocktrans %} -
+

{% blocktrans trimmed %} - Do you really want to cancel this order? You cannot revert this action. + If you cancel this order, all tickets will be invalidated and you can no longer use them. You cannot + revert this action. {% endblocktrans %} - {% trans "This will invalidate all tickets in this order." %}

{% if request.event.settings.cancel_allow_user_paid_adjust_fees %} @@ -57,25 +60,62 @@ {% endif %} {% if refund_amount %} - {% if can_auto_refund %} -

- + {% if request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "force" %} + + {% trans "The refund will be issued in form of a gift card that you can use for further purchases." %} + + {% elif request.event.settings.cancel_allow_user_paid_refund_as_giftcard == "option" %} +

+ +
+
+ +
+ {% if can_auto_refund %} +

{% blocktrans trimmed %} The refund amount will automatically be sent back to your original payment method. Depending on the payment method, please allow for up to two weeks before this appears on your statement. {% endblocktrans %} - -

+

+ {% else %} +

+ {% blocktrans trimmed %} + With the payment method you used, the refund amount can not be sent back to you + automatically. Instead, the event organizer will need to initiate the transfer + manually. Please be patient as this might take a bit longer. + {% endblocktrans %} +

+ {% endif %} {% else %} -

- {% blocktrans trimmed %} - With the payment method you used, the refund amount can not be sent back to you - automatically. Instead, the event organizer will need to initiate the transfer - manually. Please be patient as this might take a bit longer. - {% endblocktrans %} -

+ {% if can_auto_refund %} +

+ + {% blocktrans trimmed %} + The refund amount will automatically be sent back to your original payment method. Depending + on the payment method, please allow for up to two weeks before this appears on your + statement. + {% endblocktrans %} + +

+ {% else %} +

+ {% blocktrans trimmed %} + With the payment method you used, the refund amount can not be sent back to you + automatically. Instead, the event organizer will need to initiate the transfer + manually. Please be patient as this might take a bit longer. + {% endblocktrans %} +

+ {% endif %} {% endif %} + {% endif %} {% csrf_token %} diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index ce976e8f26..d0caf0cb89 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -759,7 +759,13 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View): else: messages.error(request, _('You chose an invalid cancellation fee.')) return redirect(self.get_order_url()) - return self.do(self.order.pk, cancellation_fee=fee, try_auto_refund=True) + giftcard = ( + self.request.event.settings.cancel_allow_user_paid_refund_as_giftcard == 'force' or ( + self.request.event.settings.cancel_allow_user_paid_refund_as_giftcard == 'option' and + self.request.POST.get('giftcard') == 'true' + ) + ) + return self.do(self.order.pk, cancellation_fee=fee, try_auto_refund=True, refund_as_giftcard=giftcard) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index d614fb9598..31d64389f3 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -399,6 +399,68 @@ class OrdersTest(BaseOrdersTest): self.order.refresh_from_db() assert self.order.status == Order.STATUS_CANCELED + def test_orders_cancel_paid_fee_autorefund_gift_card_optional(self): + self.order.status = Order.STATUS_PAID + self.order.save() + with scopes_disabled(): + self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED) + self.event.settings.cancel_allow_user_paid = True + self.event.settings.cancel_allow_user_paid_keep = Decimal('3.00') + self.event.settings.cancel_allow_user_paid_refund_as_giftcard = 'option' + response = self.client.get( + '/%s/%s/order/%s/%s/cancel' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'manually' not in response.rendered_content + assert "gift card" in response.rendered_content + response = self.client.post( + '/%s/%s/order/%s/%s/cancel/do' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + 'giftcard': 'true' + }, follow=True) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + assert "gift card" in response.rendered_content + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PAID + assert self.order.total == Decimal('3.00') + with scopes_disabled(): + r = self.order.refunds.get() + assert r.provider == "giftcard" + assert r.amount == Decimal('20.00') + + def test_orders_cancel_paid_fee_autorefund_gift_card_force(self): + self.order.status = Order.STATUS_PAID + self.order.save() + with scopes_disabled(): + self.order.payments.create(provider='testdummy_partialrefund', amount=self.order.total, state=OrderPayment.PAYMENT_STATE_CONFIRMED) + self.event.settings.cancel_allow_user_paid = True + self.event.settings.cancel_allow_user_paid_keep = Decimal('3.00') + self.event.settings.cancel_allow_user_paid_refund_as_giftcard = 'force' + response = self.client.get( + '/%s/%s/order/%s/%s/cancel' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'manually' not in response.rendered_content + assert "gift card" in response.rendered_content + response = self.client.post( + '/%s/%s/order/%s/%s/cancel/do' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + 'giftcard': 'false' + }, follow=True) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + assert "gift card" in response.rendered_content + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PAID + assert self.order.total == Decimal('3.00') + with scopes_disabled(): + r = self.order.refunds.get() + assert r.provider == "giftcard" + assert r.amount == Decimal('20.00') + def test_orders_cancel_paid_fee_autorefund(self): self.order.status = Order.STATUS_PAID self.order.save()