From 06eddb2c6d1520301751a49f03687a059eac9782 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 18 Jan 2019 17:24:42 +0100 Subject: [PATCH] Self-service refund form (#1135) * Auto-refund * Add missing template * Notification for requested refund * Model-level tests * Add front-end tests * Default to notify --- .../migrations/0106_auto_20190118_1527.py | 31 +++ src/pretix/base/models/auth.py | 8 + src/pretix/base/models/orders.py | 112 +++++++++-- src/pretix/base/notifications.py | 6 + src/pretix/base/services/orders.py | 56 +++++- src/pretix/base/settings.py | 25 +++ src/pretix/control/forms/event.py | 33 ++++ src/pretix/control/logdisplay.py | 2 + src/pretix/control/navigation.py | 8 + .../templates/pretixcontrol/event/cancel.html | 40 ++++ .../templates/pretixcontrol/order/index.html | 2 +- src/pretix/control/urls.py | 1 + src/pretix/control/views/event.py | 45 ++++- src/pretix/control/views/orders.py | 50 +---- .../templates/pretixpresale/event/order.html | 61 +++++- .../pretixpresale/event/order_cancel.html | 27 ++- src/pretix/presale/views/order.py | 13 +- src/tests/base/test_models.py | 187 +++++++++++++++++- src/tests/base/test_orders.py | 130 +++++++++++- src/tests/control/test_permissions.py | 2 + src/tests/control/test_views.py | 1 + src/tests/presale/test_orders.py | 68 +++++++ src/tests/testdummy/payment.py | 40 ++++ src/tests/testdummy/signals.py | 4 +- 24 files changed, 857 insertions(+), 95 deletions(-) create mode 100644 src/pretix/base/migrations/0106_auto_20190118_1527.py create mode 100644 src/pretix/control/templates/pretixcontrol/event/cancel.html diff --git a/src/pretix/base/migrations/0106_auto_20190118_1527.py b/src/pretix/base/migrations/0106_auto_20190118_1527.py new file mode 100644 index 000000000..2f94cb42c --- /dev/null +++ b/src/pretix/base/migrations/0106_auto_20190118_1527.py @@ -0,0 +1,31 @@ +# Generated by Django 2.1.5 on 2019-01-18 15:27 +from django.db import migrations + + +def enable_notifications_for_everyone(apps, schema_editor): + NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting') + User = apps.get_model('pretixbase', 'User') + create = [] + for u in User.objects.iterator(): + create.append(NotificationSetting( + user=u, + action_type='pretix.event.order.refund.requested', + event=None, + method='mail', + enabled=True + )) + if len(create) > 200: + NotificationSetting.objects.bulk_create(create) + create.clear() + NotificationSetting.objects.bulk_create(create) + create.clear() + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0105_auto_20190112_1512'), + ] + + operations = [ + migrations.RunPython(enable_notifications_for_everyone, migrations.RunPython.noop) + ] diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index ffc0daec3..d2263da56 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -114,7 +114,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): def save(self, *args, **kwargs): self.email = self.email.lower() + is_new = not self.pk super().save(*args, **kwargs) + if is_new: + self.notification_settings.create( + action_type='pretix.event.order.refund.requested', + event=None, + method='mail', + enabled=True + ) def __str__(self): return self.email diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index c9bee1fd1..e54c510f2 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -28,6 +28,7 @@ from django_countries.fields import CountryField from i18nfield.strings import LazyI18nString from jsonfallback.fields import FallbackJSONField +from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.models import User from pretix.base.reldate import RelativeDateWrapper @@ -356,9 +357,106 @@ class Order(LockModel, LoggedModel): def cancel_allowed(self): return ( - self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.positions.exists() + self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and self.count_positions ) + @cached_property + def user_cancel_deadline(self): + if self.status == Order.STATUS_PAID and self.total != Decimal('0.00'): + until = self.event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper) + else: + until = self.event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper) + if until: + if self.event.has_subevents: + return min([ + until.datetime(se) + for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) + ]) + else: + return until.datetime(self.event) + + @cached_property + def user_cancel_fee(self): + fee = Decimal('0.00') + if self.event.settings.cancel_allow_user_paid_keep: + fee += self.event.settings.cancel_allow_user_paid_keep + if self.event.settings.cancel_allow_user_paid_keep_percentage: + fee += self.event.settings.cancel_allow_user_paid_keep_percentage / Decimal('100.0') * self.total + if self.event.settings.cancel_allow_user_paid_keep_fees: + fee += self.fees.filter( + fee_type__in=(OrderFee.FEE_TYPE_PAYMENT, OrderFee.FEE_TYPE_SHIPPING, OrderFee.FEE_TYPE_SERVICE) + ).aggregate( + s=Sum('value') + )['s'] or 0 + return round_decimal(fee, self.event.currency) + + @property + def user_cancel_allowed(self) -> bool: + """ + Returns whether or not this order can be canceled by the user. + """ + positions = list(self.positions.all().select_related('item')) + cancelable = all([op.item.allow_cancel for op in positions]) + if not cancelable or not positions: + return False + if self.user_cancel_deadline and now() > self.user_cancel_deadline: + return False + if self.status == Order.STATUS_PENDING: + return self.event.settings.cancel_allow_user + elif self.status == Order.STATUS_PAID: + if self.total == Decimal('0.00'): + return self.event.settings.cancel_allow_user + return self.event.settings.cancel_allow_user_paid + return False + + def propose_auto_refunds(self, amount: Decimal, payments: list=None): + # Algorithm to choose which payments are to be refunded to create the least hassle + payments = payments or self.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED) + for p in payments: + p.full_refund_possible = p.payment_provider.payment_refund_supported(p) + p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p) + p.propose_refund = Decimal('0.00') + p.available_amount = p.amount - p.refunded_amount + + unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible) + to_refund = amount + proposals = {} + + while to_refund and unused_payments: + bigger = sorted([ + p for p in unused_payments + if p.available_amount > to_refund + and p.partial_refund_possible + ], key=lambda p: p.available_amount) + same = [ + p for p in unused_payments + if p.available_amount == to_refund + and (p.full_refund_possible or p.partial_refund_possible) + ] + smaller = sorted([ + p for p in unused_payments + if p.available_amount < to_refund + and (p.full_refund_possible or p.partial_refund_possible) + ], key=lambda p: p.available_amount, reverse=True) + if same: + payment = same[0] + proposals[payment] = payment.available_amount + to_refund -= payment.available_amount + unused_payments.remove(payment) + elif bigger: + payment = bigger[0] + proposals[payment] = to_refund + to_refund -= to_refund + unused_payments.remove(payment) + elif smaller: + payment = smaller[0] + proposals[payment] = payment.available_amount + to_refund -= payment.available_amount + unused_payments.remove(payment) + else: + break + return proposals + @staticmethod def normalize_code(code): tr = str.maketrans({ @@ -411,18 +509,6 @@ class Order(LockModel, LoggedModel): return False # nothing there to modify - @property - def can_user_cancel(self) -> bool: - """ - Returns whether or not this order can be canceled by the user. - """ - positions = self.positions.all().select_related('item') - cancelable = all([op.item.allow_cancel for op in positions]) - return ( - self.status == Order.STATUS_PENDING - or (self.status == Order.STATUS_PAID and self.total == Decimal('0.00')) - ) and self.event.settings.cancel_allow_user and cancelable and self.positions.exists() - @property def is_expired_by_time(self): return ( diff --git a/src/pretix/base/notifications.py b/src/pretix/base/notifications.py index f25216cd5..0da10c3d8 100644 --- a/src/pretix/base/notifications.py +++ b/src/pretix/base/notifications.py @@ -241,6 +241,12 @@ def register_default_notification_types(sender, **kwargs): _('External refund of payment'), _('An external refund for {order.code} has occurred.') ), + ParametrizedOrderNotificationType( + sender, + 'pretix.event.order.refund.requested', + _('Refund requested'), + _('You have been requested to issue a refund for {order.code}.') + ), ActionRequiredNotificationType( sender, ) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 374bc6f76..e9a530110 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -31,7 +31,7 @@ from pretix.base.models.orders import ( ) from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.tax import TaxedPrice -from pretix.base.payment import BasePaymentProvider +from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, ) @@ -1364,11 +1364,59 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str], @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, - device=None, cancellation_fee=None): + device=None, cancellation_fee=None, try_auto_refund=False): try: try: - return _cancel_order(order, user, send_mail, api_token, device, oauth_application, - cancellation_fee) + ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application, + cancellation_fee) + if try_auto_refund: + notify_admin = False + error = False + order = Order.objects.get(pk=order) + refund_amount = order.pending_sum * -1 + proposals = order.propose_auto_refunds(refund_amount) + can_auto_refund = sum(proposals.values()) == 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, + state=OrderRefund.REFUND_STATE_CREATED, + amount=value, + provider=p.provider + ) + order.log_action('pretix.event.order.refund.created', { + 'local_id': r.local_id, + 'provider': r.provider, + }) + + 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: + notify_admin = True + + if notify_admin: + order.log_action('pretix.event.order.refund.requested') + if error: + raise OrderError( + _('There was an error while trying to send the money back to you. Please contact the event organizer for further information.') + ) + return ret except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 4151c533f..8fee4ee11 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1,6 +1,7 @@ import json from collections import OrderedDict from datetime import datetime +from decimal import Decimal from typing import Any from django.conf import settings @@ -224,6 +225,30 @@ DEFAULTS = { 'default': 'True', 'type': bool }, + 'cancel_allow_user_until': { + 'default': None, + 'type': RelativeDateWrapper, + }, + 'cancel_allow_user_paid': { + 'default': 'False', + 'type': bool, + }, + 'cancel_allow_user_paid_keep': { + 'default': '0.00', + 'type': Decimal, + }, + 'cancel_allow_user_paid_keep_fees': { + 'default': 'False', + 'type': bool, + }, + 'cancel_allow_user_paid_keep_percentage': { + 'default': '0.00', + 'type': Decimal, + }, + 'cancel_allow_user_paid_until': { + 'default': None, + 'type': RelativeDateWrapper, + }, 'contact_mail': { 'default': None, 'type': str diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 9badc03fe..75c23866a 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -435,6 +435,39 @@ class EventSettingsForm(SettingsForm): ) +class CancelSettingsForm(SettingsForm): + cancel_allow_user = forms.BooleanField( + label=_("Customers can cancel their unpaid orders"), + required=False + ) + cancel_allow_user_until = RelativeDateTimeField( + label=_("Do not allow cancellations after"), + required=False + ) + cancel_allow_user_paid = forms.BooleanField( + label=_("Customers can cancel their paid orders"), + help_text=_("Paid money will be automatically paid back if the payment method allows it. " + "Otherwise, a manual refund will be created for you to process manually."), + required=False + ) + cancel_allow_user_paid_keep = forms.DecimalField( + label=_("Keep a fixed cancellation fee"), + required=False + ) + cancel_allow_user_paid_keep_fees = forms.BooleanField( + label=_("Keep payment, shipping and service fees"), + required=False + ) + cancel_allow_user_paid_keep_percentage = forms.DecimalField( + label=_("Keep a percentual cancellation fee"), + required=False + ) + cancel_allow_user_paid_until = RelativeDateTimeField( + label=_("Do not allow cancellations after"), + required=False + ) + + class PaymentSettingsForm(SettingsForm): payment_term_days = forms.IntegerField( label=_('Payment term in days'), diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 7fb8fc778..ffc93bbce 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -212,8 +212,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.quotaexceeded': _('The order could not be marked as paid: {message}'), 'pretix.event.order.refund.created': _('Refund {local_id} has been created.'), 'pretix.event.order.refund.created.externally': _('Refund {local_id} has been created by an external entity.'), + 'pretix.event.order.refund.requested': _('The customer requested you to issue a refund.'), 'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'), 'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'), + 'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'), 'pretix.control.auth.user.created': _('The user has been created.'), 'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'), 'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 052c94778..3e532b18b 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -88,6 +88,14 @@ def get_event_navigation(request: HttpRequest): }), 'active': url.url_name == 'event.settings.invoice', }, + { + 'label': pgettext_lazy('action', 'Cancellation'), + 'url': reverse('control:event.settings.cancel', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': url.url_name == 'event.settings.cancel', + }, { 'label': _('Widget'), 'url': reverse('control:event.settings.widget', kwargs={ diff --git a/src/pretix/control/templates/pretixcontrol/event/cancel.html b/src/pretix/control/templates/pretixcontrol/event/cancel.html new file mode 100644 index 000000000..004e701b4 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -0,0 +1,40 @@ +{% extends "pretixcontrol/event/settings_base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inside %} +

{% trans "Cancellation settings" %}

+
+ {% csrf_token %} + {% bootstrap_form_errors form %} +
+ {% trans "Cancellation of unpaid or free orders" %} + {% bootstrap_field form.cancel_allow_user layout="control" %} + {% bootstrap_field form.cancel_allow_user_until layout="control" %} +
+
+ {% trans "Cancellation of paid orders" %} + {% bootstrap_field form.cancel_allow_user_paid layout="control" %} + {% bootstrap_field form.cancel_allow_user_paid_keep layout="control" %} + {% bootstrap_field form.cancel_allow_user_paid_keep_percentage layout="control" %} + {% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %} + {% bootstrap_field form.cancel_allow_user_paid_until layout="control" %} + {% if not gets_notification %} +
+ {% blocktrans trimmed %} + If a user requests cancels a paid order and the money can not be refunded automatically, e.g. + due to the selected payment method, you will need to take manual action. However, you have + currently turned off notifications for this event. + {% endblocktrans %} + + {% trans "Change notification settings" %} + +
+ {% endif %} +
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index fe2ef6f95..1d1a36e13 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -150,7 +150,7 @@
{% for i in invoices %} - {% if i.is_cancellation %}{% trans "Cancellation" %}{% else %}{% trans "Invoice" %}{% endif %} + {% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %} {{ i.number }} ({{ i.date|date:"SHORT_DATE_FORMAT" }}) {% if not i.canceled %}
str: + return reverse('control:event.settings.cancel', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug + }) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + ctx['gets_notification'] = self.request.user.notifications_send and ( + ( + self.request.user.notification_settings.filter( + event=self.request.event, + action_type='pretix.event.order.refund.requested', + enabled=True + ).exists() + ) or ( + self.request.user.notification_settings.filter( + event__isnull=True, + action_type='pretix.event.order.refund.requested', + enabled=True + ).exists() and not + self.request.user.notification_settings.filter( + event=self.request.event, + action_type='pretix.event.order.refund.requested', + enabled=False + ).exists() + ) + ) + return ctx + + class InvoicePreview(EventPermissionRequiredMixin, View): permission = 'can_change_event_settings' diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 6279b2e33..915c9f3e5 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -583,51 +583,15 @@ class OrderRefundView(OrderView): ) def choose_form(self): - payments = self.order.payments.filter( - state=OrderPayment.PAYMENT_STATE_CONFIRMED - ) - for p in payments: - p.full_refund_possible = p.payment_provider.payment_refund_supported(p) - p.partial_refund_possible = p.payment_provider.payment_partial_refund_supported(p) - p.propose_refund = Decimal('0.00') - p.available_amount = p.amount - p.refunded_amount - - unused_payments = set(p for p in payments if p.full_refund_possible or p.partial_refund_possible) - - # Algorithm to choose which payments are to be refunded to create the least hassle + payments = list(self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_CONFIRMED)) if self.start_form.cleaned_data.get('mode') == 'full': - to_refund = full_refund = self.order.payment_refund_sum + full_refund = self.order.payment_refund_sum else: - to_refund = full_refund = self.start_form.cleaned_data.get('partial_amount') - - while to_refund and unused_payments: - bigger = sorted([p for p in unused_payments if p.available_amount > to_refund], - key=lambda p: p.available_amount) - same = [p for p in unused_payments if p.available_amount == to_refund] - smaller = sorted([p for p in unused_payments if p.available_amount < to_refund], - key=lambda p: p.available_amount, - reverse=True) - if same: - for payment in same: - if payment.full_refund_possible or payment.partial_refund_possible: - payment.propose_refund = payment.available_amount - to_refund -= payment.available_amount - unused_payments.remove(payment) - break - elif bigger: - for payment in bigger: - if payment.partial_refund_possible: - payment.propose_refund = to_refund - to_refund -= to_refund - unused_payments.remove(payment) - break - elif smaller: - for payment in smaller: - if payment.full_refund_possible or payment.partial_refund_possible: - payment.propose_refund = payment.available_amount - to_refund -= payment.available_amount - unused_payments.remove(payment) - break + full_refund = self.start_form.cleaned_data.get('partial_amount') + proposals = self.order.propose_auto_refunds(full_refund, payments=payments) + to_refund = full_refund - sum(proposals.values()) + for p in payments: + p.propose_refund = proposals.get(p, 0) if 'perform' in self.request.POST: refund_selected = Decimal('0.00') diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index e9ef27316..7a04f2289 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -168,7 +168,7 @@ {% for i in invoices %}
  • - {% if i.is_cancellation %}{% trans "Cancellation" %}{% else %}{% trans "Invoice" %}{% endif %} + {% if i.is_cancellation %}{% trans "Cancellation" context "invoice" %}{% else %}{% trans "Invoice" %}{% endif %} {{ i.number }} ({{ i.date|date:"SHORT_DATE_FORMAT" }})
  • {% endfor %} @@ -245,16 +245,57 @@ {% endif %}
    - {% if order.can_user_cancel %} -
    - {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order_cancel.html b/src/pretix/presale/templates/pretixpresale/event/order_cancel.html index 940c6835f..b110891b3 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order_cancel.html +++ b/src/pretix/presale/templates/pretixpresale/event/order_cancel.html @@ -1,5 +1,6 @@ {% extends "pretixpresale/event/base.html" %} {% load i18n %} +{% load money %} {% load eventurl %} {% block title %}{% trans "Cancel order" %}{% endblock %} {% block content %} @@ -8,9 +9,29 @@ Cancel order: {{ code }} {% endblocktrans %} -

    {% blocktrans trimmed %} - Do you really want to cancel this order? You cannot revert this action. - {% endblocktrans %}

    +

    + {% blocktrans trimmed %} + Do you really want to cancel this order? You cannot revert this action. + {% endblocktrans %} + {% trans "This will invalidate all of your tickets." %} +

    + {% if can_auto_refund %} +

    + + {% blocktrans trimmed with amount=refund_amount|money:request.event.currency %} + The refund amount of {{ 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 amount=refund_amount|money:request.event.currency %} + With to the payment method you used, the refund amount of {{ 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 %} {% csrf_token %} diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 359fd1bf7..c0c040e8b 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -566,7 +566,7 @@ class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView): self.kwargs = kwargs if not self.order: raise Http404(_('Unknown order code or not authorized to access this order.')) - if not self.order.can_user_cancel: + if not self.order.user_cancel_allowed: messages.error(request, _('You cannot cancel this order.')) return redirect(self.get_order_url()) return super().dispatch(request, *args, **kwargs) @@ -577,6 +577,10 @@ class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['order'] = self.order + refund_amount = self.order.total - self.order.user_cancel_fee + proposals = self.order.propose_auto_refunds(refund_amount) + ctx['refund_amount'] = refund_amount + ctx['can_auto_refund'] = sum(proposals.values()) == refund_amount return ctx @@ -594,10 +598,13 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View): def post(self, request, *args, **kwargs): if not self.order: raise Http404(_('Unknown order code or not authorized to access this order.')) - if not self.order.can_user_cancel: + if not self.order.user_cancel_allowed: messages.error(request, _('You cannot cancel this order.')) return redirect(self.get_order_url()) - return self.do(self.order.pk) + fee = None + if self.order.status == Order.STATUS_PAID and self.order.total != Decimal('0.00'): + fee = self.order.user_cancel_fee + return self.do(self.order.pk, cancellation_fee=fee, try_auto_refund=True) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index d5f618c1e..e75be73d6 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -16,8 +16,8 @@ from django.utils.timezone import now from pretix.base.i18n import language from pretix.base.models import ( CachedFile, CartPosition, CheckinList, Event, Item, ItemCategory, - ItemVariation, Order, OrderPayment, OrderPosition, OrderRefund, Organizer, - Question, Quota, User, Voucher, WaitingListEntry, + ItemVariation, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, + Organizer, Question, Quota, User, Voucher, WaitingListEntry, ) from pretix.base.models.event import SubEvent from pretix.base.models.items import SubEventItem, SubEventItemVariation @@ -45,7 +45,7 @@ class BaseQuotaTestCase(TestCase): o = Organizer.objects.create(name='Dummy', slug='dummy') self.event = Event.objects.create( organizer=o, name='Dummy', slug='dummy', - date_from=now(), + date_from=now(), plugins='tests.testdummy' ) self.quota = Quota.objects.create(name="Test", size=2, event=self.event) self.item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, @@ -600,7 +600,7 @@ class OrderTestCase(BaseQuotaTestCase): self.order = Order.objects.create( status=Order.STATUS_PENDING, event=self.event, datetime=now() - timedelta(days=5), - expires=now() + timedelta(days=5), total=46 + expires=now() + timedelta(days=5), total=46, ) self.quota.items.add(self.item1) self.op1 = OrderPosition.objects.create(order=self.order, item=self.item1, @@ -845,7 +845,25 @@ class OrderTestCase(BaseQuotaTestCase): admission=True, allow_cancel=True) OrderPosition.objects.create(order=self.order, item=item1, variation=None, price=23) - assert self.order.can_user_cancel + assert self.order.user_cancel_allowed + self.event.settings.cancel_allow_user = False + assert not self.order.user_cancel_allowed + + def test_can_cancel_order_free(self): + self.order.status = Order.STATUS_PAID + self.order.total = Decimal('0.00') + self.order.save() + assert self.order.user_cancel_allowed + self.event.settings.cancel_allow_user = False + assert not self.order.user_cancel_allowed + + def test_can_cancel_order_paid(self): + self.order.status = Order.STATUS_PAID + self.order.save() + assert not self.order.user_cancel_allowed + self.event.settings.cancel_allow_user = False + self.event.settings.cancel_allow_user_paid = True + assert self.order.user_cancel_allowed def test_can_cancel_order_multiple(self): item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, @@ -856,14 +874,14 @@ class OrderTestCase(BaseQuotaTestCase): variation=None, price=23) OrderPosition.objects.create(order=self.order, item=item2, variation=None, price=23) - assert self.order.can_user_cancel + assert self.order.user_cancel_allowed def test_can_not_cancel_order(self): item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, admission=True, allow_cancel=False) OrderPosition.objects.create(order=self.order, item=item1, variation=None, price=23) - assert self.order.can_user_cancel is False + assert self.order.user_cancel_allowed is False def test_can_not_cancel_order_multiple(self): item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, @@ -874,7 +892,7 @@ class OrderTestCase(BaseQuotaTestCase): variation=None, price=23) OrderPosition.objects.create(order=self.order, item=item2, variation=None, price=23) - assert self.order.can_user_cancel is False + assert self.order.user_cancel_allowed is False def test_can_not_cancel_order_multiple_mixed(self): item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, @@ -885,7 +903,7 @@ class OrderTestCase(BaseQuotaTestCase): variation=None, price=23) OrderPosition.objects.create(order=self.order, item=item2, variation=None, price=23) - assert self.order.can_user_cancel is False + assert self.order.user_cancel_allowed is False def test_no_duplicate_position_secret(self): item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, @@ -895,7 +913,119 @@ class OrderTestCase(BaseQuotaTestCase): p2 = OrderPosition.objects.create(order=self.order, item=item1, secret='ABC', variation=None, price=23) assert p1.secret != p2.secret - assert self.order.can_user_cancel is False + assert self.order.user_cancel_allowed is False + + def test_user_cancel_absolute_deadline_unpaid_no_subevents(self): + assert self.order.user_cancel_deadline is None + self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( + now() + timedelta(days=1) + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline > now() + assert self.order.user_cancel_allowed + self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( + now() - timedelta(days=1) + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline < now() + assert not self.order.user_cancel_allowed + + def test_user_cancel_relative_deadline_unpaid_no_subevents(self): + self.event.date_from = now() + timedelta(days=3) + self.event.save() + + assert self.order.user_cancel_deadline is None + self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( + RelativeDate(days_before=2, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline > now() + assert self.order.user_cancel_allowed + self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( + RelativeDate(days_before=4, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline < now() + assert not self.order.user_cancel_allowed + + def test_user_cancel_absolute_deadline_paid_no_subevents(self): + self.order.status = Order.STATUS_PAID + self.order.save() + self.event.settings.cancel_allow_user_paid = True + assert self.order.user_cancel_deadline is None + self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( + now() + timedelta(days=1) + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_allowed + assert self.order.user_cancel_deadline > now() + self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( + now() - timedelta(days=1) + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline < now() + assert not self.order.user_cancel_allowed + + def test_user_cancel_relative_deadline_paid_no_subevents(self): + self.order.status = Order.STATUS_PAID + self.order.save() + self.event.date_from = now() + timedelta(days=3) + self.event.save() + self.event.settings.cancel_allow_user_paid = True + + assert self.order.user_cancel_deadline is None + self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( + RelativeDate(days_before=2, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline > now() + assert self.order.user_cancel_allowed + self.event.settings.set('cancel_allow_user_paid_until', RelativeDateWrapper( + RelativeDate(days_before=4, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline < now() + assert not self.order.user_cancel_allowed + + def test_user_cancel_relative_deadline_to_subevents(self): + self.event.date_from = now() + timedelta(days=3) + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) + se2 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=1)) + self.op1.subevent = se1 + self.op1.save() + self.op2.subevent = se2 + self.op2.save() + + self.event.settings.set('cancel_allow_user_until', RelativeDateWrapper( + RelativeDate(days_before=2, time=datetime.time(14, 0, 0), base_date_name='date_from') + )) + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline < now() + self.op2.subevent = se1 + self.op2.save() + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_deadline > now() + + def test_user_cancel_fee(self): + self.order.fees.create(fee_type=OrderFee.FEE_TYPE_SHIPPING, value=Decimal('2.00')) + self.order.total = 48 + self.order.save() + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_fee == Decimal('0.00') + + self.event.settings.cancel_allow_user_paid_keep = Decimal('2.50') + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_fee == Decimal('2.50') + + self.event.settings.cancel_allow_user_paid_keep_percentage = Decimal('10.0') + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_fee == Decimal('7.30') + + self.event.settings.cancel_allow_user_paid_keep_fees = True + self.order = Order.objects.get(pk=self.order.pk) + assert self.order.user_cancel_fee == Decimal('9.30') def test_paid_order_underpaid(self): self.order.status = Order.STATUS_PAID @@ -1044,6 +1174,43 @@ class OrderTestCase(BaseQuotaTestCase): assert self.order.positions.count() == 1 assert self.order.all_positions.count() == 2 + def test_propose_auto_refunds(self): + p1 = self.order.payments.create( + amount=Decimal('23.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='testdummy_fullrefund' + ) + p2 = self.order.payments.create( + amount=Decimal('10.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='testdummy_partialrefund' + ) + self.order.payments.create( + amount=Decimal('13.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='testdummy' + ) + assert self.order.propose_auto_refunds(Decimal('23.00')) == { + p1: Decimal('23.00') + } + assert self.order.propose_auto_refunds(Decimal('10.00')) == { + p2: Decimal('10.00') + } + assert self.order.propose_auto_refunds(Decimal('5.00')) == { + p2: Decimal('5.00') + } + assert self.order.propose_auto_refunds(Decimal('20.00')) == { + p2: Decimal('10.00') + } + assert self.order.propose_auto_refunds(Decimal('25.00')) == { + p1: Decimal('23.00'), + p2: Decimal('2.00'), + } + assert self.order.propose_auto_refunds(Decimal('35.00')) == { + p1: Decimal('23.00'), + p2: Decimal('10.00'), + } + class ItemCategoryTest(TestCase): """ diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 41a07a80a..21dd5d24f 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -18,8 +18,8 @@ from pretix.base.payment import FreeOrderProvider from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.invoices import generate_invoice from pretix.base.services.orders import ( - OrderChangeManager, OrderError, _create_order, approve_order, deny_order, - expire_orders, send_download_reminders, send_expiry_warnings, + OrderChangeManager, OrderError, _create_order, approve_order, cancel_order, + deny_order, expire_orders, send_download_reminders, send_expiry_warnings, ) @@ -401,6 +401,132 @@ class DownloadReminderTests(TestCase): assert len(djmail.outbox) == 0 +class OrderCancelTests(TestCase): + def setUp(self): + super().setUp() + o = Organizer.objects.create(name='Dummy', slug='dummy') + self.event = Event.objects.create(organizer=o, name='Dummy', slug='dummy', date_from=now(), + plugins='tests.testdummy') + self.order = Order.objects.create( + code='FOO', event=self.event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, locale='en', + datetime=now(), expires=now() + timedelta(days=10), + total=Decimal('46.00'), + ) + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', + default_price=Decimal('23.00'), admission=True) + self.op1 = OrderPosition.objects.create( + order=self.order, item=self.ticket, variation=None, + price=Decimal("23.00"), attendee_name_parts={'full_name': "Peter"}, positionid=1 + ) + self.op2 = OrderPosition.objects.create( + order=self.order, item=self.ticket, variation=None, + price=Decimal("23.00"), attendee_name_parts={'full_name': "Dieter"}, positionid=2 + ) + generate_invoice(self.order) + djmail.outbox = [] + + def test_cancel_canceled(self): + self.order.status = Order.STATUS_CANCELED + self.order.save() + with pytest.raises(OrderError): + cancel_order(self.order.pk) + + def test_cancel_send_mail(self): + cancel_order(self.order.pk, send_mail=True) + assert len(djmail.outbox) == 1 + + def test_cancel_send_no_mail(self): + cancel_order(self.order.pk, send_mail=False) + assert len(djmail.outbox) == 0 + + def test_cancel_unpaid(self): + cancel_order(self.order.pk) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_CANCELED + assert self.order.all_logentries().last().action_type == 'pretix.event.order.canceled' + assert self.order.invoices.count() == 2 + + def test_cancel_unpaid_with_voucher(self): + self.op1.voucher = self.event.vouchers.create(item=self.ticket, redeemed=1) + self.op1.save() + cancel_order(self.order.pk) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_CANCELED + assert self.order.all_logentries().last().action_type == 'pretix.event.order.canceled' + self.op1.voucher.refresh_from_db() + assert self.op1.voucher.redeemed == 0 + assert self.order.invoices.count() == 2 + + def test_cancel_paid(self): + self.order.status = Order.STATUS_PAID + self.order.save() + cancel_order(self.order.pk) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_CANCELED + assert self.order.all_logentries().last().action_type == 'pretix.event.order.canceled' + assert self.order.invoices.count() == 2 + + def test_cancel_paid_with_too_high_fee(self): + self.order.status = Order.STATUS_PAID + self.order.save() + self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5) + with pytest.raises(OrderError): + cancel_order(self.order.pk, cancellation_fee=50) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PAID + assert self.order.total == 46 + + def test_cancel_paid_with_fee(self): + f = self.order.fees.create(fee_type=OrderFee.FEE_TYPE_SHIPPING, value=2.5) + self.order.status = Order.STATUS_PAID + self.order.total = 48.5 + self.order.save() + self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5) + self.op1.voucher = self.event.vouchers.create(item=self.ticket, redeemed=1) + self.op1.save() + cancel_order(self.order.pk, cancellation_fee=2.5) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PAID + self.op1.refresh_from_db() + assert self.op1.canceled + self.op2.refresh_from_db() + assert self.op2.canceled + f.refresh_from_db() + assert f.canceled + assert self.order.total == 2.5 + assert self.order.all_logentries().last().action_type == 'pretix.event.order.canceled' + self.op1.voucher.refresh_from_db() + assert self.op1.voucher.redeemed == 0 + assert self.order.invoices.count() == 3 + assert not self.order.invoices.last().is_cancellation + + def test_auto_refund_possible(self): + p1 = self.order.payments.create( + amount=Decimal('46.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='testdummy_partialrefund' + ) + cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + r = self.order.refunds.get() + assert r.state == OrderRefund.REFUND_STATE_DONE + assert r.amount == Decimal('44.00') + assert r.source == OrderRefund.REFUND_SOURCE_BUYER + 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() + + def test_auto_refund_impossible(self): + self.order.payments.create( + amount=Decimal('46.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='testdummy_fullrefund' + ) + cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + assert not self.order.refunds.exists() + assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() + + class OrderChangeManagerTests(TestCase): def setUp(self): super().setUp() diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 126a8db31..3103b6e5c 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -53,6 +53,7 @@ event_urls = [ "settings/payment", "settings/tickets", "settings/email", + "settings/cancel", "settings/invoice", "settings/invoice/preview", "settings/display", @@ -222,6 +223,7 @@ event_permission_urls = [ ("can_change_event_settings", "settings/tickets", 200), ("can_change_event_settings", "settings/email", 200), ("can_change_event_settings", "settings/display", 200), + ("can_change_event_settings", "settings/cancel", 200), ("can_change_event_settings", "settings/invoice", 200), ("can_change_event_settings", "settings/widget", 200), ("can_change_event_settings", "settings/invoice/preview", 200), diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index ee22de9d3..4cfec7fe9 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -114,6 +114,7 @@ def logged_in_client(client, event): ('/control/event/{orga}/{event}/settings/widget', 200), # ('/control/event/{orga}/{event}/settings/tickets/preview/(?P[^/]+)', 200), ('/control/event/{orga}/{event}/settings/email', 200), + ('/control/event/{orga}/{event}/settings/cancel', 200), ('/control/event/{orga}/{event}/settings/invoice', 200), ('/control/event/{orga}/{event}/settings/invoice/preview', 200), ('/control/event/{orga}/{event}/settings/display', 200), diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index d3de1646f..5b72424e9 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -279,6 +279,74 @@ class OrdersTest(TestCase): self.order.refresh_from_db() assert self.order.status == Order.STATUS_CANCELED + def test_orders_cancel_paid(self): + self.event.settings.cancel_allow_user_paid = True + 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 + response = self.client.post( + '/%s/%s/order/%s/%s/cancel/do' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), { + }, 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) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_CANCELED + + def test_orders_cancel_paid_fee_autorefund(self): + self.order.status = Order.STATUS_PAID + self.order.save() + 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') + 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 'alert-warning' not 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), { + }, 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) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PAID + assert self.order.total == Decimal('3.00') + assert self.order.refunds.count() == 1 + + def test_orders_cancel_paid_fee_no_autorefund(self): + self.order.status = Order.STATUS_PAID + self.order.save() + self.order.payments.create(provider='testdummy', 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') + response = self.client.get( + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'cancellation fee of €3.00' in response.rendered_content + 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 'alert-warning' in response.rendered_content + assert '20.00' 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), { + }, 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) + self.order.refresh_from_db() + assert self.order.status == Order.STATUS_PAID + assert self.order.total == Decimal('3.00') + assert self.order.refunds.count() == 0 + def test_orders_cancel_forbidden(self): self.event.settings.set('cancel_allow_user', False) self.client.post( diff --git a/src/tests/testdummy/payment.py b/src/tests/testdummy/payment.py index b0bdfcda3..93c6eb7d2 100644 --- a/src/tests/testdummy/payment.py +++ b/src/tests/testdummy/payment.py @@ -2,6 +2,7 @@ import logging from django.http import HttpRequest +from pretix.base.models import OrderPayment, OrderRefund from pretix.base.payment import BasePaymentProvider logger = logging.getLogger('tests.testdummy.ticketoutput') @@ -17,3 +18,42 @@ class DummyPaymentProvider(BasePaymentProvider): def checkout_confirm_render(self, request) -> str: pass + + +class DummyFullRefundablePaymentProvider(BasePaymentProvider): + identifier = 'testdummy_fullrefund' + verbose_name = 'Test dummy' + abort_pending_allowed = False + + def execute_refund(self, refund: OrderRefund): + refund.done() + + def payment_is_valid_session(self, request: HttpRequest) -> bool: + pass + + def checkout_confirm_render(self, request) -> str: + pass + + def payment_refund_supported(self, payment: OrderPayment) -> bool: + return True + + +class DummyPartialRefundablePaymentProvider(BasePaymentProvider): + identifier = 'testdummy_partialrefund' + verbose_name = 'Test dummy' + abort_pending_allowed = False + + def execute_refund(self, refund: OrderRefund): + refund.done() + + def payment_is_valid_session(self, request: HttpRequest) -> bool: + pass + + def checkout_confirm_render(self, request) -> str: + pass + + def payment_refund_supported(self, payment: OrderPayment) -> bool: + return True + + def payment_partial_refund_supported(self, payment: OrderPayment) -> bool: + return True diff --git a/src/tests/testdummy/signals.py b/src/tests/testdummy/signals.py index 6d83956a3..0554a345b 100644 --- a/src/tests/testdummy/signals.py +++ b/src/tests/testdummy/signals.py @@ -13,5 +13,5 @@ def register_ticket_outputs(sender, **kwargs): @receiver(register_payment_providers, dispatch_uid="payment_dummy") def register_payment_provider(sender, **kwargs): - from .payment import DummyPaymentProvider - return DummyPaymentProvider + from .payment import DummyPaymentProvider, DummyFullRefundablePaymentProvider, DummyPartialRefundablePaymentProvider + return [DummyPaymentProvider, DummyFullRefundablePaymentProvider, DummyPartialRefundablePaymentProvider]