From 6e9d921af65c2a778d8afaa78b7e6c9a0dfeb4fc Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 8 Jul 2020 15:00:13 +0200 Subject: [PATCH] Allow country specific tax rules (#1714) --- .../0156_cartposition_override_tax_rate.py | 18 ++ src/pretix/base/models/items.py | 38 +++- src/pretix/base/models/orders.py | 28 ++- src/pretix/base/models/tax.py | 46 +++- src/pretix/base/services/cart.py | 50 ++--- src/pretix/base/services/invoices.py | 18 +- src/pretix/base/services/orders.py | 52 +++-- src/pretix/base/services/pricing.py | 26 +-- src/pretix/control/forms/event.py | 5 + src/pretix/control/forms/orders.py | 13 +- .../pretixcontrol/event/tax_edit.html | 18 +- src/pretix/control/views/event.py | 3 +- src/pretix/control/views/orders.py | 16 +- src/tests/api/test_orders.py | 32 +++ src/tests/base/test_orders.py | 84 ++++++++ src/tests/base/test_pricing.py | 33 +++ src/tests/base/test_taxrules.py | 100 ++++++--- src/tests/control/test_orders.py | 38 +++- src/tests/presale/test_cart.py | 202 ++++++++++++++++++ src/tests/presale/test_checkout.py | 57 +++++ 20 files changed, 716 insertions(+), 161 deletions(-) create mode 100644 src/pretix/base/migrations/0156_cartposition_override_tax_rate.py diff --git a/src/pretix/base/migrations/0156_cartposition_override_tax_rate.py b/src/pretix/base/migrations/0156_cartposition_override_tax_rate.py new file mode 100644 index 0000000000..d91f159609 --- /dev/null +++ b/src/pretix/base/migrations/0156_cartposition_override_tax_rate.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.6 on 2020-06-28 19:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0155_quota_release_after_exit'), + ] + + operations = [ + migrations.AddField( + model_name='cartposition', + name='override_tax_rate', + field=models.DecimalField(decimal_places=2, max_digits=10, null=True), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index c3b66adb34..b76573c4e2 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -448,24 +448,32 @@ class Item(LoggedModel): return self.event.settings.show_quota_left return self.show_quota_left - def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False): + def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False): price = price if price is not None else self.default_price if not self.tax_rule: t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='') else: - t = self.tax_rule.tax(price, base_price_is=base_price_is, - currency=currency or self.event.currency) + t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address, + override_tax_rate=override_tax_rate, currency=currency or self.event.currency) if include_bundled: for b in self.bundles.all(): if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id: if b.bundled_variation: - bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', + invoice_address=invoice_address, + currency=currency) else: - bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) - compare_price = self.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + bprice = b.bundled_item.tax(b.designated_price * b.count, + invoice_address=invoice_address, + base_price_is='gross', + currency=currency) + compare_price = self.tax_rule.tax(b.designated_price * b.count, + override_tax_rate=override_tax_rate, + invoice_address=invoice_address, + currency=currency) t.net += bprice.net - compare_price.net t.tax += bprice.tax - compare_price.tax t.name = "MIXED!" @@ -673,23 +681,31 @@ class ItemVariation(models.Model): def price(self): return self.default_price if self.default_price is not None else self.item.default_price - def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False): + def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False, override_tax_rate=None, + invoice_address=None): price = price if price is not None else self.price if not self.item.tax_rule: t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='') else: - t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency) + t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency, + override_tax_rate=override_tax_rate, + invoice_address=invoice_address) if include_bundled: for b in self.item.bundles.all(): if b.designated_price and b.bundled_item.tax_rule_id != self.item.tax_rule_id: if b.bundled_variation: - bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', + currency=currency, + invoice_address=invoice_address) else: - bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) - compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', currency=currency) + bprice = b.bundled_item.tax(b.designated_price * b.count, base_price_is='gross', + currency=currency, + invoice_address=invoice_address) + compare_price = self.item.tax_rule.tax(b.designated_price * b.count, base_price_is='gross', + currency=currency, invoice_address=invoice_address) t.net += bprice.net - compare_price.net t.tax += bprice.tax - compare_price.tax t.name = "MIXED!" diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index ae365f396e..cba2124c15 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1813,13 +1813,9 @@ class OrderFee(models.Model): self.tax_rule = self.order.event.settings.tax_rate_default if self.tax_rule: - if self.tax_rule.tax_applicable(ia): - tax = self.tax_rule.tax(self.value, base_price_is='gross') - self.tax_rate = tax.rate - self.tax_value = tax.tax - else: - self.tax_value = Decimal('0.00') - self.tax_rate = Decimal('0.00') + tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia) + self.tax_rate = tax.rate + self.tax_value = tax.tax else: self.tax_value = Decimal('0.00') self.tax_rate = Decimal('0.00') @@ -1966,13 +1962,9 @@ class OrderPosition(AbstractPosition): except InvoiceAddress.DoesNotExist: ia = None if self.tax_rule: - if self.tax_rule.tax_applicable(ia): - tax = self.tax_rule.tax(self.price, base_price_is='gross') - self.tax_rate = tax.rate - self.tax_value = tax.tax - else: - self.tax_value = Decimal('0.00') - self.tax_rate = Decimal('0.00') + tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross') + self.tax_rate = tax.rate + self.tax_value = tax.tax else: self.tax_value = Decimal('0.00') self.tax_rate = Decimal('0.00') @@ -2111,6 +2103,10 @@ class CartPosition(AbstractPosition): includes_tax = models.BooleanField( default=True ) + override_tax_rate = models.DecimalField( + max_digits=10, decimal_places=2, + null=True, blank=True + ) is_bundled = models.BooleanField(default=False) objects = ScopedManager(organizer='event__organizer') @@ -2127,6 +2123,8 @@ class CartPosition(AbstractPosition): @property def tax_rate(self): if self.includes_tax: + if self.override_tax_rate is not None: + return self.override_tax_rate return self.item.tax(self.price, base_price_is='gross').rate else: return Decimal('0.00') @@ -2134,7 +2132,7 @@ class CartPosition(AbstractPosition): @property def tax_value(self): if self.includes_tax: - return self.item.tax(self.price, base_price_is='gross').tax + return self.item.tax(self.price, override_tax_rate=self.override_tax_rate, base_price_is='gross').tax else: return Decimal('0.00') diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index bc288097e6..391af5ca26 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -164,16 +164,39 @@ class TaxRule(LoggedModel): def has_custom_rules(self): return self.custom_rules and self.custom_rules != '[]' - def tax(self, base_price, base_price_is='auto', currency=None): + def tax_rate_for(self, invoice_address): + if not self._tax_applicable(invoice_address): + return Decimal('0.00') + if self.has_custom_rules: + rule = self.get_matching_rule(invoice_address) + if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None: + return Decimal(rule.get('rate')) + return Decimal(self.rate) + + def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None, + subtract_from_gross=Decimal('0.00')): from .event import Event try: currency = currency or self.event.currency except Event.DoesNotExist: pass - if self.rate == Decimal('0.00'): + + rate = Decimal(self.rate) + if override_tax_rate is not None: + rate = override_tax_rate + elif invoice_address: + adjust_rate = self.tax_rate_for(invoice_address) + if adjust_rate != rate: + normal_price = self.tax(base_price, base_price_is, currency, subtract_from_gross=subtract_from_gross) + base_price = normal_price.net + base_price_is = 'net' + subtract_from_gross = Decimal('0.00') + rate = adjust_rate + + if rate == Decimal('0.00'): return TaxedPrice( - net=base_price, gross=base_price, tax=Decimal('0.00'), - rate=self.rate, name=self.name + net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'), + rate=rate, name=self.name ) if base_price_is == 'auto': @@ -183,19 +206,22 @@ class TaxRule(LoggedModel): base_price_is = 'net' if base_price_is == 'gross': - gross = base_price - net = round_decimal(gross - (base_price * (1 - 100 / (100 + self.rate))), + gross = max(Decimal('0.00'), base_price - subtract_from_gross) + net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))), currency) elif base_price_is == 'net': net = base_price - gross = round_decimal((net * (1 + self.rate / 100)), - currency) + gross = round_decimal((net * (1 + rate / 100)), currency) + if subtract_from_gross: + gross -= subtract_from_gross + net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))), + currency) else: raise ValueError('Unknown base price type: {}'.format(base_price_is)) return TaxedPrice( net=net, gross=gross, tax=gross - net, - rate=self.rate, name=self.name + rate=rate, name=self.name ) @property @@ -243,7 +269,7 @@ class TaxRule(LoggedModel): return False - def tax_applicable(self, invoice_address): + def _tax_applicable(self, invoice_address): if self._custom_rules: rule = self.get_matching_rule(invoice_address) return rule.get('action', 'vat') == 'vat' diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index b627decd47..87bdb641c5 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -907,6 +907,7 @@ class CartManager: price=op.price.gross, expires=self._expiry, cart_id=self.cart_id, voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None, subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat, + override_tax_rate=op.price.rate, price_before_voucher=op.price_before_voucher.gross if op.price_before_voucher is not None else None ) if self.event.settings.attendee_names_asked: @@ -940,7 +941,7 @@ class CartManager: new_cart_positions.append(CartPosition( event=self.event, item=b.item, variation=b.variation, price=b.price.gross, expires=self._expiry, cart_id=self.cart_id, - voucher=None, addon_to=cp, + voucher=None, addon_to=cp, override_tax_rate=b.price.rate, subevent=b.subevent, includes_tax=b.includes_tax, is_bundled=True )) @@ -1032,19 +1033,15 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress for pos in positions: if not pos.item.tax_rule: continue - charge_tax = pos.item.tax_rule.tax_applicable(invoice_address) - if pos.includes_tax and not charge_tax: - price = pos.item.tax(pos.price, base_price_is='gross').net - totaldiff += price - pos.price - pos.price = price - pos.includes_tax = False - pos.save(update_fields=['price', 'includes_tax']) - elif charge_tax and not pos.includes_tax: - price = pos.item.tax(pos.price, base_price_is='net').gross - totaldiff += price - pos.price - pos.price = price - pos.includes_tax = True - pos.save(update_fields=['price', 'includes_tax']) + rate = pos.item.tax_rule.tax_rate_for(invoice_address) + + if pos.tax_rate != rate: + current_net = pos.price - pos.tax_value + new_gross = pos.item.tax(current_net, base_price_is='net', invoice_address=invoice_address).gross + pos.price = new_gross + pos.includes_tax = rate != Decimal('0.00') + pos.override_tax_rate = rate + pos.save(update_fields=['price', 'includes_tax', 'override_tax_rate']) return totaldiff @@ -1092,23 +1089,14 @@ def get_fees(event, request, total, invoice_address, provider, positions): if payment_fee: payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero() - if payment_fee_tax_rule.tax_applicable(invoice_address): - payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross') - fees.append(OrderFee( - fee_type=OrderFee.FEE_TYPE_PAYMENT, - value=payment_fee, - tax_rate=payment_fee_tax.rate, - tax_value=payment_fee_tax.tax, - tax_rule=payment_fee_tax_rule - )) - else: - fees.append(OrderFee( - fee_type=OrderFee.FEE_TYPE_PAYMENT, - value=payment_fee, - tax_rate=Decimal('0.00'), - tax_value=Decimal('0.00'), - tax_rule=payment_fee_tax_rule - )) + payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address) + fees.append(OrderFee( + fee_type=OrderFee.FEE_TYPE_PAYMENT, + value=payment_fee, + tax_rate=payment_fee_tax.rate, + tax_value=payment_fee_tax.tax, + tax_rule=payment_fee_tax_rule + )) return fees diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 38f7dfef7b..ed2f9ff4d0 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -24,7 +24,7 @@ from pretix.base.i18n import language from pretix.base.models import ( Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee, ) -from pretix.base.models.tax import EU_CURRENCIES +from pretix.base.models.tax import EU_COUNTRIES, EU_CURRENCIES from pretix.base.services.tasks import TransactionAwareTask from pretix.base.settings import GlobalSettingsObject from pretix.base.signals import invoice_line_text, periodic_task @@ -181,11 +181,17 @@ def build_invoice(invoice: Invoice) -> Invoice: if reverse_charge: if invoice.additional_text: invoice.additional_text += "

" - invoice.additional_text += pgettext( - "invoice", - "Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability " - "rests with the service recipient." - ) + if str(invoice.invoice_to_country) in EU_COUNTRIES: + invoice.additional_text += pgettext( + "invoice", + "Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability " + "rests with the service recipient." + ) + else: + invoice.additional_text += pgettext( + "invoice", + "VAT liability rests with the service recipient." + ) invoice.reverse_charge = True invoice.save() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 87dd0fc309..420ed754ec 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -34,7 +34,7 @@ from pretix.base.models.orders import ( generate_secret, ) from pretix.base.models.organizer import TeamAPIToken -from pretix.base.models.tax import TaxedPrice, TaxRule +from pretix.base.models.tax import TaxRule from pretix.base.payment import BasePaymentProvider, PaymentException from pretix.base.reldate import RelativeDateWrapper from pretix.base.services import tickets @@ -1263,7 +1263,8 @@ class OrderChangeManager: self._operations.append(self.RegenerateSecretOperation(position)) def change_price(self, position: OrderPosition, price: Decimal): - price = position.item.tax(price, base_price_is='gross') + tax_rule = self._current_tax_rules().get(position.pk, position.tax_rule) + price = tax_rule.tax(price, base_price_is='gross') if position.issued_gift_cards.exists(): raise OrderError(self.error_messages['gift_card_change']) @@ -1279,27 +1280,38 @@ class OrderChangeManager: self._operations.append(self.TaxRuleOperation(position_or_fee, tax_rule)) self._invoice_dirty = True - def recalculate_taxes(self): + def _current_tax_rules(self): + tax_rules = {} + for p in self._operations: + if isinstance(p, self.TaxRuleOperation): + tax_rules[p.position.pk] = p.tax_rule + elif isinstance(p, self.ItemOperation): + tax_rules[p.position.pk] = p.item.tax_rule + return tax_rules + + def recalculate_taxes(self, keep='net'): positions = self.order.positions.select_related('item', 'item__tax_rule') ia = self._invoice_address + tax_rules = self._current_tax_rules() + for pos in positions: - if not pos.item.tax_rule: + tax_rule = tax_rules.get(pos.pk, pos.tax_rule) + if not tax_rule: continue if not pos.price: continue - charge_tax = pos.item.tax_rule.tax_applicable(ia) - if pos.tax_value and not charge_tax: - net_price = pos.price - pos.tax_value - price = TaxedPrice(gross=net_price, net=net_price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='') - if price.gross != pos.price: - self._totaldiff += price.gross - pos.price - self._operations.append(self.PriceOperation(pos, price)) - elif charge_tax and not pos.tax_value: - price = pos.item.tax(pos.price, base_price_is='net') - if price.gross != pos.price: - self._totaldiff += price.gross - pos.price - self._operations.append(self.PriceOperation(pos, price)) + new_rate = tax_rule.tax_rate_for(ia) + # We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself + if new_rate != pos.tax_rate: + if keep == 'net': + new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency, + override_tax_rate=new_rate) + else: + new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, + override_tax_rate=new_rate) + self._totaldiff += new_tax.gross - pos.price + self._operations.append(self.PriceOperation(pos, new_tax)) def cancel_fee(self, fee: OrderFee): self._totaldiff -= fee.value @@ -1345,10 +1357,7 @@ class OrderChangeManager: if price is None: price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) else: - if item.tax_rule and item.tax_rule.tax_applicable(self._invoice_address): - price = item.tax(price, base_price_is='gross') - else: - price = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), rate=Decimal('0.00'), name='') + price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address) if price is None: raise OrderError(self.error_messages['product_invalid']) @@ -1599,7 +1608,8 @@ class OrderChangeManager: 'new_price': op.price.gross }) op.position.price = op.price.gross - op.position._calculate_tax() + op.position.tax_rate = op.price.rate + op.position.tax_value = op.price.tax op.position.save() elif isinstance(op, self.TaxRuleOperation): if isinstance(op.position, OrderPosition): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 71795c74d0..e95d771b6b 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -46,33 +46,27 @@ def get_price(item: Item, variation: ItemVariation = None, price_includes_tax=True, eu_reverse_charge=False, ) - price = tax_rule.tax(price) + + price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum) if force_custom_price and custom_price is not None and custom_price != "": if custom_price_is_net: - price = tax_rule.tax(custom_price, base_price_is='net') + price = tax_rule.tax(custom_price, base_price_is='net', invoice_address=invoice_address, + subtract_from_gross=bundled_sum) else: - price = tax_rule.tax(custom_price, base_price_is='gross') + price = tax_rule.tax(custom_price, base_price_is='gross', invoice_address=invoice_address, + subtract_from_gross=bundled_sum) if item.free_price and custom_price is not None and custom_price != "": if not isinstance(custom_price, Decimal): custom_price = Decimal(str(custom_price).replace(",", ".")) if custom_price > 100000000: raise ValueError('price_too_high') if custom_price_is_net: - price = tax_rule.tax(max(custom_price, price.net), base_price_is='net') + price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', + invoice_address=invoice_address, subtract_from_gross=bundled_sum) else: - price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross') - - if bundled_sum: - price = price - TaxedPrice(net=bundled_sum, gross=bundled_sum, rate=0, tax=0, name='') - if price.gross < Decimal('0.00'): - return TAXED_ZERO - - if invoice_address and not tax_rule.tax_applicable(invoice_address): - price.tax = Decimal('0.00') - price.rate = Decimal('0.00') - price.gross = price.net - price.name = '' + price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', + invoice_address=invoice_address, subtract_from_gross=bundled_sum) price.gross = round_decimal(price.gross, item.event.currency) price.net = round_decimal(price.net, item.event.currency) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index ad10718e36..9c1c12f923 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -1086,6 +1086,11 @@ class TaxRuleLineForm(forms.Form): ('no', _('No VAT')), ], ) + rate = forms.DecimalField( + label=_('Deviating tax rate'), + max_digits=10, decimal_places=2, + required=False + ) TaxRuleLineFormSet = formset_factory( diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index df539e924c..3e8281bba7 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -183,14 +183,15 @@ class CommentForm(I18nModelForm): class OtherOperationsForm(forms.Form): - recalculate_taxes = forms.BooleanField( + recalculate_taxes = forms.ChoiceField( label=_('Re-calculate taxes'), required=False, - help_text=_( - 'This operation re-checks if taxes should be paid to the items due to e.g. configured reverse charge rules ' - 'and changes the prices and tax values accordingly. This is useful e.g. after an invoice address change. ' - 'Use with care and only if you need to. Note that rounding differences might occur in this procedure.' - ) + choices=( + ('', _('Do not re-calculate taxes')), + ('gross', _('Re-calculate taxes based on address and product settings, keep gross amount the same.')), + ('net', _('Re-calculate taxes based on address and product settings, keep net amount the same.')), + ), + widget=forms.RadioSelect ) reissue_invoice = forms.BooleanField( label=_('Issue a new invoice if required'), diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html index 896f7b6dcc..2d06661fcc 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html +++ b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html @@ -59,7 +59,7 @@ {{ form.id }} {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} -
+
{% bootstrap_field form.country layout='inline' form_group_class="" %}
@@ -68,8 +68,11 @@
{% bootstrap_field form.action layout='inline' form_group_class="" %}
-
-
@@ -82,7 +85,7 @@ {{ form.id }} {% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
-
+
{% bootstrap_field formset.empty_form.country layout='inline' form_group_class="" %}
@@ -91,8 +94,11 @@
{% bootstrap_field formset.empty_form.action layout='inline' form_group_class="" %}
-
-
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index cec1bfde6b..c344e63b60 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.files import File +from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction from django.db.models import ProtectedError from django.forms import inlineformset_factory @@ -1135,7 +1136,7 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView messages.success(self.request, _('Your changes have been saved.')) form.instance.custom_rules = json.dumps([ f.cleaned_data for f in self.formset if f not in self.formset.deleted_forms - ]) + ], cls=DjangoJSONEncoder) if form.has_changed(): self.object.log_action( 'pretix.event.taxrule.changed', user=self.request.user, data={ diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 0976d3f0aa..6338e5fe07 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1397,11 +1397,6 @@ class OrderChange(OrderView): for f in fees: f.form = OrderFeeChangeForm(prefix='of-{}'.format(f.pk), instance=f, data=self.request.POST if self.request.method == "POST" else None) - try: - ia = self.order.invoice_address - except InvoiceAddress.DoesNotExist: - ia = None - f.apply_tax = self.request.event.settings.tax_rate_default and self.request.event.settings.tax_rate_default.tax_applicable(invoice_address=ia) return fees @cached_property @@ -1411,11 +1406,6 @@ class OrderChange(OrderView): p.form = OrderPositionChangeForm(prefix='op-{}'.format(p.pk), instance=p, items=self.items, initial={'seat': p.seat.seat_guid if p.seat else None}, data=self.request.POST if self.request.method == "POST" else None) - try: - ia = self.order.invoice_address - except InvoiceAddress.DoesNotExist: - ia = None - p.apply_tax = p.item.tax_rule and p.item.tax_rule.tax_applicable(invoice_address=ia) return positions def get_context_data(self, **kwargs): @@ -1431,7 +1421,9 @@ class OrderChange(OrderView): return False else: if self.other_form.cleaned_data['recalculate_taxes']: - ocm.recalculate_taxes() + ocm.recalculate_taxes( + keep=self.other_form.cleaned_data['recalculate_taxes'] + ) return True def _process_add(self, ocm): @@ -1523,7 +1515,7 @@ class OrderChange(OrderView): if p.seat and p.form.cleaned_data['seat'] and p.form.cleaned_data['seat'] != p.seat.seat_guid: ocm.change_seat(p, p.form.cleaned_data['seat']) - if p.form.cleaned_data['price'] != p.price: + if p.form.cleaned_data['price'] is not None and 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: diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index b65a5efe74..7fbafd7cf7 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -3394,6 +3394,38 @@ def test_order_create_auto_pricing_reverse_charge(token_client, organizer, event assert o.total == Decimal('19.58') +@pytest.mark.django_db +def test_order_create_auto_pricing_country_rate(token_client, organizer, event, item, quota, question, taxrule): + taxrule.eu_reverse_charge = True + taxrule.custom_rules = json.dumps([ + {'country': 'FR', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + ]) + taxrule.save() + item.tax_rule = taxrule + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['invoice_address']['country'] = 'FR' + res['invoice_address']['is_business'] = True + res['invoice_address']['vat_id'] = 'FR12345' + res['invoice_address']['vat_id_validated'] = True + del res['positions'][0]['price'] + 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']) + p = o.positions.first() + assert p.price == Decimal('38.66') + assert p.tax_rate == Decimal('100.00') + assert p.tax_value == Decimal('19.33') + assert o.total == Decimal('38.91') + + @pytest.mark.django_db def test_order_create_auto_pricing_reverse_charge_require_valid_vatid(token_client, organizer, event, item, quota, question, taxrule): diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 0e0befadfe..da0a39d333 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta from decimal import Decimal @@ -1442,6 +1443,68 @@ class OrderChangeManagerTests(TestCase): self.ocm.commit() assert self.order.invoices.count() == 1 + @classscope(attr='o') + def test_recalculate_country_rate(self): + self.event.settings.set('tax_rate_default', self.tr19.pk) + prov = self.ocm._get_payment_provider() + prov.settings.set('_fee_abs', Decimal('0.30')) + self.ocm._recalculate_total_and_payment_fee() + + assert self.order.total == Decimal('46.30') + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert fee.value == prov.calculate_fee(self.order.total) + assert fee.tax_rate == Decimal('19.00') + assert fee.tax_value == Decimal('0.05') + + self.ocm = OrderChangeManager(self.order, None) + + self._enable_reverse_charge() + self.tr7.custom_rules = json.dumps([ + {'country': 'AT', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + ]) + self.tr7.save() + + self.ocm.recalculate_taxes(keep='net') + self.ocm.commit() + ops = list(self.order.positions.all()) + for op in ops: + assert op.price == Decimal('43.00') + assert op.tax_value == Decimal('21.50') + assert op.tax_rate == Decimal('100.00') + + assert self.order.total == Decimal('86.00') + fee.value + + @classscope(attr='o') + def test_recalculate_country_rate_keep_gross(self): + self.event.settings.set('tax_rate_default', self.tr19.pk) + prov = self.ocm._get_payment_provider() + prov.settings.set('_fee_abs', Decimal('0.30')) + self.ocm._recalculate_total_and_payment_fee() + + assert self.order.total == Decimal('46.30') + fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) + assert fee.value == prov.calculate_fee(self.order.total) + assert fee.tax_rate == Decimal('19.00') + assert fee.tax_value == Decimal('0.05') + + self.ocm = OrderChangeManager(self.order, None) + + self._enable_reverse_charge() + self.tr7.custom_rules = json.dumps([ + {'country': 'AT', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + ]) + self.tr7.save() + + self.ocm.recalculate_taxes(keep='gross') + self.ocm.commit() + ops = list(self.order.positions.all()) + for op in ops: + assert op.price == Decimal('23.00') + assert op.tax_value == Decimal('11.50') + assert op.tax_rate == Decimal('100.00') + + assert self.order.total == Decimal('46.00') + fee.value + @classscope(attr='o') def test_recalculate_reverse_charge(self): self.event.settings.set('tax_rate_default', self.tr19.pk) @@ -2305,6 +2368,27 @@ class OrderChangeManagerTests(TestCase): assert nop.tax_rate == Decimal('0.00') assert nop.tax_value == Decimal('0.00') + @classscope(attr='o') + def test_change_taxrate_to_country_specific(self): + self.tr19.eu_reverse_charge = True + self.tr19.custom_rules = json.dumps([ + {'country': 'AT', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + ]) + 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('100.00') + assert nop.tax_value == Decimal('19.33') + @classscope(attr='o') def test_change_taxrate_from_reverse_charge(self): self.tr7.eu_reverse_charge = True diff --git a/src/tests/base/test_pricing.py b/src/tests/base/test_pricing.py index f6e4550945..1a28c2d999 100644 --- a/src/tests/base/test_pricing.py +++ b/src/tests/base/test_pricing.py @@ -1,3 +1,4 @@ +import json from decimal import Decimal import pytest @@ -325,3 +326,35 @@ def test_tax_reverse_charge_invalid_vat_id(item): ) assert not item.tax_rule.is_reverse_charge(ia) assert get_price(item, invoice_address=ia).gross == Decimal('119.00') + + +@pytest.mark.django_db +def test_country_specific_rule_net_based(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=False, + custom_rules=json.dumps([ + {'country': 'BE', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + ]) + ) + ia = InvoiceAddress( + is_business=True, vat_id="EU1234", vat_id_validated=True, + country=Country('BE') + ) + assert get_price(item, invoice_address=ia).gross == Decimal('200.00') + + +@pytest.mark.django_db +def test_country_specific_rule_gross_based(item): + item.default_price = Decimal('100.00') + item.tax_rule = item.event.tax_rules.create( + rate=Decimal('19.00'), price_includes_tax=True, + custom_rules=json.dumps([ + {'country': 'BE', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + ]) + ) + ia = InvoiceAddress( + is_business=True, vat_id="EU1234", vat_id_validated=True, + country=Country('BE') + ) + assert get_price(item, invoice_address=ia).gross == Decimal('168.06') diff --git a/src/tests/base/test_taxrules.py b/src/tests/base/test_taxrules.py index 1e1d7b2d29..9dd431007c 100644 --- a/src/tests/base/test_taxrules.py +++ b/src/tests/base/test_taxrules.py @@ -53,7 +53,7 @@ def test_reverse_charge_no_address(event): rate=Decimal('10.00'), price_includes_tax=False ) assert not tr.is_reverse_charge(None) - assert tr.tax_applicable(None) + assert tr._tax_applicable(None) @pytest.mark.django_db @@ -65,7 +65,8 @@ def test_reverse_charge_no_country(event): ia = InvoiceAddress( ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -79,7 +80,8 @@ def test_reverse_charge_individual_same_country(event): country=Country('DE') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -93,7 +95,8 @@ def test_reverse_charge_individual_eu(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -107,7 +110,8 @@ def test_reverse_charge_individual_3rdc(event): country=Country('US') ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') @pytest.mark.django_db @@ -121,7 +125,8 @@ def test_reverse_charge_business_same_country(event): country=Country('DE') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -135,7 +140,8 @@ def test_reverse_charge_business_eu(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -149,7 +155,8 @@ def test_reverse_charge_business_3rdc(event): country=Country('US') ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') @pytest.mark.django_db @@ -165,7 +172,8 @@ def test_reverse_charge_valid_vat_id_business_same_country(event): vat_id_validated=True ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -181,7 +189,8 @@ def test_reverse_charge_valid_vat_id_business_eu(event): country=Country('AT') ) assert tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') @pytest.mark.django_db @@ -197,7 +206,8 @@ def test_reverse_charge_valid_vat_id_business_3rdc(event): vat_id_validated=True ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') @pytest.mark.django_db @@ -213,7 +223,8 @@ def test_reverse_charge_disabled(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -232,7 +243,8 @@ def test_custom_rules_override(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -252,7 +264,8 @@ def test_custom_rules_in_order(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -269,7 +282,8 @@ def test_custom_rules_any_country(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') @pytest.mark.django_db @@ -286,13 +300,14 @@ def test_custom_rules_eu_country(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') ia = InvoiceAddress( is_business=True, country=Country('US') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) @pytest.mark.django_db @@ -309,13 +324,15 @@ def test_custom_rules_specific_country(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') ia = InvoiceAddress( is_business=True, country=Country('DE') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -332,13 +349,15 @@ def test_custom_rules_individual(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') ia = InvoiceAddress( is_business=True, country=Country('DE') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -355,13 +374,15 @@ def test_custom_rules_business(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') ia = InvoiceAddress( is_business=False, country=Country('DE') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') @pytest.mark.django_db @@ -378,7 +399,8 @@ def test_custom_rules_vat_id(event): country=Country('AT') ) assert not tr.is_reverse_charge(ia) - assert tr.tax_applicable(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') ia = InvoiceAddress( is_business=True, country=Country('DE'), @@ -386,4 +408,32 @@ def test_custom_rules_vat_id(event): vat_id_validated=True ) assert tr.is_reverse_charge(ia) - assert not tr.tax_applicable(ia) + assert not tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('0.00') + + +@pytest.mark.django_db +def test_custom_rules_country_rate(event): + tr = TaxRule( + event=event, + rate=Decimal('10.00'), price_includes_tax=False, + custom_rules=json.dumps([ + {'country': 'EU', 'address_type': 'business_vat_id', 'action': 'vat', 'rate': '100.00'}, + ]) + ) + ia = InvoiceAddress( + is_business=True, + country=Country('DE') + ) + assert not tr.is_reverse_charge(ia) + assert tr._tax_applicable(ia) + assert tr.tax_rate_for(ia) == Decimal('10.00') + ia = InvoiceAddress( + is_business=True, + country=Country('DE'), + vat_id='DE1234', + vat_id_validated=True + ) + assert tr.tax_rate_for(ia) == Decimal('100.00') + assert not tr.is_reverse_charge(ia) + assert tr._tax_applicable(ia) diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 1f0e48cd41..3c98b38ac4 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -1370,7 +1370,7 @@ class OrderChangeTests(SoupTest): 'add-INITIAL_FORMS': '0', 'add-MIN_NUM_FORMS': '0', 'add-MAX_NUM_FORMS': '100', - 'other-recalculate_taxes': 'on', + 'other-recalculate_taxes': 'net', 'op-{}-operation'.format(self.op1.pk): '', 'op-{}-operation'.format(self.op2.pk): '', 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), @@ -1386,6 +1386,42 @@ class OrderChangeTests(SoupTest): assert op.tax_value == Decimal('0.00') assert op.tax_rate == Decimal('0.00') + def test_recalculate_reverse_charge_keep_gross(self): + self.tr7.eu_reverse_charge = True + self.tr7.home_country = Country('DE') + self.tr7.save() + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + with scopes_disabled(): + InvoiceAddress.objects.create( + order=self.order, is_business=True, vat_id='ATU1234567', vat_id_validated=True, + country=Country('AT') + ) + + self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'add-TOTAL_FORMS': '0', + 'add-INITIAL_FORMS': '0', + 'add-MIN_NUM_FORMS': '0', + 'add-MAX_NUM_FORMS': '100', + 'other-recalculate_taxes': 'gross', + 'op-{}-operation'.format(self.op1.pk): '', + 'op-{}-operation'.format(self.op2.pk): '', + 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op2.pk): str(self.op2.price), + 'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk), + 'op-{}-price'.format(self.op1.pk): str(self.op1.price), + }) + + with scopes_disabled(): + ops = list(self.order.positions.all()) + for op in ops: + assert op.price == Decimal('23.00') + assert op.tax_value == Decimal('0.00') + assert op.tax_rate == Decimal('0.00') + def test_change_fee_value_success(self): with scopes_disabled(): fee = self.order.fees.create(fee_type="shipping", value=Decimal('5.00'), tax_rule=self.tr19) diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index f7fc13fba8..cefe75d857 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -237,6 +237,18 @@ class CartTest(CartTestMixin, TestCase): self._set_session('invoice_address', ia.pk) return ia + def _enable_country_specific_taxing(self): + self.tr19.custom_rules = json.dumps([ + {'country': 'EU', 'address_type': 'individual', 'action': 'vat', 'rate': '20.00'}, + ]) + self.tr19.save() + with scopes_disabled(): + ia = InvoiceAddress.objects.create( + country=Country('AT'), + ) + self._set_session('invoice_address', ia.pk) + return ia + def test_reverse_charge(self): self._enable_reverse_charge() response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { @@ -251,6 +263,20 @@ class CartTest(CartTestMixin, TestCase): self.assertEqual(len(objs), 1) self.assertEqual(objs[0].price, round_decimal(Decimal('23.00') / Decimal('1.19'))) + def test_country_specific_taxes(self): + self._enable_country_specific_taxing() + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1' + }, follow=True) + self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), + target_status_code=200) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].price, Decimal('23.20')) + def test_subevent_missing(self): self.event.has_subevents = True self.event.save() @@ -3200,6 +3226,182 @@ class CartBundleTest(CartTestMixin, TestCase): assert a.tax_rate == Decimal('0.00') assert not a.includes_tax + @classscope(attr='orga') + def test_expired_country_taxing_only_bundled(self): + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00')) + ia = InvoiceAddress.objects.create( + country=Country('AT') + ) + tr7 = self.event.tax_rules.create( + name='VAT', rate=Decimal('7.00'), + custom_rules=json.dumps([ + {'country': 'AT', 'address_type': 'individual', 'action': 'vat', 'rate': '5.00'}, + ]) + ) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.5, expires=now() - timedelta(minutes=10), + ) + a = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp, + price=1.47, expires=now() - timedelta(minutes=10), is_bundled=True, override_tax_rate=Decimal('5.00') + ) + update_tax_rates(self.event, self.session_key, ia) + cp.refresh_from_db() + a.refresh_from_db() + assert cp.price == Decimal('21.50') + assert cp.tax_rate == Decimal('19.00') + assert cp.includes_tax + assert a.price == Decimal('1.47') + assert a.tax_rate == Decimal('5.00') + assert a.includes_tax + + self.cm.invoice_address = ia + self.cm.commit() + + cp.refresh_from_db() + a.refresh_from_db() + assert cp.price == Decimal('21.50') + assert cp.tax_rate == Decimal('19.00') + assert cp.includes_tax + assert a.price == Decimal('1.47') + assert a.tax_rate == Decimal('5.00') + assert a.includes_tax + + @classscope(attr='orga') + def test_expired_country_tax_all(self): + ia = InvoiceAddress.objects.create( + country=Country('AT') + ) + tr19 = self.event.tax_rules.create( + name='VAT', rate=Decimal('19.00'), + custom_rules=json.dumps([ + {'country': 'AT', 'address_type': 'individual', 'action': 'vat', 'rate': '20.00'}, + ]) + ) + tr7 = self.event.tax_rules.create( + name='VAT', rate=Decimal('7.00'), + custom_rules=json.dumps([ + {'country': 'AT', 'address_type': 'individual', 'action': 'vat', 'rate': '5.00'}, + ]) + ) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + cp = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=21.68, expires=now() - timedelta(minutes=10), override_tax_rate=Decimal('20.00') + ) + a = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp, + price=1.47, expires=now() - timedelta(minutes=10), is_bundled=True, override_tax_rate=Decimal('5.00') + ) + update_tax_rates(self.event, self.session_key, ia) + cp.refresh_from_db() + a.refresh_from_db() + assert cp.price == Decimal('21.68') + assert cp.tax_rate == Decimal('20.00') + assert cp.includes_tax + assert a.price == Decimal('1.47') + assert a.tax_rate == Decimal('5.00') + assert a.includes_tax + + self.cm.invoice_address = ia + self.cm.commit() + + cp.refresh_from_db() + a.refresh_from_db() + assert cp.price == Decimal('21.68') + assert cp.tax_rate == Decimal('20.0') + assert cp.includes_tax + assert a.price == Decimal('1.47') + assert a.tax_rate == Decimal('5.00') + assert a.includes_tax + + @classscope(attr='orga') + def test_country_tax_all_add(self): + ia = InvoiceAddress.objects.create( + country=Country('AT') + ) + tr19 = self.event.tax_rules.create( + name='VAT', rate=Decimal('19.00'), + custom_rules=json.dumps([ + {'country': 'AT', 'address_type': 'individual', 'action': 'vat', 'rate': '20.00'}, + ]) + ) + tr7 = self.event.tax_rules.create( + name='VAT', rate=Decimal('7.00'), + custom_rules=json.dumps([ + {'country': 'AT', 'address_type': 'individual', 'action': 'vat', 'rate': '5.00'}, + ]) + ) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + self.cm.invoice_address = ia + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + + cp = CartPosition.objects.filter(addon_to__isnull=True).get() + a = CartPosition.objects.filter(addon_to__isnull=False).get() + assert cp.price == Decimal('21.68') + assert cp.tax_rate == Decimal('20.00') + assert cp.includes_tax + assert a.price == Decimal('1.47') + assert a.tax_rate == Decimal('5.00') + assert a.includes_tax + + @classscope(attr='orga') + def test_country_tax_bundled_add(self): + ia = InvoiceAddress.objects.create( + country=Country('AT') + ) + tr19 = self.event.tax_rules.create(name='VAT', rate=Decimal('19.00')) + tr7 = self.event.tax_rules.create( + name='VAT', rate=Decimal('7.00'), + custom_rules=json.dumps([ + {'country': 'AT', 'address_type': 'individual', 'action': 'vat', 'rate': '5.00'}, + ]) + ) + self.ticket.tax_rule = tr19 + self.ticket.save() + self.trans.tax_rule = tr7 + self.trans.save() + + self.cm.invoice_address = ia + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'count': 1 + } + ]) + self.cm.commit() + + cp = CartPosition.objects.filter(addon_to__isnull=True).get() + a = CartPosition.objects.filter(addon_to__isnull=False).get() + assert cp.price == Decimal('21.50') + assert cp.tax_rate == Decimal('19.00') + assert cp.includes_tax + assert a.price == Decimal('1.47') + assert a.tax_rate == Decimal('5.00') + assert a.includes_tax + class CartSeatingTest(CartTestMixin, TestCase): diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 0f2ef8c76a..104a1f899e 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -87,6 +87,19 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): self._set_session('invoice_address', ia.pk) return ia + def _enable_country_specific_taxing(self): + self.tr19.custom_rules = json.dumps([ + {'country': 'EU', 'address_type': 'individual', 'action': 'vat', 'rate': '20.00'}, + {'country': 'US', 'address_type': 'individual', 'action': 'vat', 'rate': '10.00'}, + ]) + self.tr19.save() + with scopes_disabled(): + ia = InvoiceAddress.objects.create( + country=Country('AT'), + ) + self._set_session('invoice_address', ia.pk) + return ia + def test_empty_cart(self): response = self.client.get('/%s/%s/checkout/start' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), @@ -364,6 +377,50 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): cr1.refresh_from_db() assert cr1.price == Decimal('23.00') + def test_country_taxing(self): + self._enable_country_specific_taxing() + + with scopes_disabled(): + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'individual', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AT', + 'email': 'admin@localhost' + }, follow=True) + + cr1.refresh_from_db() + assert cr1.price == Decimal('23.20') + + def test_country_taxing_switch(self): + self.test_country_taxing() + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'individual', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'US', + 'state': 'CA', + 'email': 'admin@localhost' + }, follow=True) + + with scopes_disabled(): + cr = CartPosition.objects.get(cart_id=self.session_key) + assert cr.price == Decimal('21.26') + def test_question_file_upload(self): with scopes_disabled(): q1 = Question.objects.create(