mirror of
https://github.com/pretix/pretix.git
synced 2026-05-08 15:44:02 +00:00
Event cancellation: Add safety and security checks (#5565)
* Event cancellation: Add safety and security checks When cancelling an event, a large sum of money might be refunded instantly. This PR adds safety features around this by - doing a dry-run first that shows a preview of the expected refund sum - sending a confirmation mode via email for any automatic refunds of more than 100 currency units - keeping a more detailed log of the settings this was executed with * Update src/pretix/control/views/orders.py Co-authored-by: luelista <weller@rami.io> --------- Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
@@ -24,6 +24,7 @@ from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
@@ -41,6 +42,7 @@ from pretix.base.services.orders import (
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.services.tax import split_fee_for_taxes
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.format import format_map
|
||||
@@ -112,7 +114,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
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, refund_as_giftcard: bool=False, giftcard_expires=None, giftcard_conditions=None,
|
||||
subevents_from: str=None, subevents_to: str=None):
|
||||
subevents_from: str=None, subevents_to: str=None, dry_run=False):
|
||||
send_subject = LazyI18nString(send_subject)
|
||||
send_message = LazyI18nString(send_message)
|
||||
send_waitinglist_subject = LazyI18nString(send_waitinglist_subject)
|
||||
@@ -161,32 +163,72 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
has_subevent=True, has_other_subevent=False, has_blocked=False
|
||||
)
|
||||
|
||||
for se in subevents:
|
||||
se.log_action(
|
||||
'pretix.subevent.canceled', user=user,
|
||||
)
|
||||
se.active = False
|
||||
se.save(update_fields=['active'])
|
||||
se.log_action(
|
||||
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
if not dry_run:
|
||||
for se in subevents:
|
||||
se.log_action(
|
||||
'pretix.subevent.canceled', user=user,
|
||||
data={
|
||||
"auto_refund": auto_refund,
|
||||
"keep_fee_fixed": keep_fee_fixed,
|
||||
"keep_fee_per_ticket": keep_fee_per_ticket,
|
||||
"keep_fee_percentage": keep_fee_percentage,
|
||||
"keep_fees": keep_fees,
|
||||
"manual_refund": manual_refund,
|
||||
"send": send,
|
||||
"send_subject": send_subject,
|
||||
"send_message": send_message,
|
||||
"send_waitinglist": send_waitinglist,
|
||||
"send_waitinglist_subject": send_waitinglist_subject,
|
||||
"send_waitinglist_message": send_waitinglist_message,
|
||||
"refund_as_giftcard": refund_as_giftcard,
|
||||
"giftcard_expires": str(giftcard_expires),
|
||||
"giftcard_conditions": giftcard_conditions,
|
||||
}
|
||||
)
|
||||
se.active = False
|
||||
se.save(update_fields=['active'])
|
||||
se.log_action(
|
||||
'pretix.subevent.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
else:
|
||||
subevents = None
|
||||
subevent_ids = set()
|
||||
orders_to_change = orders_to_cancel.filter(has_blocked=True)
|
||||
orders_to_cancel = orders_to_cancel.filter(has_blocked=False)
|
||||
event.log_action(
|
||||
'pretix.event.canceled', user=user,
|
||||
)
|
||||
|
||||
for i in event.items.filter(active=True):
|
||||
i.active = False
|
||||
i.save(update_fields=['active'])
|
||||
i.log_action(
|
||||
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
if not dry_run:
|
||||
event.log_action(
|
||||
'pretix.event.canceled', user=user,
|
||||
data={
|
||||
"auto_refund": auto_refund,
|
||||
"keep_fee_fixed": keep_fee_fixed,
|
||||
"keep_fee_per_ticket": keep_fee_per_ticket,
|
||||
"keep_fee_percentage": keep_fee_percentage,
|
||||
"keep_fees": keep_fees,
|
||||
"manual_refund": manual_refund,
|
||||
"send": send,
|
||||
"send_subject": send_subject,
|
||||
"send_message": send_message,
|
||||
"send_waitinglist": send_waitinglist,
|
||||
"send_waitinglist_subject": send_waitinglist_subject,
|
||||
"send_waitinglist_message": send_waitinglist_message,
|
||||
"refund_as_giftcard": refund_as_giftcard,
|
||||
"giftcard_expires": str(giftcard_expires),
|
||||
"giftcard_conditions": giftcard_conditions,
|
||||
}
|
||||
)
|
||||
|
||||
for i in event.items.filter(active=True):
|
||||
i.active = False
|
||||
i.save(update_fields=['active'])
|
||||
i.log_action(
|
||||
'pretix.event.item.changed', user=user, data={'active': False, '_source': 'cancel_event'}
|
||||
)
|
||||
failed = 0
|
||||
total = orders_to_cancel.count() + orders_to_change.count()
|
||||
refund_total = Decimal("0.00")
|
||||
cancel_full_total = orders_to_cancel.count()
|
||||
cancel_partial_total = orders_to_change.count()
|
||||
total = cancel_full_total + cancel_partial_total
|
||||
qs_wl = event.waitinglistentries.filter(voucher__isnull=True).select_related('subevent')
|
||||
if subevents:
|
||||
qs_wl = qs_wl.filter(subevent__in=subevents)
|
||||
@@ -199,6 +241,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
)
|
||||
|
||||
for o in orders_to_cancel.only('id', 'total').iterator():
|
||||
payment_refund_sum = o.payment_refund_sum # cache to avoid multiple computations
|
||||
try:
|
||||
fee = Decimal('0.00')
|
||||
fee_sum = Decimal('0.00')
|
||||
@@ -217,20 +260,24 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
for p in o.positions.all():
|
||||
if p.addon_to_id is None:
|
||||
fee += min(p.price, Decimal(keep_fee_per_ticket))
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
fee = round_decimal(min(fee, payment_refund_sum), event.currency)
|
||||
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
|
||||
refund_amount = o.payment_refund_sum
|
||||
if dry_run:
|
||||
refund_total += max(Decimal("0.00"), min(payment_refund_sum, o.total - fee))
|
||||
else:
|
||||
_cancel_order(o.pk, user, send_mail=False, cancellation_fee=fee, keep_fees=keep_fee_objects)
|
||||
refund_amount = payment_refund_sum
|
||||
refund_amount += refund_total
|
||||
|
||||
try:
|
||||
if auto_refund or manual_refund:
|
||||
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
finally:
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||
try:
|
||||
if auto_refund or manual_refund:
|
||||
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
finally:
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, o.positions.all())
|
||||
|
||||
counter += 1
|
||||
if not self.request.called_directly and counter % max(10, total // 100) == 0:
|
||||
@@ -247,12 +294,16 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
|
||||
for o in orders_to_change.values_list('id', flat=True).iterator():
|
||||
with transaction.atomic():
|
||||
o = event.orders.select_for_update(of=OF_SELF).get(pk=o)
|
||||
if dry_run:
|
||||
o = event.orders.get(pk=o)
|
||||
else:
|
||||
o = event.orders.select_for_update(of=OF_SELF).get(pk=o)
|
||||
total = Decimal('0.00')
|
||||
fee = Decimal('0.00')
|
||||
positions = []
|
||||
|
||||
ocm = OrderChangeManager(o, user=user, notify=False)
|
||||
payment_refund_sum = o.payment_refund_sum # cache to avoid multiple computations
|
||||
for p in o.positions.all():
|
||||
if (not event.has_subevents or p.subevent_id in subevent_ids) and not p.blocked:
|
||||
total += p.price
|
||||
@@ -267,7 +318,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
fee += Decimal(keep_fee_fixed)
|
||||
if keep_fee_percentage:
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
fee = round_decimal(min(fee, payment_refund_sum), event.currency)
|
||||
if fee:
|
||||
tax_rule_zero = TaxRule.zero()
|
||||
if event.settings.tax_rule_cancellation == "default":
|
||||
@@ -298,17 +349,21 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
)
|
||||
ocm.add_fee(f)
|
||||
|
||||
ocm.commit()
|
||||
refund_amount = o.payment_refund_sum - o.total
|
||||
if dry_run:
|
||||
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00"))
|
||||
else:
|
||||
ocm.commit()
|
||||
refund_amount = payment_refund_sum - o.total
|
||||
refund_total += refund_amount
|
||||
|
||||
if auto_refund or manual_refund:
|
||||
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
if auto_refund or manual_refund:
|
||||
_try_auto_refund(o.pk, auto_refund=auto_refund, manual_refund=manual_refund, allow_partial=True,
|
||||
source=OrderRefund.REFUND_SOURCE_ADMIN, refund_as_giftcard=refund_as_giftcard,
|
||||
giftcard_expires=giftcard_expires, giftcard_conditions=giftcard_conditions,
|
||||
comment=gettext('Event canceled'))
|
||||
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||
if send:
|
||||
_send_mail(o, send_subject, send_message, subevent, refund_amount, user, positions)
|
||||
|
||||
counter += 1
|
||||
if not self.request.called_directly and counter % max(10, total // 100) == 0:
|
||||
@@ -319,7 +374,8 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
|
||||
if send_waitinglist:
|
||||
for wle in qs_wl:
|
||||
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, wle.subevent)
|
||||
if not dry_run:
|
||||
_send_wle_mail(wle, send_waitinglist_subject, send_waitinglist_message, wle.subevent)
|
||||
|
||||
counter += 1
|
||||
if not self.request.called_directly and counter % max(10, total // 100) == 0:
|
||||
@@ -327,4 +383,30 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
state='PROGRESS',
|
||||
meta={'value': round(counter / total * 100 if total else 0, 2)}
|
||||
)
|
||||
return failed
|
||||
|
||||
confirmation_code = None
|
||||
if dry_run and user and refund_total > Decimal('100.00'):
|
||||
confirmation_code = get_random_string(8, allowed_chars="01234567890")
|
||||
mail(
|
||||
user.email,
|
||||
subject=gettext('Bulk-refund confirmation'),
|
||||
template='pretixbase/email/cancel_confirm.txt',
|
||||
context={
|
||||
"event": str(event),
|
||||
"amount": money_filter(refund_total, event.currency),
|
||||
"confirmation_code": confirmation_code,
|
||||
},
|
||||
locale=user.locale,
|
||||
)
|
||||
|
||||
return {
|
||||
"dry_run": dry_run,
|
||||
"id": self.request.id,
|
||||
"failed": failed,
|
||||
"refund_total": refund_total,
|
||||
"cancel_full_total": cancel_full_total,
|
||||
"cancel_partial_total": cancel_partial_total,
|
||||
"confirmation_code": confirmation_code,
|
||||
"args": self.request.args,
|
||||
"kwargs": self.request.kwargs,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user