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:
Raphael Michel
2025-10-29 08:53:48 +01:00
committed by GitHub
parent e386ed4352
commit 1e0ede529c
9 changed files with 422 additions and 103 deletions

View File

@@ -63,6 +63,15 @@ class EventCancelTests(TestCase):
generate_invoice(self.order)
djmail.outbox = []
def _cancel_with_dryrun(self, *args, expected_refunds, **kwargs):
dry_run = cancel_event(
*args, **kwargs, dry_run=True
)
assert dry_run["refund_total"] == expected_refunds
cancel_event(
*args, **kwargs,
)
@classscope(attr='o')
def test_cancel_send_mail(self):
gc = self.o.issued_gift_cards.create(currency="EUR")
@@ -74,11 +83,11 @@ class EventCancelTests(TestCase):
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert len(djmail.outbox) == 1
self.order.refresh_from_db()
@@ -114,11 +123,11 @@ class EventCancelTests(TestCase):
self.op1.blocked = ["admin"]
self.op1.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("23.00")
)
self.op1.refresh_from_db()
@@ -147,11 +156,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
r = self.order.refunds.get()
@@ -175,11 +184,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=False, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
self.order.refresh_from_db()
@@ -198,11 +207,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("42.00")
)
r = self.order.refunds.get()
@@ -226,11 +235,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("44.00")
)
r = self.order.refunds.get()
@@ -252,11 +261,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("44.00")
)
r = self.order.refunds.get()
@@ -276,11 +285,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="10.00", keep_fee_percentage="10.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("31.40")
)
r = self.order.refunds.get()
@@ -304,11 +313,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PENDING
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="10.00", keep_fee_percentage="10.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("12.00")
)
assert not self.order.refunds.exists()
@@ -335,10 +344,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00", keep_fees=[OrderFee.FEE_TYPE_PAYMENT], keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(", user=None
send=False, send_subject="Event canceled", send_message="Event canceled :-(", user=None,
expected_refunds=Decimal("36.90")
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
@@ -371,11 +381,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00", keep_fees=[OrderFee.FEE_TYPE_PAYMENT], keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("39.40")
)
r = self.order.refunds.get()
assert r.amount == Decimal('39.40')
@@ -400,11 +410,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, manual_refund=True,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert self.order.refunds.count() == 2
@@ -436,11 +446,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, manual_refund=False,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert self.order.refunds.count() == 1
@@ -467,11 +477,11 @@ class EventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, manual_refund=True,
auto_refund=False, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("46.00")
)
assert self.order.refunds.count() == 1
@@ -511,17 +521,26 @@ class SubEventCancelTests(TestCase):
generate_invoice(self.order)
djmail.outbox = []
def _cancel_with_dryrun(self, *args, expected_refunds, **kwargs):
dry_run = cancel_event(
*args, **kwargs, dry_run=True
)
assert dry_run["refund_total"] == expected_refunds
cancel_event(
*args, **kwargs,
)
@classscope(attr='o')
def test_cancel_partially_send_mail_attendees(self):
self.op1.attendee_email = 'foo@example.com'
self.op1.save()
self.op2.attendee_email = 'foo@example.org'
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
assert len(djmail.outbox) == 2
self.order.refresh_from_db()
@@ -532,19 +551,19 @@ class SubEventCancelTests(TestCase):
def test_cancel_subevent_range(self):
self.op2.subevent = self.se1
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from - timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from + timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@@ -553,11 +572,11 @@ class SubEventCancelTests(TestCase):
def test_cancel_simple_order(self):
self.op2.subevent = self.se1
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@@ -567,11 +586,11 @@ class SubEventCancelTests(TestCase):
self.op2.subevent = self.se1
self.op2.blocked = ["admin"]
self.op2.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
@@ -582,11 +601,11 @@ class SubEventCancelTests(TestCase):
@classscope(attr='o')
def test_cancel_all_subevents(self):
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_CANCELED
@@ -602,11 +621,12 @@ class SubEventCancelTests(TestCase):
)
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self.order.refresh_from_db()
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("23.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PAID
@@ -614,20 +634,20 @@ class SubEventCancelTests(TestCase):
@classscope(attr='o')
def test_cancel_mixed_order_range(self):
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from - timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
assert self.order.positions.count() == 2
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=None, subevents_from=self.se1.date_from - timedelta(days=3), subevents_to=self.se1.date_from + timedelta(days=2),
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="",
send=True, send_subject="Event canceled", send_message="Event canceled :-( {refund_amount}",
user=None
user=None, expected_refunds=Decimal("0.00")
)
self.order.refresh_from_db()
assert self.order.status == Order.STATUS_PENDING
@@ -651,11 +671,11 @@ class SubEventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="10.00", keep_fee_per_ticket="",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("16.20")
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE
@@ -682,11 +702,11 @@ class SubEventCancelTests(TestCase):
self.order.status = Order.STATUS_PAID
self.order.save()
cancel_event(
self._cancel_with_dryrun(
self.event.pk, subevent=self.se1.pk,
auto_refund=True, keep_fee_fixed="0.00", keep_fee_percentage="0.00", keep_fee_per_ticket="2.00",
send=False, send_subject="Event canceled", send_message="Event canceled :-(",
user=None
user=None, expected_refunds=Decimal("21.00")
)
r = self.order.refunds.get()
assert r.state == OrderRefund.REFUND_STATE_DONE