From 5f50aa95eba806717e9ca42628d0894ab84fccdf Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Tue, 30 Jun 2020 11:13:33 +0200 Subject: [PATCH] Add TaxRule selection in OrderPositionChange (#1700) Co-authored-by: Raphael Michel --- src/pretix/api/serializers/order.py | 2 + src/pretix/api/views/order.py | 8 ++- src/pretix/base/models/orders.py | 9 ++- src/pretix/base/services/orders.py | 23 +++++++ src/pretix/base/services/pricing.py | 6 +- src/pretix/control/forms/orders.py | 20 +++++- src/pretix/control/logdisplay.py | 16 +++++ .../templates/pretixcontrol/order/change.html | 13 ++++ src/pretix/control/views/orders.py | 6 ++ .../static/pretixcontrol/js/ui/orderchange.js | 6 +- src/tests/api/test_orders.py | 9 +++ src/tests/base/test_orders.py | 66 +++++++++++++++++++ 12 files changed, 176 insertions(+), 8 deletions(-) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 82e12a930..96a48f707 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -428,12 +428,14 @@ class PriceCalcSerializer(serializers.Serializer): item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True) variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True) subevent = serializers.PrimaryKeyRelatedField(queryset=SubEvent.objects.none(), required=False, allow_null=True) + tax_rule = serializers.PrimaryKeyRelatedField(queryset=TaxRule.objects.none(), required=False, allow_null=True) locale = serializers.CharField(allow_null=True, required=False) def __init__(self, *args, **kwargs): event = kwargs.pop('event') super().__init__(*args, **kwargs) self.fields['item'].queryset = event.items.all() + self.fields['tax_rule'].queryset = event.tax_rules.all() self.fields['variation'].queryset = ItemVariation.objects.filter(item__event=event) if event.has_subevents: self.fields['subevent'].queryset = event.subevents.all() diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 8ce91f74d..74b2682fc 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -783,7 +783,8 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS { "item": 2, "variation": null, - "subevent": 3 + "subevent": 3, + "tax_rule": 4, } Sample output: @@ -837,7 +838,11 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS if data.get('subevent'): kwargs['subevent'] = data.get('subevent') + if data.get('tax_rule'): + kwargs['tax_rule'] = data.get('tax_rule') + price = get_price(**kwargs) + tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule) with language(data.get('locale') or self.request.event.settings.locale): return Response({ 'gross': price.gross, @@ -846,6 +851,7 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS 'rate': price.rate, 'name': str(price.name), 'tax': price.tax, + 'tax_rule': tr.pk if tr else None, }) @action(detail=True, url_name='download', url_path='download/(?P[^/]+)') diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 5e1ed913a..4370b781c 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1800,7 +1800,10 @@ class OrderFee(models.Model): self.fee_type, self.value ) - def _calculate_tax(self): + def _calculate_tax(self, tax_rule=None): + if tax_rule: + self.tax_rule = tax_rule + try: ia = self.order.invoice_address except InvoiceAddress.DoesNotExist: @@ -1956,8 +1959,8 @@ class OrderPosition(AbstractPosition): self.item.id, self.variation.id if self.variation else 0, self.order_id ) - def _calculate_tax(self): - self.tax_rule = self.item.tax_rule + def _calculate_tax(self, tax_rule=None): + self.tax_rule = tax_rule or self.item.tax_rule try: ia = self.order.invoice_address except InvoiceAddress.DoesNotExist: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 784c6c1c1..3314eea55 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1160,6 +1160,7 @@ class OrderChangeManager: SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) SeatOperation = namedtuple('SubeventOperation', ('position', 'seat')) PriceOperation = namedtuple('PriceOperation', ('position', 'price')) + TaxRuleOperation = namedtuple('TaxRuleOperation', ('position', 'tax_rule')) CancelOperation = namedtuple('CancelOperation', ('position',)) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat')) SplitOperation = namedtuple('SplitOperation', ('position',)) @@ -1272,6 +1273,10 @@ class OrderChangeManager: self._operations.append(self.PriceOperation(position, price)) + def change_tax_rule(self, position_or_fee, tax_rule: TaxRule): + self._operations.append(self.TaxRuleOperation(position_or_fee, tax_rule)) + self._invoice_dirty = True + def recalculate_taxes(self): positions = self.order.positions.select_related('item', 'item__tax_rule') ia = self._invoice_address @@ -1594,6 +1599,24 @@ class OrderChangeManager: op.position.price = op.price.gross op.position._calculate_tax() op.position.save() + elif isinstance(op, self.TaxRuleOperation): + if isinstance(op.position, OrderPosition): + self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={ + 'position': op.position.pk, + 'positionid': op.position.positionid, + 'addon_to': op.position.addon_to_id, + 'old_taxrule': op.position.tax_rule.pk, + 'new_taxrule': op.tax_rule.pk + }) + elif isinstance(op.position, OrderFee): + self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={ + 'fee': op.position.pk, + 'fee_type': op.position.fee_type, + 'old_taxrule': op.position.tax_rule.pk, + 'new_taxrule': op.tax_rule.pk + }) + op.position._calculate_tax(op.tax_rule) + op.position.save() elif isinstance(op, self.CancelFeeOperation): self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={ 'fee': op.fee.pk, diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 16de47374..71795c74d 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -13,7 +13,7 @@ def get_price(item: Item, variation: ItemVariation = None, subevent: SubEvent = None, custom_price_is_net: bool = False, addon_to: AbstractPosition = None, invoice_address: InvoiceAddress = None, force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'), - max_discount: Decimal = None) -> TaxedPrice: + max_discount: Decimal = None, tax_rule=None) -> TaxedPrice: if addon_to: try: iao = addon_to.item.addons.get(addon_category_id=item.category_id) @@ -35,7 +35,9 @@ def get_price(item: Item, variation: ItemVariation = None, if voucher: price = voucher.calculate_price(price, max_discount=max_discount) - if item.tax_rule: + if tax_rule is not None: + tax_rule = tax_rule + elif item.tax_rule: tax_rule = item.tax_rule else: tax_rule = TaxRule( diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index daea5ae6a..df539e924 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -20,7 +20,7 @@ from pretix.base.forms.widgets import ( DatePickerWidget, SplitDateTimePickerWidget, ) from pretix.base.models import ( - InvoiceAddress, ItemAddOn, Order, OrderFee, OrderPosition, + InvoiceAddress, ItemAddOn, Order, OrderFee, OrderPosition, TaxRule, ) from pretix.base.models.event import SubEvent from pretix.base.services.pricing import get_price @@ -348,6 +348,11 @@ class OrderPositionChangeForm(forms.Form): localize=True, label=_('New price (gross)') ) + tax_rule = forms.ModelChoiceField( + TaxRule.objects.none(), + required=False, + empty_label=_('(Unchanged)') + ) operation_secret = forms.BooleanField( required=False, label=_('Generate a new secret') @@ -361,6 +366,10 @@ class OrderPositionChangeForm(forms.Form): label=_('Split into new order') ) + @staticmethod + def taxrule_label_from_instance(obj): + return f"{obj.name} ({obj.rate} %)" + def __init__(self, *args, **kwargs): instance = kwargs.pop('instance') items = kwargs.pop('items') @@ -386,6 +395,9 @@ class OrderPositionChangeForm(forms.Form): else: del self.fields['subevent'] + self.fields['tax_rule'].queryset = instance.event.tax_rules.all() + self.fields['tax_rule'].label_from_instance = self.taxrule_label_from_instance + if not instance.seat: del self.fields['seat'] @@ -415,6 +427,11 @@ class OrderFeeChangeForm(forms.Form): localize=True, label=_('New price (gross)') ) + tax_rule = forms.ModelChoiceField( + TaxRule.objects.none(), + required=False, + empty_label=_('(Unchanged)') + ) operation_cancel = forms.BooleanField( required=False, label=_('Remove this fee') @@ -427,6 +444,7 @@ class OrderFeeChangeForm(forms.Form): initial['value'] = instance.value kwargs['initial'] = initial super().__init__(*args, **kwargs) + self.fields['tax_rule'].queryset = instance.order.event.tax_rules.all() change_decimal_field(self.fields['value'], instance.order.event.currency) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 6005a8837..4aec735c1 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -15,6 +15,7 @@ from i18nfield.strings import LazyI18nString from pretix.base.models import ( Checkin, CheckinList, Event, ItemVariation, LogEntry, OrderPosition, + TaxRule, ) from pretix.base.signals import logentry_display from pretix.base.templatetags.money import money_filter @@ -65,6 +66,21 @@ def _display_order_changed(event: Event, logentry: LogEntry): old_price=money_filter(Decimal(data['old_price']), event.currency), new_price=money_filter(Decimal(data['new_price']), event.currency), ) + elif logentry.action_type == 'pretix.event.order.changed.tax_rule': + if 'positionid' in data: + return text + ' ' + _('Tax rule of position #{posid} changed from {old_rule} ' + 'to {new_rule}.').format( + posid=data.get('positionid', '?'), + old_rule=TaxRule.objects.get(pk=data['old_taxrule']), + new_rule=TaxRule.objects.get(pk=data['new_taxrule']), + ) + elif 'fee' in data: + return text + ' ' + _('Tax rule of fee #{fee} changed from {old_rule} ' + 'to {new_rule}.').format( + fee=data.get('fee', '?'), + old_rule=TaxRule.objects.get(pk=data['old_taxrule']), + new_rule=TaxRule.objects.get(pk=data['new_taxrule']), + ) elif logentry.action_type == 'pretix.event.order.changed.addfee': return text + ' ' + str(_('A fee has been added')) elif logentry.action_type == 'pretix.event.order.changed.feevalue': diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index 2930d2a11..acaa0bf3e 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -140,6 +140,18 @@ +
+
+ {% trans "Tax rule" %} +
+
+ {{ position.tax_rule.name }} ({{ position.tax_rule.rate }} %) +
+
+ {% bootstrap_field position.form.tax_rule layout='inline' %} +
+
+
{% trans "Price" %} @@ -307,6 +319,7 @@
{% bootstrap_field fee.form.value addon_after=request.event.currency layout='inline' %} {% trans "including all taxes" %} + {% bootstrap_field fee.form.tax_rule layout='inline' %}
diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 4b7513138..0976d3f0a 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1476,6 +1476,9 @@ class OrderChange(OrderView): if f.form.cleaned_data['value'] != f.value: ocm.change_fee(f, f.form.cleaned_data['value']) + if f.form.cleaned_data['tax_rule'] and f.form.cleaned_data['tax_rule'] != f.tax_rule: + ocm.change_tax_rule(f, f.form.cleaned_data['tax_rule']) + except OrderError as e: f.custom_error = str(e) return False @@ -1523,6 +1526,9 @@ class OrderChange(OrderView): if p.form.cleaned_data['price'] != p.price: ocm.change_price(p, p.form.cleaned_data['price']) + if p.form.cleaned_data['tax_rule'] and p.form.cleaned_data['tax_rule'] != p.tax_rule: + ocm.change_tax_rule(p, p.form.cleaned_data['tax_rule']) + if p.form.cleaned_data['operation_split']: ocm.split(p) diff --git a/src/pretix/static/pretixcontrol/js/ui/orderchange.js b/src/pretix/static/pretixcontrol/js/ui/orderchange.js index ce851a511..952ec58ea 100644 --- a/src/pretix/static/pretixcontrol/js/ui/orderchange.js +++ b/src/pretix/static/pretixcontrol/js/ui/orderchange.js @@ -8,6 +8,7 @@ $(function () { var url = $(this).attr("data-pricecalc-endpoint"); var $itemvar = $(this).find("[name*=itemvar]"); var $subevent = $(this).find("[name*=subevent]"); + var $tax_rule = $(this).find("[name*=tax_rule]"); var $price = $(this).find("[name*=price]"); var update_price = function () { console.log(url); @@ -31,11 +32,13 @@ $(function () { 'item': item, 'variation': variation, 'subevent': $subevent.val(), + 'tax_rule': $tax_rule.val(), 'locale': $("body").attr("data-pretixlocale"), }), 'contentType': "application/json", 'success': function (data) { $price.val(data.gross_formatted); + $tax_rule.val(data.tax_rule); $price.closest(".field-container").find(".loading-indicator").remove(); }, // 'error': … @@ -45,7 +48,8 @@ $(function () { } ); }; - $itemvar.on("change", update_price); + $itemvar.on("change", function () { $tax_rule.val(null); update_price() }); + $tax_rule.on("change", update_price); $subevent.on("change", update_price).on("change", function () { var seat = $(this).closest(".form-order-change").find("[id$=seat]"); if (seat.length) { diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 38fc3707d..b65a5efe7 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -4224,6 +4224,7 @@ def test_orderposition_price_calculation(token_client, organizer, event, order, 'name': '', 'net': Decimal('23.00'), 'rate': Decimal('0.00'), + 'tax_rule': None, 'tax': Decimal('0.00') } @@ -4246,6 +4247,7 @@ def test_orderposition_price_calculation_item_with_tax(token_client, organizer, 'name': '', 'net': Decimal('19.33'), 'rate': Decimal('19.00'), + 'tax_rule': taxrule.pk, 'tax': Decimal('3.67') } @@ -4270,6 +4272,7 @@ def test_orderposition_price_calculation_item_with_variation(token_client, organ 'name': '', 'net': Decimal('12.00'), 'rate': Decimal('0.00'), + 'tax_rule': None, 'tax': Decimal('0.00') } @@ -4295,6 +4298,7 @@ def test_orderposition_price_calculation_subevent(token_client, organizer, event 'name': '', 'net': Decimal('23.00'), 'rate': Decimal('0.00'), + 'tax_rule': None, 'tax': Decimal('0.00') } @@ -4322,6 +4326,7 @@ def test_orderposition_price_calculation_subevent_with_override(token_client, or 'name': '', 'net': Decimal('12.00'), 'rate': Decimal('0.00'), + 'tax_rule': None, 'tax': Decimal('0.00') } @@ -4350,6 +4355,7 @@ def test_orderposition_price_calculation_voucher_matching(token_client, organize 'name': '', 'net': Decimal('15.00'), 'rate': Decimal('0.00'), + 'tax_rule': None, 'tax': Decimal('0.00') } @@ -4377,6 +4383,7 @@ def test_orderposition_price_calculation_voucher_not_matching(token_client, orga 'name': '', 'net': Decimal('23.00'), 'rate': Decimal('0.00'), + 'tax_rule': None, 'tax': Decimal('0.00') } @@ -4401,6 +4408,7 @@ def test_orderposition_price_calculation_net_price(token_client, organizer, even 'name': '', 'net': Decimal('10.00'), 'rate': Decimal('19.00'), + 'tax_rule': taxrule.pk, 'tax': Decimal('1.90') } @@ -4432,5 +4440,6 @@ def test_orderposition_price_calculation_reverse_charge(token_client, organizer, 'name': '', 'net': Decimal('10.00'), 'rate': Decimal('0.00'), + 'tax_rule': taxrule.pk, 'tax': Decimal('0.00') } diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 55304b664..0e0befadf 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -2262,6 +2262,72 @@ class OrderChangeManagerTests(TestCase): self.order.refresh_from_db() assert self.order.total == Decimal('46.00') + @classscope(attr='o') + def test_change_taxrate(self): + self.ocm.change_tax_rule(self.op1, self.tr19) + self.ocm.commit() + self.order.refresh_from_db() + nop = self.order.positions.first() + assert nop.price == Decimal('23.00') + assert nop.tax_rule != self.ticket.tax_rule + assert nop.tax_rate == self.tr19.rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.tr19.rate))) == nop.tax_value + + @classscope(attr='o') + def test_change_taxrate_and_product(self): + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.change_tax_rule(self.op1, self.tr7) + self.ocm.commit() + self.order.refresh_from_db() + nop = self.order.positions.first() + assert nop.item == self.shirt + assert nop.price == Decimal('23.00') + assert nop.tax_rule != self.shirt.tax_rule + assert nop.tax_rate == self.tr7.rate + assert round_decimal(nop.price * (1 - 100 / (100 + self.tr7.rate))) == nop.tax_value + + @classscope(attr='o') + def test_change_taxrate_to_reverse_charge(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + InvoiceAddress.objects.create( + order=self.order, is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + + self.ocm.change_tax_rule(self.op1, self.tr19) + self.ocm.commit() + self.order.refresh_from_db() + nop = self.order.positions.first() + assert nop.price == Decimal('23.00') + assert nop.tax_rule == self.tr19 + assert nop.tax_rate == Decimal('0.00') + assert nop.tax_value == Decimal('0.00') + + @classscope(attr='o') + def test_change_taxrate_from_reverse_charge(self): + self.tr7.eu_reverse_charge = True + self.tr7.home_country = Country('DE') + self.tr7.save() + nop = self.order.positions.first() + nop.tax_value = Decimal('0.00') + nop.tax_rate = Decimal('0.00') + nop.save() + InvoiceAddress.objects.create( + order=self.order, is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + + self.ocm.change_tax_rule(self.op1, self.tr19) + self.ocm.commit() + self.order.refresh_from_db() + nop = self.order.positions.first() + assert nop.price == Decimal('23.00') + assert nop.tax_rule == self.tr19 + assert nop.tax_rate == Decimal('19.00') + assert nop.tax_value == Decimal('3.67') + @pytest.mark.django_db def test_autocheckin(clist_autocheckin, event):