diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 23ea2954a8..6cd1a3f256 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -41,6 +41,7 @@ expires datetime The order will payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order total money (string) Total value of this order +tax_rounding_mode string Tax rounding mode, see :ref:`algorithms-rounding` comment string Internal comment on this order api_meta object Meta data for that order. Only available through API, no guarantees on the content structure. You can use this to save references to your system. @@ -142,6 +143,10 @@ plugin_data object Additional data The ``plugin_data`` attribute has been added. +.. versionchanged:: 2025.8 + + The ``tax_rounding_mode`` attribute has been added. + .. _order-position-resource: Order position resource @@ -349,6 +354,7 @@ List of all orders "payment_provider": "banktransfer", "fees": [], "total": "23.00", + "tax_rounding_mode": "line", "comment": "", "custom_followup_at": null, "checkin_attention": false, @@ -590,6 +596,7 @@ Fetching individual orders "payment_provider": "banktransfer", "fees": [], "total": "23.00", + "tax_rounding_mode": "line", "comment": "", "api_meta": {}, "custom_followup_at": null, @@ -996,6 +1003,7 @@ Creating orders provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no* charge will be created), this is just informative in case you *handled the payment already*. * ``payment_date`` (optional) – Date and time of the completion of the payment. + * ``tax_rounding_mode`` (optional) * ``comment`` (optional) * ``custom_followup_at`` (optional) * ``checkin_attention`` (optional) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 50c91c7f66..6f992d5620 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -50,9 +50,10 @@ from pretix.api.signals import order_api_details, orderposition_api_details from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.models import ( - CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, - ItemVariation, Order, OrderPosition, Question, QuestionAnswer, - ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher, + CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress, + InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, + QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, + Voucher, ) from pretix.base.models.orders import ( BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, @@ -62,10 +63,13 @@ from pretix.base.pdf import get_images, get_variables from pretix.base.services.cart import error_messages from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects from pretix.base.services.pricing import ( - apply_discounts, get_line_price, get_listed_price, is_included_for_free, + apply_discounts, apply_rounding, get_line_price, get_listed_price, + is_included_for_free, ) from pretix.base.services.quotas import QuotaAvailability -from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS +from pretix.base.settings import ( + COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES, +) from pretix.base.signals import register_ticket_outputs from pretix.helpers.countries import CachedCountries from pretix.multidomain.urlreverse import build_absolute_uri @@ -781,14 +785,15 @@ class OrderSerializer(I18nAwareModelSerializer): list_serializer_class = OrderListSerializer fields = ( 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', - 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', - 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', - 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data', + 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address', + 'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', + 'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', + 'plugin_data', ) read_only_fields = ( 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', - 'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', - 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date' + 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer', + 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date', ) def __init__(self, *args, **kwargs): @@ -1103,6 +1108,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): queryset=SalesChannel.objects.none(), required=False, ) + tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,) locale = serializers.ChoiceField(choices=[], required=False, allow_null=True) def __init__(self, *args, **kwargs): @@ -1118,7 +1124,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', 'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date', 'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', - 'require_approval', 'valid_if_pending', 'expires', 'api_meta') + 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode') def validate_payment_provider(self, pp): if pp is None: @@ -1641,7 +1647,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer): else: f.save() - order.total += sum([f.value for f in fees]) + rounding_mode = validated_data.get("tax_rounding_mode") + if not rounding_mode: + if isinstance(self.context.get("auth"), Device): + # Safety fallback to avoid differences in tax reporting + brand = self.context.get("auth").software_brand or "" + if "pretixPOS" in brand or "pretixKIOSK" in brand: + rounding_mode = "line" + if not rounding_mode: + rounding_mode = self.context["event"].settings.tax_rounding + changed = apply_rounding( + rounding_mode, + self.context["event"].currency, + [*pos_map.values(), *fees] + ) + for line in changed: + if isinstance(line, OrderPosition): + line.save(update_fields=[ + "price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + elif isinstance(line, OrderFee): + line.save(update_fields=[ + "value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction" + ]) + + order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees]) if simulate: order.fees = fees order.positions = pos_map.values() diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index f7d7a7ed16..eb6176059d 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): def get_serializer_context(self): ctx = super().get_serializer_context() ctx['event'] = self.request.event + ctx['auth'] = self.request.auth ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true' return ctx diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 45b85fec1a..526f1c563e 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -77,7 +77,6 @@ from pretix.control.forms import ( ) from pretix.helpers.countries import CachedCountries - ROUNDING_MODES = ( ('line', _('Rounding every line individually')), ('sum_by_net', _('Rounding by order total, keeping net prices stable')), diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index fc510eefd3..309c306876 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -420,6 +420,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques } ], 'total': '21.75', + 'tax_rounding_mode': 'line', 'comment': '', 'api_meta': {}, "custom_followup_at": None, @@ -3099,3 +3100,79 @@ 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_rounding_mode(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res["tax_rounding_mode"] = "sum_by_net" + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['value'] = Decimal("100.00") + res['positions'] = [ + { + "item": 1, + "price": "100.00", + } + ] * 4 + + for simulate in (True, False): + res["simulate"] = simulate + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "499.98" + assert resp.data["positions"][0]["price"] == "99.99" + assert resp.data["positions"][-1]["price"] == "100.00" + + res["tax_rounding_mode"] = "sum_by_gross" + for simulate in (True, False): + res["simulate"] = simulate + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "500.00" + assert resp.data["positions"][0]["tax_value"] == "15.96" + assert resp.data["positions"][-1]["tax_value"] == "15.97" + + +@pytest.mark.django_db +def test_order_create_rounding_default_pretixpos_fallback(device, device_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['value'] = Decimal("100.00") + res['positions'] = [ + { + "item": 1, + "price": "100.00", + } + ] * 4 + + event.settings.tax_rounding = "sum_by_net" + + resp = device_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "499.98" + assert resp.data["positions"][0]["price"] == "99.99" + assert resp.data["positions"][-1]["price"] == "100.00" + + device.software_brand = "pretixPOS Android" + device.save() + resp = device_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + assert resp.data["total"] == "500.00" + assert resp.data["positions"][0]["price"] == "100.00" + assert resp.data["positions"][-1]["price"] == "100.00" diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index fb7548ef4e..ebf599f975 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -306,6 +306,7 @@ TEST_ORDER_RES = { "url": "http://example.com/dummy/dummy/order/FOO/k24fiuwvu8kxz3y1/", "payment_provider": "banktransfer", "total": "23.00", + "tax_rounding_mode": "line", "comment": "", "api_meta": {}, "custom_followup_at": None,