Add TaxRule selection in OrderPositionChange (#1700)

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
This commit is contained in:
Martin Gross
2020-06-30 11:13:33 +02:00
committed by GitHub
parent 626e332886
commit 5f50aa95eb
12 changed files with 176 additions and 8 deletions

View File

@@ -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()

View File

@@ -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<output>[^/]+)')

View File

@@ -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:

View File

@@ -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,

View File

@@ -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(

View File

@@ -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)

View File

@@ -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':

View File

@@ -140,6 +140,18 @@
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Tax rule" %}</strong>
</div>
<div class="col-sm-5">
{{ position.tax_rule.name }} ({{ position.tax_rule.rate }} %)
</div>
<div class="col-sm-4 field-container">
{% bootstrap_field position.form.tax_rule layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Price" %}</strong>
@@ -307,6 +319,7 @@
<div class="col-sm-4 field-container">
{% bootstrap_field fee.form.value addon_after=request.event.currency layout='inline' %}
<small><strong>{% trans "including all taxes" %}</strong></small>
{% bootstrap_field fee.form.tax_rule layout='inline' %}
</div>
</div>

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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')
}

View File

@@ -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):