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 %} +