Order creation API

This commit is contained in:
Raphael Michel
2025-08-11 17:47:11 +02:00
parent ea454ac302
commit 0fd2c60fa0
6 changed files with 129 additions and 13 deletions

View File

@@ -41,6 +41,7 @@ expires datetime The order will
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
total money (string) Total value of 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 comment string Internal comment on this order
api_meta object Meta data for that order. Only available through API, no guarantees 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. 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. The ``plugin_data`` attribute has been added.
.. versionchanged:: 2025.8
The ``tax_rounding_mode`` attribute has been added.
.. _order-position-resource: .. _order-position-resource:
Order position resource Order position resource
@@ -349,6 +354,7 @@ List of all orders
"payment_provider": "banktransfer", "payment_provider": "banktransfer",
"fees": [], "fees": [],
"total": "23.00", "total": "23.00",
"tax_rounding_mode": "line",
"comment": "", "comment": "",
"custom_followup_at": null, "custom_followup_at": null,
"checkin_attention": false, "checkin_attention": false,
@@ -590,6 +596,7 @@ Fetching individual orders
"payment_provider": "banktransfer", "payment_provider": "banktransfer",
"fees": [], "fees": [],
"total": "23.00", "total": "23.00",
"tax_rounding_mode": "line",
"comment": "", "comment": "",
"api_meta": {}, "api_meta": {},
"custom_followup_at": null, "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* 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*. 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. * ``payment_date`` (optional) Date and time of the completion of the payment.
* ``tax_rounding_mode`` (optional)
* ``comment`` (optional) * ``comment`` (optional)
* ``custom_followup_at`` (optional) * ``custom_followup_at`` (optional)
* ``checkin_attention`` (optional) * ``checkin_attention`` (optional)

View File

@@ -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.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher, QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
Voucher,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, 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.cart import error_messages
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
from pretix.base.services.pricing import ( 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.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.base.signals import register_ticket_outputs
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -781,14 +785,15 @@ class OrderSerializer(I18nAwareModelSerializer):
list_serializer_class = OrderListSerializer list_serializer_class = OrderListSerializer
fields = ( fields = (
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data', 'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
'plugin_data',
) )
read_only_fields = ( read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', 'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date' 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1103,6 +1108,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
queryset=SalesChannel.objects.none(), queryset=SalesChannel.objects.none(),
required=False, required=False,
) )
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True) locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
def __init__(self, *args, **kwargs): 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', fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date', 'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', '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): def validate_payment_provider(self, pp):
if pp is None: if pp is None:
@@ -1641,7 +1647,31 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
else: else:
f.save() 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: if simulate:
order.fees = fees order.fees = fees
order.positions = pos_map.values() order.positions = pos_map.values()

View File

@@ -344,6 +344,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['event'] = self.request.event ctx['event'] = self.request.event
ctx['auth'] = self.request.auth
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true' ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
return ctx return ctx

View File

@@ -77,7 +77,6 @@ from pretix.control.forms import (
) )
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
ROUNDING_MODES = ( ROUNDING_MODES = (
('line', _('Rounding every line individually')), ('line', _('Rounding every line individually')),
('sum_by_net', _('Rounding by order total, keeping net prices stable')), ('sum_by_net', _('Rounding by order total, keeping net prices stable')),

View File

@@ -420,6 +420,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
} }
], ],
'total': '21.75', 'total': '21.75',
'tax_rounding_mode': 'line',
'comment': '', 'comment': '',
'api_meta': {}, 'api_meta': {},
"custom_followup_at": None, "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) m = organizer.reusable_media.get(identifier=i)
assert m.linked_orderposition == o.positions.first() assert m.linked_orderposition == o.positions.first()
assert m.type == "barcode" 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"

View File

@@ -306,6 +306,7 @@ TEST_ORDER_RES = {
"url": "http://example.com/dummy/dummy/order/FOO/k24fiuwvu8kxz3y1/", "url": "http://example.com/dummy/dummy/order/FOO/k24fiuwvu8kxz3y1/",
"payment_provider": "banktransfer", "payment_provider": "banktransfer",
"total": "23.00", "total": "23.00",
"tax_rounding_mode": "line",
"comment": "", "comment": "",
"api_meta": {}, "api_meta": {},
"custom_followup_at": None, "custom_followup_at": None,