diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index cdf7f98eec..233c4ce524 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -845,6 +845,14 @@ Creating orders * ``description`` * ``internal_type`` * ``tax_rule`` + * ``_treat_value_as_percentage`` (Optional convenience flag. If set to ``true``, your ``value`` parameter will + be treated as a percentage and the fee will be calculated using that percentage and the sum of all product + prices. Note that this will not include other fees and is calculated once during order generation and will not + be respected automatically when the order changes later.) + * ``_split_taxes_like_products`` (Optional convenience flag. If set to ``true``, your ``tax_rule`` will be ignored + and the fee will be taxed like the products in the order. If the products have multiple tax rates, multiple fees + will be generated with weights adjusted to the net price of the products. Note that this will be calculated once + during order generation and is not respected automatically when the order changes later.) * ``force`` (optional). If set to ``true``, quotas will be ignored. * ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 2f1afd53e9..b3e210efea 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1,5 +1,5 @@ import json -from collections import Counter +from collections import Counter, defaultdict from decimal import Decimal import pycountry @@ -14,10 +14,11 @@ from rest_framework.reverse import reverse from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.channels import get_all_sales_channels +from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.models import ( Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, - OrderPosition, Question, QuestionAnswer, Seat, SubEvent, Voucher, + OrderPosition, Question, QuestionAnswer, Seat, SubEvent, TaxRule, Voucher, ) from pretix.base.models.orders import ( CartPosition, OrderFee, OrderPayment, OrderRefund, @@ -477,9 +478,13 @@ class AnswerCreateSerializer(I18nAwareModelSerializer): class OrderFeeCreateSerializer(I18nAwareModelSerializer): + _treat_value_as_percentage = serializers.BooleanField(default=False, required=False) + _split_taxes_like_products = serializers.BooleanField(default=False, required=False) + class Meta: model = OrderFee - fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule') + fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule', + '_treat_value_as_percentage', '_split_taxes_like_products') def validate_tax_rule(self, tr): if tr and tr.event != self.context['event']: @@ -863,13 +868,48 @@ class OrderCreateSerializer(I18nAwareModelSerializer): for cp in delete_cps: cp.delete() + order.total = sum([p.price for p in order.positions.all()]) for fee_data in fees_data: - f = OrderFee(**fee_data) - f.order = order - f._calculate_tax() - f.save() + is_percentage = fee_data.pop('_treat_value_as_percentage', False) + if is_percentage: + fee_data['value'] = round_decimal(order.total * (fee_data['value'] / Decimal('100.00')), + self.context['event'].currency) + is_split_taxes = fee_data.pop('_split_taxes_like_products', False) - order.total = sum([p.price for p in order.positions.all()]) + sum([f.value for f in order.fees.all()]) + if is_split_taxes: + d = defaultdict(lambda: Decimal('0.00')) + trz = TaxRule.zero() + for p in pos_map.values(): + tr = p.tax_rule + d[tr] += p.price - p.tax_value + + base_values = sorted([tuple(t) for t in d.items()], key=lambda t: (t[0] or trz).rate) + sum_base = sum(t[1] for t in base_values) + fee_values = [(t[0], round_decimal(fee_data['value'] * t[1] / sum_base, self.context['event'].currency)) + for t in base_values] + sum_fee = sum(t[1] for t in fee_values) + + # If there are rounding differences, we fix them up, but always leaning to the benefit of the tax + # authorities + if sum_fee > fee_data['value']: + fee_values[0] = (fee_values[0][0], fee_values[0][1] + (fee_data['value'] - sum_fee)) + elif sum_fee < fee_data['value']: + fee_values[-1] = (fee_values[-1][0], fee_values[-1][1] + (fee_data['value'] - sum_fee)) + + for tr, val in fee_values: + fee_data['tax_rule'] = tr + fee_data['value'] = val + f = OrderFee(**fee_data) + f.order = order + f._calculate_tax() + f.save() + else: + f = OrderFee(**fee_data) + f.order = order + f._calculate_tax() + f.save() + + order.total += sum([f.value for f in order.fees.all()]) order.save(update_fields=['total']) if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider: diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index f9ee4478b4..970b778864 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -1825,6 +1825,46 @@ def test_order_create_fee_type_validation(token_client, organizer, event, item, assert resp.data == {'fees': [{'fee_type': ['"unknown" is not a valid choice.']}]} +@pytest.mark.django_db +def test_order_create_fee_as_percentage(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['_treat_value_as_percentage'] = True + res['fees'][0]['value'] = '10.00' + 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']) + fee = o.fees.first() + assert fee.value == Decimal('2.30') + assert o.total == Decimal('25.30') + + +@pytest.mark.django_db +def test_order_create_fee_with_auto_tax(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['_treat_value_as_percentage'] = True + res['fees'][0]['value'] = '10.00' + item.tax_rule = taxrule + item.save() + 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']) + fee = o.fees.first() + assert fee.value == Decimal('2.30') + assert fee.tax_rate == Decimal('19.00') + assert o.total == Decimal('25.30') + + @pytest.mark.django_db def test_order_create_tax_rule_wrong_event(token_client, organizer, event, item, quota, question, taxrule2): res = copy.deepcopy(ORDER_CREATE_PAYLOAD)