move checks to classes and add distinction between process_fees and position fees

This commit is contained in:
Lukas Bockstaller
2026-04-01 18:07:05 +02:00
parent 7ffadb87b3
commit c624fcfe41
4 changed files with 321 additions and 181 deletions

View File

@@ -2,42 +2,33 @@ import dataclasses
from dataclasses import dataclass
from decimal import Decimal
from functools import reduce
from typing import Callable, Dict, List, Set, Union
from typing import Callable, Dict, List, Literal, Optional, Set, Union
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models import Checkin
from pretix.base.models import Event, Order, OrderPosition
from pretix.base.models import Item
from pretix.base.models import ItemVariation
from pretix.base.reldate import ModelRelativeDateTimeField
from pretix.base.services.orders import CancellationCheck
from pretix.base.signals import self_service_cancellation_checks
from pretix.base.timemachine import time_machine_now
@dataclass(frozen=True)
class AbsoluteFee:
amount: Decimal
@dataclass(frozen=True)
class RelativeFee:
reference_price: Decimal
percentage: Decimal
@property
def amount(self):
return self.reference_price * (self.percentage/100)
Fee=Union[AbsoluteFee, RelativeFee]
@dataclass(frozen=True)
class OrderDiff:
order: Order
prev: Set[OrderPosition]
next: Set[OrderPosition]
keep: Set[OrderPosition]
def cancellations(self):
return self.prev.difference(self.next)
@@ -47,75 +38,97 @@ class OrderDiff:
return OrderDiff(order=order, prev=set(order.positions.all()), next=set())
@dataclass(frozen=True)
class CheckRes:
class CancellationCheckResult:
cancellation_possible: bool
reason: str
CheckResult=Dict[str, CheckRes]
# Maps Check identifier → cancellation check result
CancellationCheckResultsById = Dict[str, CancellationCheckResult]
CheckFn=Callable[[Order, Set[OrderPosition], OrderPosition], CancellationCheckResultsById]
FeeType = Literal['position_fee', 'process_fee']
CheckFn=Callable[[OrderDiff, OrderPosition], CheckResult]
def merge_check_results(a: CheckResult, b: CheckResult) -> CheckResult:
result = dict(a)
for key, b_inner in b.items():
if key in result:
result[key] = result[key] | b_inner
else:
result[key] = b_inner
return result
@dataclass(frozen=True)
class Ruling:
"""
A Ruling is the result of applying a CancellationRule onto an Order or OrderPosition.
"""
rule_id: int
results: CheckResult
order_fee: Decimal=dataclasses.field(default_factory=lambda: Decimal(0))
position_fee: Fee=dataclasses.field(default_factory=lambda: AbsoluteFee(Decimal(0)))
results: CancellationCheckResultsById
fee_type: FeeType
fee: Decimal
cancellation_possible: bool
cancellation_possible: bool=dataclasses.field(init=False)
def __post_init__(self):
object.__setattr__(
def __init__(
self,
'cancellation_possible',
all(ruling.cancellation_possible
for ruling in self.results.values()
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
fee: Decimal
):
self.rule_id = rule_id
self.results = results
self.fee_type = fee_type
self.fee = fee
self.cancellation_possible = all(ruling.cancellation_possible
for ruling in results.values()
)
)
@property
def total_fee(self):
return self.position_fee.amount + self.order_fee
@classmethod
def from_absolute_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: FeeType,
absolute_fee: Decimal
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
:param rule_id: Id of the rule
:param results: CheckResult object
:param fee_type: If the fee is calculated for a position or process fee
:param absolute_fee: amount of the fee
:return:
"""
return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=absolute_fee)
@classmethod
def from_relative_fee(
cls,
rule_id: int,
results: CancellationCheckResultsById,
fee_type: Literal['position_fee'],
reference_price: Decimal,
percentage: Decimal,
currency: str
) -> "Ruling":
"""
Constructs a Ruling with an absolute fee.
:param rule_id: Id of the rule
:param results: CheckResult object
:param fee_type: Must be a position_fee as the fee can only be in reference to a position
:param reference_price: Price of the position to reference
:param percentage: Percentage of the reference_price set as the fee
:param currency: Currency of the reference_price, used for correct rounding of the fee
:return:
"""
if fee_type == "process_fee":
raise ValidationError("Process fee cannot be used with relative fees")
return Ruling(rule_id=rule_id, results=results, fee_type=fee_type, fee=round_decimal(reference_price * (percentage/100), currency))
def __lt__(self, other):
if not isinstance(other, Ruling):
return NotImplemented
if self.fee_type != other.fee_type:
return NotImplemented
if self.cancellation_possible == other.cancellation_possible:
return self.total_fee < other.total_fee
return self.fee < other.fee
else:
return self.cancellation_possible and not other.cancellation_possible
class CancellationRuleQuerySet(models.QuerySet):
def cancellation_possible(self, diff: OrderDiff):
verdicts = [v[0] for v in self._evaluate(diff)]
return all(v.cancellation_possible for v in verdicts), verdicts
def _evaluate(self, diff: OrderDiff) -> List[List[Ruling]]:
return [self._evaluate_op(diff, position) for position in diff.order.positions.all()]
def _evaluate_op(self, diff: OrderDiff, order_position: OrderPosition) -> List[Ruling]:
consequences = []
for rule in self:
if order_position.item in rule.items.all() or order_position.variation in rule.variations.all():
consequences.append(rule.apply(diff, order_position))
consequences.sort()
return consequences
def validate_status_chars(value):
invalid=set(value) - Order.ALLOWED_STATUS_CHARS
if invalid:
@@ -126,33 +139,25 @@ def validate_status_chars(value):
raise ValidationError("Duplicate characters are not allowed.")
class CancellationRule(models.Model):
"""
"""
organizer=models.ForeignKey(
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event=models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="orders",
on_delete=models.CASCADE
)
items = models.ManyToManyField(
"Item",
verbose_name=_("Items"),
all_products=models.BooleanField(
verbose_name=_("All products and variations"),
default=True,
)
variations=models.ManyToManyField(
"ItemVariation",
verbose_name=_("Item variations"),
limit_products=models.ManyToManyField(Item, verbose_name=_("Products"), blank=True)
limit_variations=models.ManyToManyField(
ItemVariation, blank=True, verbose_name=_("Variations")
)
allowed_if_in_order_status=models.CharField(
max_length=4,
choices=Order.STATUS_CHOICE,
@@ -166,92 +171,34 @@ class CancellationRule(models.Model):
fee_percentage_per_item=models.DecimalField(
max_digits=5,
decimal_places=2,
max_value=Decimal("100.00"),
min_value=Decimal("0.00"),
verbose_name=_("Fee Percentage per Item"),
validators=[MinValueValidator("0.00"), MaxValueValidator("100.00")],
verbose_name=_("Fee Percentage per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_absolute_per_item=models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute Fee per Item"),
verbose_name=_("Absolute fee per OrderPosition"),
default=Decimal("0.00"),
) # wird als sum() kombiniert
fee_absolute_per_order=models.DecimalField(
fee_cancellation_process=models.DecimalField(
max_digits=13,
decimal_places=2,
verbose_name=_("Absolute Fee per Cancellation"),
verbose_name=_("Absolute fee per Cancellation"),
default=Decimal("0.00"),
) # wird als max() kombiniert
objects=ScopedManager(CancellationRuleQuerySet.as_manager().__class__, organizer='organizer',
event='event')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# erstmal festgelegte List an Stornoregeln, weitere checks können zukünftig über ein
# Signal eingesammelt und an CancellationRule.__init__ übergeben werden
# Ermöglicht dann: "Shipping modul kann storno geshippter Items verhindern"
self.checks: List[CheckFn]=[self._check_order_status, self._check_time_window,
self._system_check_not_checked_in, self._system_check_not_discounted]
self.checks: List[CheckFn] = [self._check_order_status, self._check_time_window]
# TODO weitere System Checks
# OrderPositions mit Item.min_per_order dürfen nur storniert werden, wenn genug übrig bleiben oder alle des gleichen Items storniert werden
# OrderPositions mit addon_to != None dürfen nur über den bestehenden Add-On-Flow storniert werden
# OrderPositions mit is_bundled dürfen nur mit der Parent-Position zusammen storniert werden
@staticmethod
def _system_check_not_checked_in(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
check_id = "SYSTEM_TICKET_NOT_USED"
if order_position.checkins.filter(list__consider_tickets_used=True).exists():
return {check_id: CheckRes(
cancellation_possible=False,
reason=f"Order position was used",
)}
else:
return {check_id: CheckRes(
cancellation_possible=True,
reason=f"Order position not yet used",
)}
@staticmethod
def _system_check_not_discounted(diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
"""
Check that ensures that orders containing discounted order_positions cannot
be canceled partially.
This is a stop-gap solution until the `discount_grouper` attribute for
AbstractPositions is introduced, allowing us to be more grannular
:param diff:
:param order_position:
:return CheckResults:
"""
check_id = "SYSTEM_TICKET_NOT_DISCOUNTED"
if order_position in diff.cancellations():
if order_position.discount_id is None:
return {check_id: CheckRes(
cancellation_possible=True,
reason=_("Order position was bought without discount"),
)}
else:
return {check_id: CheckRes(
cancellation_possible=False,
reason=_("Order position was bought with a discount"),
)}
else:
return {check_id: CheckRes(
cancellation_possible=False,
reason=_("Order position not canceled - check not applicable"),
)}
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
def _check_time_window(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
check_id = "TIME_WINDOW"
if not self.allowed_until and not self.allowed_until:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"No time window specified",
)}
@@ -264,57 +211,37 @@ class CancellationRule(models.Model):
if in_allowed_until and not in_exemption:
except_after_message = f" and not after {self.except_after.datetime(relevant_event)}" if self.except_after else ""
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Cancellation in required time window before {self.allowed_until.datetime(relevant_event)}{except_after_message}",
)}
elif in_allowed_until and in_exemption:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation in exemption period after {self.except_after.datetime(relevant_event)}",
)}
else:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Cancellation after time window ending on {self.allowed_until.datetime(relevant_event)}",
)}
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CheckResult:
def _check_order_status(self, diff: OrderDiff, order_position: OrderPosition) -> CancellationCheckResultsById:
check_id = "ORDER_STATUS"
if diff.order.status == "".join(Order.ALLOWED_STATUS_CHARS):
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Orders in every status can be cancelled",
)}
elif diff.order.status in self.allowed_if_in_order_status:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=True,
reason=f"Order in required status: '{diff.order.status}'",
)}
else:
return {check_id: CheckRes(
return {check_id: CancellationCheckResult(
cancellation_possible=False,
reason=f"Order in status '{diff.order.status}' cannot be canceled",
)}
def apply(self, diff: OrderDiff, order_position: OrderPosition) -> Ruling:
check_results=reduce(merge_check_results,
[rule(diff, order_position) for rule in self.checks])
if self.fee_percentage_per_item and self.fee_absolute_per_item:
raise NotImplementedError("Should never be reached")
elif self.fee_absolute_per_item:
fee=AbsoluteFee(self.fee_absolute_per_item)
else:
fee=RelativeFee(percentage=self.fee_percentage_per_item,
reference_price=order_position.price)
return Ruling(
rule_id=self.id,
results=check_results,
order_fee=self.fee_absolute_per_order,
position_fee=fee
)