Tax rules: Allow to block countries from making a purchase

This commit is contained in:
Raphael Michel
2020-11-22 13:27:58 +01:00
parent 168a6bae98
commit 6d9e1be844
8 changed files with 145 additions and 47 deletions

View File

@@ -33,7 +33,7 @@ from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress, CachedCombinedTicket, CachedTicket, Device, Event, Invoice, InvoiceAddress,
Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund, Quota, SubEvent,
TeamAPIToken, generate_secret, TaxRule, TeamAPIToken, generate_secret,
) )
from pretix.base.models.orders import RevokedTicketSecret from pretix.base.models.orders import RevokedTicketSecret
from pretix.base.payment import PaymentException 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 = OrderCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): 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 send_mail = serializer._send_mail
order = serializer.instance order = serializer.instance
if not order.pk: if not order.pk:

View File

@@ -127,6 +127,9 @@ class TaxRule(LoggedModel):
class Meta: class Meta:
ordering = ('event', 'rate', 'id') ordering = ('event', 'rate', 'id')
class SaleNotAllowed(Exception):
pass
def allow_delete(self): def allow_delete(self):
from pretix.base.models.orders import OrderFee, OrderPosition from pretix.base.models.orders import OrderFee, OrderPosition
@@ -169,6 +172,8 @@ class TaxRule(LoggedModel):
return Decimal('0.00') return Decimal('0.00')
if self.has_custom_rules: if self.has_custom_rules:
rule = self.get_matching_rule(invoice_address) 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: if rule.get('action', 'vat') == 'vat' and rule.get('rate') is not None:
return Decimal(rule.get('rate')) return Decimal(rule.get('rate'))
return Decimal(self.rate) return Decimal(self.rate)
@@ -279,6 +284,8 @@ class TaxRule(LoggedModel):
def _tax_applicable(self, invoice_address): def _tax_applicable(self, invoice_address):
if self._custom_rules: if self._custom_rules:
rule = self.get_matching_rule(invoice_address) rule = self.get_matching_rule(invoice_address)
if rule.get('action', 'vat') == 'block':
raise self.SaleNotAllowed()
return rule.get('action', 'vat') == 'vat' return rule.get('action', 'vat') == 'vat'
if not self.eu_reverse_charge: if not self.eu_reverse_charge:

View File

@@ -106,6 +106,7 @@ error_messages = {
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'), '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.'), '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."), '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, 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 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: except ValueError as e:
if str(e) == 'price_too_high': if str(e) == 'price_too_high':
raise CartError(error_messages['price_too_high']) raise CartError(error_messages['price_too_high'])

View File

@@ -89,6 +89,7 @@ error_messages = {
'positions have been removed from your cart.'), '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_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.'), '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__) 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 current_discount = cp.price_before_voucher - cp.price
max_discount = max(v_budget[cp.voucher] + current_discount, 0) max_discount = max(v_budget[cp.voucher] + current_discount, 0)
if cp.is_bundled: try:
try: if cp.is_bundled:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation) try:
bprice = bundle.designated_price or 0 bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
except ItemBundle.DoesNotExist: bprice = bundle.designated_price or 0
bprice = cp.price except ItemBundle.DoesNotExist:
except ItemBundle.MultipleObjectsReturned: bprice = cp.price
raise OrderError("Invalid product configuration (duplicate bundle)") except ItemBundle.MultipleObjectsReturned:
price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False, raise OrderError("Invalid product configuration (duplicate bundle)")
custom_price_is_tax_rate=cp.override_tax_rate, price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False,
invoice_address=address, force_custom_price=True, max_discount=max_discount) custom_price_is_tax_rate=cp.override_tax_rate,
pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False, invoice_address=address, force_custom_price=True, max_discount=max_discount)
custom_price_is_tax_rate=cp.override_tax_rate, pbv = get_price(cp.item, cp.variation, None, bprice, cp.subevent, custom_price_is_net=False,
invoice_address=address, force_custom_price=True, max_discount=max_discount) custom_price_is_tax_rate=cp.override_tax_rate,
changed_prices[cp.pk] = bprice invoice_address=address, force_custom_price=True, max_discount=max_discount)
else: changed_prices[cp.pk] = bprice
bundled_sum = 0 else:
if not cp.addon_to_id: bundled_sum = 0
for bundledp in cp.addons.all(): if not cp.addon_to_id:
if bundledp.is_bundled: for bundledp in cp.addons.all():
bundled_sum += changed_prices.get(bundledp.pk, bundledp.price) 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, 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, 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) 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, 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, 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) 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: if max_discount is not None:
v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross) 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_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_required': _('The selected product requires you to select a seat.'),
'seat_forbidden': _('The selected product does not allow 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.'), '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')) ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
@@ -1241,8 +1248,11 @@ class OrderChangeManager:
self._operations.append(self.SeatOperation(position, seat)) self._operations.append(self.SeatOperation(position, seat))
def change_subevent(self, position: OrderPosition, subevent: SubEvent): def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent, try:
invoice_address=self._invoice_address) 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 if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid']) 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): if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation']) raise OrderError(self.error_messages['product_without_variation'])
price = get_price(item, variation, voucher=position.voucher, subevent=subevent, try:
invoice_address=self._invoice_address) 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 if price is None: # NOQA
raise OrderError(self.error_messages['product_invalid']) raise OrderError(self.error_messages['product_invalid'])
@@ -1321,7 +1334,10 @@ class OrderChangeManager:
if not pos.price: if not pos.price:
continue 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 # 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 new_rate != pos.tax_rate:
if keep == 'net': if keep == 'net':
@@ -1374,10 +1390,13 @@ class OrderChangeManager:
except Seat.DoesNotExist: except Seat.DoesNotExist:
raise OrderError(error_messages['seat_invalid']) raise OrderError(error_messages['seat_invalid'])
if price is None: try:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) if price is None:
else: price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
price = item.tax(price, base_price_is='gross', 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: if price is None:
raise OrderError(self.error_messages['product_invalid']) raise OrderError(self.error_messages['product_invalid'])
@@ -1952,7 +1971,10 @@ class OrderChangeManager:
self._check_quotas() self._check_quotas()
self._check_seats() self._check_seats()
self._check_complete_cancel() 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._recalculate_total_and_payment_fee()
self._reissue_invoice() self._reissue_invoice()
self._clear_tickets_cache() self._clear_tickets_cache()

View File

@@ -1146,6 +1146,7 @@ class TaxRuleLineForm(forms.Form):
('vat', _('Charge VAT')), ('vat', _('Charge VAT')),
('reverse', _('Reverse charge')), ('reverse', _('Reverse charge')),
('no', _('No VAT')), ('no', _('No VAT')),
('block', _('Sale not allowed')),
], ],
) )
rate = forms.DecimalField( rate = forms.DecimalField(

View File

@@ -18,7 +18,7 @@ from django_scopes import scopes_disabled
from pretix.base.models import Order from pretix.base.models import Order
from pretix.base.models.orders import InvoiceAddress, OrderPayment 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 ( from pretix.base.services.cart import (
CartError, error_messages, get_fees, set_cart_addons, update_tax_rates, 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 self.cart_session['contact_form_data'] = self.contact_form.cleaned_data
if self.address_asked or self.request.event.settings.invoice_name_required: if self.address_asked or self.request.event.settings.invoice_name_required:
addr = self.invoice_form.save() 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( self.cart_session['invoice_address'] = addr.pk
event=request.event,
cart_id=get_or_create_cart_id(request),
invoice_address=self.invoice_form.instance
)
if abs(diff) > Decimal('0.001'): if abs(diff) > Decimal('0.001'):
messages.info(request, _('Due to the invoice address you entered, we need to apply a different tax ' 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 ' 'rate to your purchase and the price of the products in your cart has '

View File

@@ -23,7 +23,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from pretix.base.models import ( from pretix.base.models import (
CachedTicket, GiftCard, Invoice, Order, OrderPosition, Quota, CachedTicket, GiftCard, Invoice, Order, OrderPosition, Quota, TaxRule,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund, CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund,
@@ -699,6 +699,13 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem
messages.error(self.request, messages.error(self.request,
_("We had difficulties processing your input. Please review the errors below.")) _("We had difficulties processing your input. Please review the errors below."))
return self.get(request, *args, **kwargs) 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'): if hasattr(self.invoice_form, 'save'):
self.invoice_form.save() self.invoice_form.save()
self.order.log_action('pretix.event.order.modified', { self.order.log_action('pretix.event.order.modified', {

View File

@@ -378,6 +378,55 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
cr1.refresh_from_db() cr1.refresh_from_db()
assert cr1.price == Decimal('23.00') 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): def test_country_taxing(self):
self._enable_country_specific_taxing() self._enable_country_specific_taxing()