From 40db7d939f5b1f0251b5bf4a2c720dc103435927 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 21 Oct 2025 18:35:06 +0200 Subject: [PATCH] API: Trust discounts assigned by pretixPOS, do not assign differently (#5531) --- doc/api/resources/orders.rst | 1 + src/pretix/api/serializers/order.py | 36 ++++---- src/tests/api/test_order_create.py | 125 ++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 5073c3a40..210212569 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1058,6 +1058,7 @@ Creating orders * ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected) * ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID) + * ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run) * ``answers`` * ``question`` diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index e2499c35f..b58754ed9 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1005,7 +1005,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled', 'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until', - 'requested_valid_from', 'use_reusable_medium') + 'requested_valid_from', 'use_reusable_medium', 'discount') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1101,6 +1101,10 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): {'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]} ) + if data.get('price') is None and data.get('discount'): + raise ValidationError( + {'discount': ['You can only specify a discount if you do the price computation, but price is not set.']} + ) return data @@ -1160,6 +1164,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all() + self.fields['positions'].child.fields['discount'].queryset = self.context['event'].discounts.all() self.fields['customer'].queryset = self.context['event'].organizer.customers.all() self.fields['expires'].required = False self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all() @@ -1567,19 +1572,22 @@ class OrderCreateSerializer(I18nAwareModelSerializer): pos.voucher_budget_use = max(listed_price - price_after_voucher, Decimal('0.00')) order_positions = [pos_data['__instance'] for pos_data in positions_data] - discount_results = apply_discounts( - self.context['event'], - order.sales_channel, - [ - (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price, - bool(cp.addon_to), cp.is_bundled, pos._voucher_discount) - for cp in order_positions - ] - ) - for cp, (new_price, discount) in zip(order_positions, discount_results): - if new_price != pos.price and pos._auto_generated_price: - pos.price = new_price - pos.discount = discount + if not any([p.get("discount") for p in positions_data]): + # If any discount is set by the client (i.e. pretixPOS), we do not recalculate but believe the client + # to avoid differences in end results. + discount_results = apply_discounts( + self.context['event'], + order.sales_channel, + [ + (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price, + bool(cp.addon_to), cp.is_bundled, pos._voucher_discount) + for cp in order_positions + ] + ) + for cp, (new_price, discount) in zip(order_positions, discount_results): + if new_price != pos.price and pos._auto_generated_price: + pos.price = new_price + pos.discount = discount # Save instances for pos_data in positions_data: diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index 06083965d..e954da047 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -3134,3 +3134,128 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota, m = organizer.reusable_media.get(identifier=i) assert m.linked_orderposition == o.positions.first() assert m.type == "barcode" + + +@pytest.mark.django_db +def test_order_create_auto_pricing_discount(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + event.discounts.create( + condition_min_count=2, + benefit_discount_matching_percent=50, + benefit_only_apply_to_cheapest_n_matches=1, + ) + + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['positionid'] + del res['positions'][0]['price'] + res['positions'].append(dict(res['positions'][0])) + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p1 = o.positions.first() + p2 = o.positions.last() + assert p1.price == Decimal('23') + assert p2.price == Decimal('11.50') + assert o.total == Decimal('34.75') + + +@pytest.mark.django_db +def test_order_create_auto_pricing_do_not_discount_if_price_explcitly_set(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + event.discounts.create( + condition_min_count=2, + benefit_discount_matching_percent=50, + benefit_only_apply_to_cheapest_n_matches=1, + ) + + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['positionid'] + res['positions'].append(dict(res['positions'][0])) + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p1 = o.positions.first() + p2 = o.positions.last() + assert p1.price == Decimal('23.00') + assert p2.price == Decimal('23.00') + assert o.total == Decimal('46.25') + + +@pytest.mark.django_db +def test_order_create_auto_pricing_believe_wrong_discounts_by_client(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + discount = event.discounts.create( + condition_min_count=2, + benefit_discount_matching_percent=50, + benefit_only_apply_to_cheapest_n_matches=1, + ) + + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['positionid'] + res['positions'].append(dict(res['positions'][0])) + res['positions'][0]['price'] = Decimal("10.00") + res['positions'][1]['price'] = Decimal("7.00") + res['positions'][1]['discount'] = discount.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p1 = o.positions.first() + p2 = o.positions.last() + assert p1.price == Decimal('10.00') + assert p1.discount is None + assert p2.price == Decimal('7.00') + assert p2.discount == discount + assert o.total == Decimal('17.25') + + +@pytest.mark.django_db +def test_order_create_auto_pricing_explicit_discount_not_allowed(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + discount = event.discounts.create( + condition_min_count=2, + benefit_discount_matching_percent=50, + benefit_only_apply_to_cheapest_n_matches=1, + ) + + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['positions'][0]['positionid'] + del res['positions'][0]['price'] + res['positions'].append(dict(res['positions'][0])) + res['positions'][1]['discount'] = discount.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == { + "positions": [ + {}, + { + "discount": ["You can only specify a discount if you do the price computation, but price is not set."] + } + ] + }