diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 14cd870549..7986c17c42 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1029,6 +1029,7 @@ Creating orders 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) + * ``total`` (optional, will be computed from backend and operation will fail if the computation has a different result) * ``comment`` (optional) * ``custom_followup_at`` (optional) * ``checkin_attention`` (optional) @@ -1059,7 +1060,9 @@ Creating orders * ``item`` * ``variation`` (optional) * ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product) - * ``tax_rule`` (optional, if set to ``null`` or missing the price will be computed from the given product) + * ``tax_rule`` (optional, if set to ``null`` or missing the tax rule will be selected from the given product) + * ``tax_rate`` (optional, only works together with a tax rule, if set to ``null`` or missing the tax rate will be computed from the given tax rule) + * ``tax_code`` (optional, only works together with a tax rule, if set to ``null`` or missing the tax code will be computed from the given tax rule) * ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.) * ``attendee_name`` **or** ``attendee_name_parts`` (optional) * ``voucher`` (optional, the ``code`` attribute of a valid voucher) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 08b58179c5..24717bfbfc 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1026,10 +1026,25 @@ class OrderFeeCreateSerializer(I18nAwareModelSerializer): _treat_value_as_percentage = serializers.BooleanField(default=False, required=False) _split_taxes_like_products = serializers.BooleanField(default=False, required=False) + tax_rate = serializers.DecimalField(required=False, decimal_places=4, max_digits=7) + tax_value = serializers.DecimalField(required=False, decimal_places=2, max_digits=13) + value_includes_rounding_correction = serializers.DecimalField(required=False, decimal_places=2, max_digits=13) + tax_value_includes_rounding_correction = serializers.DecimalField(required=False, decimal_places=2, max_digits=13) + class Meta: model = OrderFee - fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rule', - '_treat_value_as_percentage', '_split_taxes_like_products') + fields = ( + 'fee_type', 'value', 'description', 'internal_type', 'tax_rule', + '_treat_value_as_percentage', '_split_taxes_like_products', + # The following fields should usually not be set by the client but computed by the backend. However, it makes + # sense to set them when the decision on the tax rate is made irrevertably on the client side, such as with + # pretixPOS. + 'tax_rate', 'tax_code', + # The following fields are undocumented and only used by pretixPOS. They do *NOT* override the calculations + # by the backends but are checked against them and must match exactly, otherwise the entire operation is + # aborted to avoid diverging tax values between POS reports and backend reports. + 'tax_value', 'value_includes_rounding_correction', 'tax_value_includes_rounding_correction', + ) def validate_tax_rule(self, tr): if tr and tr.event != self.context['event']: @@ -1038,6 +1053,25 @@ class OrderFeeCreateSerializer(I18nAwareModelSerializer): ) return tr + def validate(self, attrs): + if attrs.get('_treat_value_as_percentage'): + for k in ('tax_value', 'value_includes_rounding_correction', 'tax_value_includes_rounding_correction'): + if k in attrs: + raise ValidationError({ + k: ['You cannot ask for the value to be treated as a percentage if you already computed taxes.'] + }) + if attrs.get('_split_taxes_like_products'): + for k in ('tax_rate', 'tax_value', 'value_includes_rounding_correction', 'tax_value_includes_rounding_correction'): + if k in attrs: + raise ValidationError({ + k: ['You cannot ask for the value to be distributed over tax rates if you already set the tax rate or computed taxes.'] + }) + if 'tax_rate' in attrs and 'tax_rule' not in attrs: + raise ValidationError({ + 'tax_rate': ['Tax rate cannot be stored without tax rule.'] + }) + return attrs + class OrderPositionCreateSerializer(I18nAwareModelSerializer): answers = AnswerCreateSerializer(many=True, required=False) @@ -1064,11 +1098,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): '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', 'discount', 'tax_rule', - # These are currently undocumented and only used by pretixPOS. The backend still calculates these values, but the - # results must match exactly, otherwise the operation is aborted to avoid diverging tax values between POS reports - # and backend reports. - 'tax_rate', 'tax_code', 'tax_value', 'price_includes_rounding_correction', 'tax_value_includes_rounding_correction', + 'requested_valid_from', 'use_reusable_medium', 'discount', + # The following fields should usually not be set by the client but computed by the backend. However, it makes + # sense to set them when the decision on the tax rate is made irrevertably on the client side, such as with + # pretixPOS. + 'tax_rule', 'tax_rate', 'tax_code', + # The following fields are undocumented and only used by pretixPOS. They do *NOT* override the calculations + # by the backends but are checked against them and must match exactly, otherwise the entire operation is + # aborted to avoid diverging tax values between POS reports and backend reports. + 'tax_value', 'price_includes_rounding_correction', 'tax_value_includes_rounding_correction', ) def __init__(self, *args, **kwargs): @@ -1177,6 +1215,11 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): {'discount': ['You can only specify a discount if you do the price computation, but price is not set.']} ) + if 'tax_rate' in data and 'tax_rule' not in data and not data['item'].tax_rule_id: + raise ValidationError({ + 'tax_rate': ['Tax rate cannot be stored without tax rule.'] + }) + return data @@ -1234,6 +1277,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,) locale = serializers.ChoiceField(choices=[], required=False, allow_null=True) use_gift_cards = serializers.ListField(child=serializers.CharField(required=False), required=False) + total = serializers.DecimalField(required=False, decimal_places=2, max_digits=13) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1248,7 +1292,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): model = Order 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', + 'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', 'total', 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards') def validate_payment_provider(self, pp): @@ -1588,6 +1632,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): if validated_data.get('locale', None) is None: validated_data['locale'] = self.context['event'].settings.locale + expected_total = validated_data.pop('total', None) order = Order(event=self.context['event'], **validated_data) if not validated_data.get('expires'): order.set_expires(subevents=[p.get('subevent') for p in positions_data]) @@ -1669,7 +1714,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer): if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax: price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price)) else: - pos._calculate_tax(invoice_address=ia, tax_rule=pos_data.get('tax_rule')) + pos._calculate_tax( + invoice_address=ia, + tax_rule=pos_data.get('tax_rule'), + override_tax_rate=pos_data.get('tax_rate'), + override_tax_code=pos_data.get('tax_code'), + ) price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price)) else: price_after_voucher = listed_price @@ -1701,7 +1751,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer): answers_data = pos_data.pop('answers', []) use_reusable_medium = pos_data.pop('use_reusable_medium', None) pos = pos_data['__instance'] - pos._calculate_tax(invoice_address=ia, tax_rule=pos_data.get('tax_rule', None)) + pos._calculate_tax( + invoice_address=ia, + tax_rule=pos_data.get('tax_rule', None), + override_tax_rate=pos_data.get('tax_rate'), + override_tax_code=pos_data.get('tax_code'), + ) if simulate: pos = WrappedModel(pos) @@ -1800,7 +1855,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer): for tr, val in fee_values: fee_data['tax_rule'] = tr fee_data['value'] = val - f = OrderFee(**fee_data) + f = OrderFee(**{k: v for k, v in fee_data.items() if k not in ( + 'tax_value', + 'value_includes_rounding_correction', + 'tax_value_includes_rounding_correction' + )}) f.order = order._wrapped if simulate else order f._calculate_tax() fees.append(f) @@ -1809,9 +1868,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer): else: f.save() else: - f = OrderFee(**fee_data) + f = OrderFee(**{k: v for k, v in fee_data.items() if k not in ( + 'tax_value', + 'value_includes_rounding_correction', + 'tax_value_includes_rounding_correction' + )}) f.order = order._wrapped if simulate else order - f._calculate_tax() + f._calculate_tax( + override_tax_rate=fee_data.get('tax_rate'), + override_tax_code=fee_data.get('tax_code'), + ) + fee_data['__instance'] = f fees.append(f) if simulate: f.id = 0 @@ -1845,25 +1912,45 @@ class OrderCreateSerializer(I18nAwareModelSerializer): for i, pos_data in enumerate(positions_data): pos = pos_data['__instance'] - if 'tax_rate' in pos_data and pos_data['tax_rate'] != pos.tax_rate: - raise ValidationError(f'Tax rate {pos_data["tax_rate"]} in position {pos.positionid} does not match backend value {pos.tax_rate}.') - if 'tax_code' in pos_data and pos_data['tax_code'] != pos.tax_code: - raise ValidationError(f'Tax code {pos_data["tax_code"]} in position {pos.positionid} does not match backend value {pos.tax_code}.') if 'tax_value' in pos_data and pos_data['tax_value'] != pos.tax_value: - raise ValidationError(f'Tax value {pos_data["tax_value"]} in position {pos.positionid} does not match backend value {pos.tax_value}.') + raise ValidationError(f'Tax value {pos_data["tax_value"]} in position {pos.positionid} does not match backend computation of {pos.tax_value}.') if 'price_includes_rounding_correction' in pos_data and pos_data['price_includes_rounding_correction'] != pos.price_includes_rounding_correction: raise ValidationError( - f'Price rounding {pos_data['price_includes_rounding_correction']} in position {pos.positionid} does not ' - f'match backend value {pos.price_includes_rounding_correction}.' + f'Price rounding {pos_data["price_includes_rounding_correction"]} in position {pos.positionid} does not ' + f'match backend computation of {pos.price_includes_rounding_correction}.' ) if ('tax_value_includes_rounding_correction' in pos_data and pos_data['tax_value_includes_rounding_correction'] != pos.tax_value_includes_rounding_correction): raise ValidationError( - f'Tax rounding {pos_data['tax_value_includes_rounding_correction']} in position {pos.positionid} does not ' - f'match backend value {pos.tax_value_includes_rounding_correction}.' + f'Tax rounding {pos_data["tax_value_includes_rounding_correction"]} in position {pos.positionid} does not ' + f'match backend computation of {pos.tax_value_includes_rounding_correction}.' + ) + + for i, fee_data in enumerate(fees_data): + if '__instance' not in fee_data: # split fee not supported for this, validated in OrderFeeCreateSerializer + continue + fee = fee_data['__instance'] + if 'tax_value' in fee_data and fee_data['tax_value'] != fee.tax_value: + raise ValidationError(f'Tax value {fee_data["tax_value"]} in fee does not match backend computation of {fee.tax_value}.') + if 'value_includes_rounding_correction' in fee_data and fee_data['value_includes_rounding_correction'] != fee.value_includes_rounding_correction: + raise ValidationError( + f'Price rounding {fee_data["value_includes_rounding_correction"]} in fee does not ' + f'match backend computation of {fee.value_includes_rounding_correction}.' + ) + if ('tax_value_includes_rounding_correction' in fee_data and + fee_data['tax_value_includes_rounding_correction'] != fee.tax_value_includes_rounding_correction): + raise ValidationError( + f'Tax rounding {fee_data["tax_value_includes_rounding_correction"]} in fee does not ' + f'match backend computation of {fee.tax_value_includes_rounding_correction}.' ) order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees]) + + if expected_total is not None and expected_total != order.total: + raise ValidationError({ + 'total': [f'Order total of {expected_total} does not match backend computation of {order.total}.'] + }) + if simulate: order.fees = fees order.positions = pos_map.values() diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 15dba58a98..4487dfdec4 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2450,7 +2450,7 @@ class OrderFee(RoundingCorrectionMixin, models.Model): self.fee_type, self.value ) - def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None): + def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None, override_tax_rate=None, override_tax_code=None): if tax_rule: self.tax_rule = tax_rule @@ -2469,7 +2469,8 @@ class OrderFee(RoundingCorrectionMixin, models.Model): self.tax_rule = event.cached_default_tax_rule if self.tax_rule: - tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True) + tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True, + override_tax_rate=override_tax_rate, override_tax_code=override_tax_code) self.tax_rate = tax.rate self.tax_code = tax.code self.tax_value = tax.tax @@ -2832,14 +2833,15 @@ class OrderPosition(AbstractPosition): self.item.id, self.variation.id if self.variation else 0, self.order_id ) - def _calculate_tax(self, tax_rule=None, invoice_address=None): + def _calculate_tax(self, tax_rule=None, invoice_address=None, override_tax_rate=None, override_tax_code=None): self.tax_rule = tax_rule or self.item.tax_rule try: ia = invoice_address or self.order.invoice_address except InvoiceAddress.DoesNotExist: ia = None if self.tax_rule: - tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True) + tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True, + override_tax_rate=override_tax_rate, override_tax_code=override_tax_code) self.tax_rate = tax.rate self.tax_code = tax.code self.tax_value = tax.tax diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index ef78a2195f..47224718a3 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -1034,6 +1034,34 @@ def test_order_create_tax_rule_explicit(token_client, organizer, event, item, qu assert pos.tax_rate == Decimal('19.00') assert pos.tax_value == Decimal('3.67') assert pos.tax_rule == taxrule + assert pos.tax_code == "S/standard" + + +@pytest.mark.django_db +def test_order_create_tax_rule_custom_rate_and_code(token_client, organizer, event, item, quota, question, taxrule): + item.tax_rule = None + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['tax_rule'] = taxrule.pk + res['positions'][0]['item'] = item.pk + res['positions'][0]['tax_rule'] = taxrule.pk + res['positions'][0]['tax_code'] = 'S/reduced' + res['positions'][0]['tax_rate'] = "7.00" + res['positions'][0]['answers'][0]['question'] = question.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']) + pos = o.positions.first() + assert pos.item == item + assert pos.tax_rate == Decimal('7.00') + assert pos.tax_value == Decimal('1.50') + assert pos.tax_rule == taxrule + assert pos.tax_code == "S/reduced" @pytest.mark.django_db @@ -1131,6 +1159,75 @@ def test_order_create_fee_with_auto_tax(token_client, organizer, event, item, qu assert o.total == Decimal('25.30') +@pytest.mark.django_db +def test_order_create_fee_with_explicit_tax_and_percentage(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['tax_rate'] = '7.00' + res['fees'][0]['tax_rule'] = taxrule.pk + res['fees'][0]['_split_taxes_like_products'] = False + res['fees'][0]['_treat_value_as_percentage'] = True + res['fees'][0]['value'] = '10.00' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + 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('7.00') + assert fee.tax_value == Decimal('0.15') + assert o.total == Decimal('25.30') + + +@pytest.mark.django_db +def test_order_create_fee_with_explicit_tax_and_split_requested(token_client, organizer, event, item, quota, question, taxrule): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['fees'][0]['tax_rate'] = '7.00' + res['fees'][0]['tax_rule'] = taxrule.pk + res['fees'][0]['_split_taxes_like_products'] = True + res['fees'][0]['_treat_value_as_percentage'] = True + res['fees'][0]['value'] = '10.00' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + 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 == 400 + assert resp.data == { + 'fees': [{'tax_rate': ['You cannot ask for the value to be distributed over tax rates if you already set the tax rate or computed taxes.']}], + } + + res['fees'][0]['tax_rate'] = '7.00' + res['fees'][0]['tax_value'] = '1.50' + res['fees'][0]['tax_rule'] = taxrule.pk + res['fees'][0]['_treat_value_as_percentage'] = True + res['fees'][0]['value'] = '10.00' + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + 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 == 400 + assert resp.data == { + 'fees': [{'tax_value': ['You cannot ask for the value to be treated as a percentage if you already computed taxes.']}], + } + + @pytest.mark.django_db def test_order_create_negative_fee_with_auto_tax(token_client, organizer, event, item, quota, question, taxrule): res = copy.deepcopy(ORDER_CREATE_PAYLOAD) @@ -3725,19 +3822,17 @@ def test_order_create_use_gift_card_invalid_secret(token_client, organizer, even @pytest.mark.django_db @pytest.mark.parametrize("rounding_mode,field,right,wrong,error", [ - ("line", "tax_rate", "19.00", "22.00", "Tax rate 22.0000 in position 1 does not match backend value 19."), - ("line", "tax_value", "2.39", "2.50", "Tax value 2.50 in position 1 does not match backend value 2.39."), - ("line", "tax_code", "S/standard", "S/reduced", "Tax code S/reduced in position 1 does not match backend value S/standard."), - ("line", "price_includes_rounding_correction", "0.00", "0.01", "Price rounding 0.01 in position 1 does not match backend value 0.00."), - ("line", "tax_value_includes_rounding_correction", "0.00", "0.01", "Tax rounding 0.01 in position 1 does not match backend value 0.00."), - ("sum_by_net", "tax_value", "2.40", "2.39", "Tax value 2.39 in position 1 does not match backend value 2.40."), - ("sum_by_net", "price_includes_rounding_correction", "0.01", "0.00", "Price rounding 0.00 in position 1 does not match backend value 0.01."), - ("sum_by_net", "tax_value_includes_rounding_correction", "0.01", "0.00", "Tax rounding 0.00 in position 1 does not match backend value 0.01."), + ("line", "tax_value", "2.39", "2.50", "Tax value 2.50 in position 1 does not match backend computation of 2.39."), + ("line", "price_includes_rounding_correction", "0.00", "0.01", "Price rounding 0.01 in position 1 does not match backend computation of 0.00."), + ("line", "tax_value_includes_rounding_correction", "0.00", "0.01", "Tax rounding 0.01 in position 1 does not match backend computation of 0.00."), + ("sum_by_net", "tax_value", "2.40", "2.39", "Tax value 2.39 in position 1 does not match backend computation of 2.40."), + ("sum_by_net", "price_includes_rounding_correction", "0.01", "0.00", "Price rounding 0.00 in position 1 does not match backend computation of 0.01."), + ("sum_by_net", "tax_value_includes_rounding_correction", "0.01", "0.00", "Tax rounding 0.00 in position 1 does not match backend computation of 0.01."), ]) def test_order_tax_match_validation(token_client, organizer, event, item, quota, question, taxrule, rounding_mode, field, right, wrong, error): res = copy.deepcopy(ORDER_CREATE_PAYLOAD) - del res['fees'] # not explicitly supported for this for now + del res['fees'] res['tax_rounding_mode'] = rounding_mode res['positions'][0]['item'] = item.pk res['positions'][0]['price'] = "15.00" @@ -3759,3 +3854,69 @@ def test_order_tax_match_validation(token_client, organizer, event, item, quota, ), format='json', data=res ) assert resp.status_code == 201 + + +@pytest.mark.django_db +@pytest.mark.parametrize("rounding_mode,field,right,wrong,error", [ + ("line", "tax_value", "1.60", "1.61", "Tax value 1.61 in fee does not match backend computation of 1.60."), + ("line", "value_includes_rounding_correction", "0.00", "0.01", "Price rounding 0.01 in fee does not match backend computation of 0.00."), + ("line", "tax_value_includes_rounding_correction", "0.00", "0.01", "Tax rounding 0.01 in fee does not match backend computation of 0.00."), + ("sum_by_net", "tax_value", "1.59", "1.60", "Tax value 1.60 in fee does not match backend computation of 1.59."), + ("sum_by_net", "value_includes_rounding_correction", "-0.01", "0.00", "Price rounding 0.00 in fee does not match backend computation of -0.01."), + ("sum_by_net", "tax_value_includes_rounding_correction", "-0.01", "0.00", "Tax rounding 0.00 in fee does not match backend computation of -0.01."), +]) +def test_order_tax_match_validation_of_fee(token_client, organizer, event, item, quota, question, taxrule, + rounding_mode, field, right, wrong, error): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['tax_rounding_mode'] = rounding_mode + res['positions'][0]['item'] = item.pk + res['positions'][0]['price'] = "5.00" + res['positions'][0]['tax_rule'] = taxrule.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['fees'][0]['value'] = "10.00" + res['fees'][0]['tax_rule'] = taxrule.pk + res['fees'][0][field] = wrong + 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 == [error] + + res['fees'][0][field] = right + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + +@pytest.mark.django_db +def test_order_expect_total(token_client, organizer, event, item, quota, question, taxrule): + # Force a rounding difference to make sure its the last step of total computation that is compared + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + del res['fees'] + res['tax_rounding_mode'] = "sum_by_net" + res['positions'][0]['item'] = item.pk + res['positions'][0]['price'] = "15.00" + res['positions'][0]['tax_rule'] = taxrule.pk + res['total'] = "15.00" + 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 == { + 'total': ['Order total of 15.00 does not match backend computation of 15.01.'] + } + + res['total'] = "15.01" + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201