diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 0d06711cde..ad800a70fd 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -33,7 +33,7 @@ from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent, - TeamAPIToken, generate_secret, + TaxRule, TeamAPIToken, generate_secret, ) from pretix.base.models.orders import RevokedTicketSecret from pretix.base.payment import PaymentException @@ -561,7 +561,10 @@ class OrderViewSet(viewsets.ModelViewSet): serializer = OrderCreateSerializer(data=request.data, context=self.get_serializer_context()) serializer.is_valid(raise_exception=True) with transaction.atomic(): - self.perform_create(serializer) + try: + self.perform_create(serializer) + except TaxRule.SaleNotAllowed: + raise ValidationError(_('One of the selected products is not available in the selected country.')) send_mail = serializer._send_mail order = serializer.instance if not order.pk: diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index 4eedfb5aef..1b0c6cbd46 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -127,6 +127,9 @@ class TaxRule(LoggedModel): class Meta: ordering = ('event', 'rate', 'id') + class SaleNotAllowed(Exception): + pass + def allow_delete(self): from pretix.base.models.orders import OrderFee, OrderPosition @@ -169,6 +172,8 @@ class TaxRule(LoggedModel): return Decimal('0.00') if self.has_custom_rules: rule = self.get_matching_rule(invoice_address) + if rule.get('action', 'vat') == 'block': + raise self.SaleNotAllowed() if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None: return Decimal(rule.get('rate')) return Decimal(self.rate) @@ -279,6 +284,8 @@ class TaxRule(LoggedModel): def _tax_applicable(self, invoice_address): if self._custom_rules: rule = self.get_matching_rule(invoice_address) + if rule.get('action', 'vat') == 'block': + raise self.SaleNotAllowed() return rule.get('action', 'vat') == 'vat' if not self.eu_reverse_charge: diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 65ebe23d63..fc42199097 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -106,6 +106,7 @@ error_messages = { 'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'), 'seat_multiple': _('You can not select the same seat multiple times.'), 'gift_card': _("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."), + 'country_blocked': _('One of the selected products is not available in the selected country.'), } @@ -324,6 +325,8 @@ class CartManager: custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices, invoice_address=self.invoice_address, force_custom_price=force_custom_price, bundled_sum=bundled_sum ) + except TaxRule.SaleNotAllowed: + raise CartError(error_messages['country_blocked']) except ValueError as e: if str(e) == 'price_too_high': raise CartError(error_messages['price_too_high']) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 39382a8718..eea6d4745d 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -89,6 +89,7 @@ error_messages = { 'positions have been removed from your cart.'), 'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'), 'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'), + 'country_blocked': _('One of the selected products is not available in the selected country.'), } logger = logging.getLogger(__name__) @@ -615,34 +616,39 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio current_discount = cp.price_before_voucher - cp.price max_discount = max(v_budget[cp.voucher] + current_discount, 0) - if cp.is_bundled: - try: - bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation) - bprice = bundle.designated_price or 0 - except ItemBundle.DoesNotExist: - bprice = cp.price - except ItemBundle.MultipleObjectsReturned: - raise OrderError("Invalid product configuration (duplicate bundle)") - price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False, - custom_price_is_tax_rate=cp.override_tax_rate, - invoice_address=address, force_custom_price=True, max_discount=max_discount) - pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False, - custom_price_is_tax_rate=cp.override_tax_rate, - invoice_address=address, force_custom_price=True, max_discount=max_discount) - changed_prices[cp.pk] = bprice - else: - bundled_sum = 0 - if not cp.addon_to_id: - for bundledp in cp.addons.all(): - if bundledp.is_bundled: - bundled_sum += changed_prices.get(bundledp.pk, bundledp.price) + try: + if cp.is_bundled: + try: + bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation) + bprice = bundle.designated_price or 0 + except ItemBundle.DoesNotExist: + bprice = cp.price + except ItemBundle.MultipleObjectsReturned: + raise OrderError("Invalid product configuration (duplicate bundle)") + price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False, + custom_price_is_tax_rate=cp.override_tax_rate, + invoice_address=address, force_custom_price=True, max_discount=max_discount) + pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False, + custom_price_is_tax_rate=cp.override_tax_rate, + invoice_address=address, force_custom_price=True, max_discount=max_discount) + changed_prices[cp.pk] = bprice + else: + bundled_sum = 0 + if not cp.addon_to_id: + for bundledp in cp.addons.all(): + if bundledp.is_bundled: + bundled_sum += changed_prices.get(bundledp.pk, bundledp.price) - price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False, - addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum, - max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate) - pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False, - addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum, - max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate) + price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False, + addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum, + max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate) + pbv = get_price(cp.item, cp.variation, None, cp.price, cp.subevent, custom_price_is_net=False, + addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum, + max_discount=max_discount, custom_price_is_tax_rate=cp.override_tax_rate) + except TaxRule.SaleNotAllowed: + err = err or error_messages['country_blocked'] + cp.delete() + continue if max_discount is not None: v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross) @@ -1174,6 +1180,7 @@ class OrderChangeManager: 'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'), 'seat_required': _('The selected product requires you to select a seat.'), 'seat_forbidden': _('The selected product does not allow to select a seat.'), + 'tax_rule_country_blocked': _('The selected country is blocked by your tax rule.'), 'gift_card_change': _('You cannot change the price of a position that has been used to issue a gift card.'), } ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation')) @@ -1241,8 +1248,11 @@ class OrderChangeManager: self._operations.append(self.SeatOperation(position, seat)) def change_subevent(self, position: OrderPosition, subevent: SubEvent): - price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent, - invoice_address=self._invoice_address) + try: + price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent, + invoice_address=self._invoice_address) + except TaxRule.SaleNotAllowed: + raise OrderError(self.error_messages['tax_rule_country_blocked']) if price is None: # NOQA raise OrderError(self.error_messages['product_invalid']) @@ -1262,8 +1272,11 @@ class OrderChangeManager: if (not variation and item.has_variations) or (variation and variation.item_id != item.pk): raise OrderError(self.error_messages['product_without_variation']) - price = get_price(item, variation, voucher=position.voucher, subevent=subevent, - invoice_address=self._invoice_address) + try: + price = get_price(item, variation, voucher=position.voucher, subevent=subevent, + invoice_address=self._invoice_address) + except TaxRule.SaleNotAllowed: + raise OrderError(self.error_messages['tax_rule_country_blocked']) if price is None: # NOQA raise OrderError(self.error_messages['product_invalid']) @@ -1321,7 +1334,10 @@ class OrderChangeManager: if not pos.price: continue - new_rate = tax_rule.tax_rate_for(ia) + try: + new_rate = tax_rule.tax_rate_for(ia) + except TaxRule.SaleNotAllowed: + raise OrderError(error_messages['tax_rule_country_blocked']) # 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': @@ -1374,10 +1390,13 @@ class OrderChangeManager: except Seat.DoesNotExist: raise OrderError(error_messages['seat_invalid']) - if price is None: - price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) - else: - price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address) + try: + if price is None: + price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) + else: + price = item.tax(price, base_price_is='gross', invoice_address=self._invoice_address) + except TaxRule.SaleNotAllowed: + raise OrderError(self.error_messages['tax_rule_country_blocked']) if price is None: raise OrderError(self.error_messages['product_invalid']) @@ -1952,7 +1971,10 @@ class OrderChangeManager: self._check_quotas() self._check_seats() self._check_complete_cancel() - self._perform_operations() + try: + self._perform_operations() + except TaxRule.SaleNotAllowed: + raise OrderError(self.error_messages['tax_rule_country_blocked']) self._recalculate_total_and_payment_fee() self._reissue_invoice() self._clear_tickets_cache() diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 56caa71d78..ca0abfa5c6 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -1146,6 +1146,7 @@ class TaxRuleLineForm(forms.Form): ('vat', _('Charge VAT')), ('reverse', _('Reverse charge')), ('no', _('No VAT')), + ('block', _('Sale not allowed')), ], ) rate = forms.DecimalField( diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 6c8d293469..4163f0c6e3 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -18,7 +18,7 @@ from django_scopes import scopes_disabled from pretix.base.models import Order from pretix.base.models.orders import InvoiceAddress, OrderPayment -from pretix.base.models.tax import TaxedPrice +from pretix.base.models.tax import TaxedPrice, TaxRule from pretix.base.services.cart import ( CartError, error_messages, get_fees, set_cart_addons, update_tax_rates, ) @@ -552,13 +552,19 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): self.cart_session['contact_form_data'] = self.contact_form.cleaned_data if self.address_asked or self.request.event.settings.invoice_name_required: addr = self.invoice_form.save() - self.cart_session['invoice_address'] = addr.pk + try: + diff = update_tax_rates( + event=request.event, + cart_id=get_or_create_cart_id(request), + invoice_address=addr + ) + except TaxRule.SaleNotAllowed: + messages.error(request, + _("Unfortunately, based on the invoice address you entered, we're not able to sell you " + "the selected products for tax-related legal reasons.")) + return self.render() - diff = update_tax_rates( - event=request.event, - cart_id=get_or_create_cart_id(request), - invoice_address=self.invoice_form.instance - ) + self.cart_session['invoice_address'] = addr.pk if abs(diff) > Decimal('0.001'): messages.info(request, _('Due to the invoice address you entered, we need to apply a different tax ' 'rate to your purchase and the price of the products in your cart has ' diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index d4b6edadd7..040eb989cd 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -23,7 +23,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.generic import TemplateView, View from pretix.base.models import ( - CachedTicket, GiftCard, Invoice, Order, OrderPosition, Quota, + CachedTicket, GiftCard, Invoice, Order, OrderPosition, Quota, TaxRule, ) from pretix.base.models.orders import ( CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund, @@ -699,6 +699,13 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem messages.error(self.request, _("We had difficulties processing your input. Please review the errors below.")) return self.get(request, *args, **kwargs) + if 'country' in self.invoice_form.cleaned_data: + trs = TaxRule.objects.filter(id__in=[p.tax_rule_id for p in self.positions]) + for tr in trs: + if tr.get_matching_rule(self.invoice_form.instance).get('action', 'vat') == 'block': + messages.error(self.request, + _('One of the selected products is not available in the selected country.')) + return self.get(request, *args, **kwargs) if hasattr(self.invoice_form, 'save'): self.invoice_form.save() self.order.log_action('pretix.event.order.modified', { diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 228a8a29b2..2a472619d1 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -378,6 +378,55 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): cr1.refresh_from_db() assert cr1.price == Decimal('23.00') + def test_custom_tax_rules_blocked(self): + self.tr19.custom_rules = json.dumps([ + {'country': 'AT', 'address_type': 'business_vat_id', 'action': 'reverse'}, + {'country': 'ZZ', 'address_type': '', 'action': 'block'}, + ]) + self.tr19.save() + self.event.settings.invoice_address_vatid = True + + 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) + ) + + r = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'DE', + 'email': 'admin@localhost' + }, follow=True) + doc = BeautifulSoup(r.rendered_content, "lxml") + assert doc.select(".alert-danger") + + cr1.refresh_from_db() + assert cr1.price == Decimal('23.00') + + with mock.patch('vat_moss.id.validate') as mock_validate: + mock_validate.return_value = ('AT', 'AT123456', 'Foo') + r = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '12345', + 'city': 'Here', + 'country': 'AT', + 'vat_id': 'AT123456', + 'email': 'admin@localhost' + }, follow=True) + doc = BeautifulSoup(r.rendered_content, "lxml") + assert not doc.select(".alert-danger") + + cr1.refresh_from_db() + assert cr1.price == Decimal('19.33') + def test_country_taxing(self): self._enable_country_specific_taxing()