diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 53d62a3608..bb77475506 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -720,6 +720,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): consume_carts = validated_data.pop('consume_carts', []) delete_cps = [] quota_avail_cache = {} + v_budget = {} voucher_usage = Counter() if consume_carts: for cp in CartPosition.objects.filter( @@ -742,9 +743,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer): errs = [{} for p in positions_data] for i, pos_data in enumerate(positions_data): + if pos_data.get('voucher'): v = pos_data['voucher'] + if pos_data.get('addon_to'): + errs[i]['voucher'] = ['Vouchers are currently not supported for add-on products.'] + continue + if not v.applies_to(pos_data['item'], pos_data.get('variation')): errs[i]['voucher'] = [error_messages['voucher_invalid_item']] continue @@ -768,6 +774,44 @@ class OrderCreateSerializer(I18nAwareModelSerializer): 'The voucher has already been used the maximum number of times.' ] + if v.budget is not None: + price = pos_data.get('price') + if price is None: + price = get_price( + item=pos_data.get('item'), + variation=pos_data.get('variation'), + voucher=v, + custom_price=None, + subevent=pos_data.get('subevent'), + addon_to=pos_data.get('addon_to'), + invoice_address=ia, + ).gross + pbv = get_price( + item=pos_data['item'], + variation=pos_data.get('variation'), + voucher=None, + custom_price=None, + subevent=pos_data.get('subevent'), + addon_to=pos_data.get('addon_to'), + invoice_address=ia, + ) + + if v not in v_budget: + v_budget[v] = v.budget - v.budget_used() + disc = pbv.gross - price + if disc > v_budget[v]: + new_disc = v_budget[v] + v_budget[v] -= new_disc + if new_disc == Decimal('0.00') or pos_data.get('price') is not None: + errs[i]['voucher'] = [ + 'The voucher has a remaining budget of {}, therefore a discount of {} can not be ' + 'given.'.format(v_budget[v] + new_disc, disc) + ] + continue + pos_data['price'] = price + (disc - new_disc) + else: + v_budget[v] -= disc + seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists() if pos_data.get('seat'): if not seated: @@ -856,6 +900,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer): pos.tax_rule = pos.item.tax_rule else: pos._calculate_tax() + + pos.price_before_voucher = get_price( + item=pos.item, + variation=pos.variation, + voucher=None, + custom_price=None, + subevent=pos.subevent, + addon_to=pos.addon_to, + invoice_address=ia, + ).gross + if pos.voucher: Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1) pos.save() diff --git a/src/pretix/base/migrations/0142_auto_20191215_1522.py b/src/pretix/base/migrations/0142_auto_20191215_1522.py new file mode 100644 index 0000000000..57690caee5 --- /dev/null +++ b/src/pretix/base/migrations/0142_auto_20191215_1522.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.7 on 2019-12-15 15:22 + +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0141_seat_sorting_rank'), + ] + + operations = [ + migrations.AddField( + model_name='cartposition', + name='price_before_voucher', + field=models.DecimalField(decimal_places=2, max_digits=10, null=True), + ), + migrations.AddField( + model_name='orderposition', + name='price_before_voucher', + field=models.DecimalField(decimal_places=2, max_digits=10, null=True), + ), + migrations.AddField( + model_name='voucher', + name='budget', + field=models.DecimalField(decimal_places=2, max_digits=10, null=True), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 14252e3f7a..8ec4cfe9c3 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -687,10 +687,12 @@ class Order(LockModel, LoggedModel): error_messages = { 'unavailable': _('The ordered product "{item}" is no longer available.'), 'seat_unavailable': _('The seat "{seat}" is no longer available.'), + 'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'), } now_dt = now_dt or now() - positions = self.positions.all().select_related('item', 'variation', 'seat') + positions = self.positions.all().select_related('item', 'variation', 'seat', 'voucher') quota_cache = {} + v_budget = {} try: for i, op in enumerate(positions): if op.seat: @@ -699,6 +701,16 @@ class Order(LockModel, LoggedModel): if force: continue + if op.voucher and op.voucher.budget is not None and op.price_before_voucher is not None: + if op.voucher not in v_budget: + v_budget[op.voucher] = op.voucher.budget - op.voucher.budget_used() + disc = op.price_before_voucher - op.price + if disc > v_budget[op.voucher]: + raise Quota.QuotaExceededException(error_messages['voucher_budget'].format( + voucher=op.voucher.code + )) + v_budget[op.voucher] -= disc + quotas = list(op.quotas) if len(quotas) == 0: raise Quota.QuotaExceededException(error_messages['unavailable'].format( @@ -991,6 +1003,9 @@ class AbstractPosition(models.Model): verbose_name=_("Variation"), on_delete=models.PROTECT ) + price_before_voucher = models.DecimalField( + decimal_places=2, max_digits=10, null=True, + ) price = models.DecimalField( decimal_places=2, max_digits=10, verbose_name=_("Price") diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 3bc82abb1f..8239bed77b 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -4,7 +4,8 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models -from django.db.models import Q +from django.db.models import F, OuterRef, Q, Subquery, Sum +from django.db.models.functions import Coalesce from django.utils.crypto import get_random_string from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ @@ -17,7 +18,7 @@ from ..decimal import round_decimal from .base import LoggedModel from .event import Event, SubEvent from .items import Item, ItemVariation, Quota -from .orders import Order +from .orders import Order, OrderPosition def _generate_random_code(prefix=None): @@ -114,6 +115,13 @@ class Voucher(LoggedModel): verbose_name=_("Redeemed"), default=0 ) + budget = models.DecimalField( + verbose_name=_("Maximum discount budget"), + help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. " + "If this is sum reached, the voucher can no longer be used."), + decimal_places=2, max_digits=10, + null=True, blank=True + ) valid_until = models.DateTimeField( blank=True, null=True, db_index=True, verbose_name=_("Valid until") @@ -430,7 +438,7 @@ class Voucher(LoggedModel): return False return True - def calculate_price(self, original_price: Decimal) -> Decimal: + def calculate_price(self, original_price: Decimal, max_discount: Decimal=None) -> Decimal: """ Returns how the price given in original_price would be modified if this voucher is applied, i.e. replaced by a different price or reduced by a @@ -448,7 +456,9 @@ class Voucher(LoggedModel): p = original_price places = settings.CURRENCY_PLACES.get(self.event.currency, 2) if places < 2: - return p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP) + p = p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP) + if max_discount is not None: + p = max(p, original_price - max_discount) return p return original_price @@ -470,3 +480,26 @@ class Voucher(LoggedModel): return self.item.seat_category_mappings.filter(**kwargs).exists() else: return bool(subevent.seating_plan) if subevent else self.event.seating_plan + + @classmethod + def annotate_budget_used_orders(cls, qs): + opq = OrderPosition.objects.filter( + voucher_id=OuterRef('pk'), + price_before_voucher__isnull=False, + order__status__in=[ + Order.STATUS_PAID, + Order.STATUS_PENDING + ] + ).order_by().values('voucher_id').annotate(s=Sum(F('price_before_voucher') - F('price'))).values('s') + return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00'))) + + def budget_used(self): + ops = OrderPosition.objects.filter( + voucher=self, + price_before_voucher__isnull=False, + order__status__in=[ + Order.STATUS_PAID, + Order.STATUS_PENDING + ] + ).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00') + return ops diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 1479c9f1e1..09f2185e4f 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -108,11 +108,12 @@ error_messages = { class CartManager: AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas', - 'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat')) + 'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat', + 'price_before_voucher')) RemoveOperation = namedtuple('RemoveOperation', ('position',)) VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price')) ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', - 'quotas', 'subevent', 'seat')) + 'quotas', 'subevent', 'seat', 'price_before_voucher')) order = { RemoveOperation: 10, VoucherOperation: 15, @@ -384,6 +385,7 @@ class CartManager: else: price = self._get_price(cp.item, cp.variation, cp.voucher, price, cp.subevent, force_custom_price=True) + pbv = TAXED_ZERO else: bundled_sum = Decimal('0.00') if not cp.addon_to_id: @@ -396,9 +398,14 @@ class CartManager: price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, cp_is_net=True, bundled_sum=bundled_sum) price = TaxedPrice(net=price.net, gross=price.net, rate=0, tax=0, name='') + pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent, + cp_is_net=True, bundled_sum=bundled_sum) + pbv = TaxedPrice(net=pbv.net, gross=pbv.net, rate=0, tax=0, name='') else: price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, bundled_sum=bundled_sum) + pbv = self._get_price(cp.item, cp.variation, None, cp.price, cp.subevent, + bundled_sum=bundled_sum) quotas = list(cp.quotas) if not quotas: @@ -414,7 +421,7 @@ class CartManager: op = self.ExtendOperation( position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1, - price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat + price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, price_before_voucher=pbv ) self._check_item_constraints(op) @@ -569,16 +576,18 @@ class CartManager: bop = self.AddOperation( count=bundle.count, item=bitem, variation=bvar, price=bprice, voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent, - includes_tax=bool(bprice.rate), bundled=[], seat=None + includes_tax=bool(bprice.rate), bundled=[], seat=None, price_before_voucher=bprice, ) self._check_item_constraints(bop, operations) bundled.append(bop) price = self._get_price(item, variation, voucher, i.get('price'), subevent, bundled_sum=bundled_sum) + pbv = self._get_price(item, variation, None, i.get('price'), subevent, bundled_sum=bundled_sum) op = self.AddOperation( count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas, - addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat + addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat, + price_before_voucher=pbv ) self._check_item_constraints(op, operations) operations.append(op) @@ -684,7 +693,8 @@ class CartManager: op = self.AddOperation( count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, - addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None + addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=None, + price_before_voucher=None ) self._check_item_constraints(op, operations) operations.append(op) @@ -898,7 +908,8 @@ class CartManager: event=self.event, item=op.item, variation=op.variation, 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 + subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat, + 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: scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme) @@ -945,6 +956,8 @@ class CartManager: elif available_count == 1: op.position.expires = self._expiry op.position.price = op.price.gross + if op.price_before_voucher is not None: + op.position.price_before_voucher = op.price_before_voucher.gross try: op.position.save(force_update=True) except DatabaseError: @@ -964,6 +977,7 @@ class CartManager: # be expected continue + op.position.price_before_voucher = op.position.price op.position.price = op.price.gross op.position.voucher = op.voucher op.position.save() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5c59e81af3..d15e9ce246 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -70,6 +70,8 @@ error_messages = { 'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'), 'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum ' 'number of times allowed. We removed this item from your cart.'), + 'voucher_budget_used': _('The voucher code used for one of the items in your cart has already been too often. We ' + 'adjusted the price of the item in your cart.'), 'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item ' 'from your cart.'), 'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We ' @@ -426,6 +428,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio products_seen = Counter() changed_prices = {} + v_budget = {} deleted_positions = set() seats_seen = set() @@ -468,6 +471,20 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio delete(cp) continue + if cp.voucher.budget is not None: + if cp.voucher not in v_budget: + v_budget[cp.voucher] = cp.voucher.budget - cp.voucher.budget_used() + disc = cp.price_before_voucher - cp.price + if disc > v_budget[cp.voucher]: + new_disc = max(0, v_budget[cp.voucher]) + cp.price = cp.price + (disc - new_disc) + cp.save() + err = err or error_messages['voucher_budget_used'] + v_budget[cp.voucher] -= new_disc + continue + else: + v_budget[cp.voucher] -= disc + if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start: err = err or error_messages['some_subevent_not_started'] delete(cp) @@ -522,6 +539,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio # Other checks are not necessary continue + max_discount = None + if cp.price_before_voucher is not None and cp.voucher in v_budget: + 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) @@ -529,7 +551,9 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio except ItemBundle.DoesNotExist: bprice = cp.price price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False, - invoice_address=address, force_custom_price=True) + 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, + invoice_address=address, force_custom_price=True, max_discount=max_discount) changed_prices[cp.pk] = bprice else: bundled_sum = 0 @@ -539,7 +563,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio 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) + addon_to=cp.addon_to, invoice_address=address, bundled_sum=bundled_sum, + max_discount=max_discount) + 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) + + if max_discount is not None: + v_budget[cp.voucher] = v_budget[cp.voucher] + current_discount - (pbv.gross - price.gross) if price is False or len(quotas) == 0: err = err or error_messages['unavailable'] @@ -552,6 +583,11 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio delete(cp) continue + if pbv is not None and pbv.gross != price.gross: + cp.price_before_voucher = pbv.gross + else: + cp.price_before_voucher = None + if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross): cp.price = price.gross cp.includes_tax = bool(price.rate) @@ -802,7 +838,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], lockfn = event.lock with lockfn() as now_dt: - positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')) + positions = list( + positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons') + ) + positions.sort(key=lambda k: position_ids.index(k.pk)) if len(positions) == 0: raise OrderError(error_messages['empty']) if len(position_ids) != len(positions): @@ -1362,6 +1401,16 @@ class OrderChangeManager: op.position.item = op.item op.position.variation = op.variation op.position._calculate_tax() + if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id: + op.position.price_before_voucher = max( + op.position.price, + get_price( + op.position.item, op.position.variation, + subevent=op.position.subevent, + custom_price=op.position.price, + invoice_address=self._invoice_address + ).gross + ) op.position.save() elif isinstance(op, self.SeatOperation): self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={ @@ -1385,6 +1434,16 @@ class OrderChangeManager: }) op.position.subevent = op.subevent op.position.save() + if op.position.price_before_voucher is not None and op.position.voucher and not op.position.addon_to_id: + op.position.price_before_voucher = max( + op.position.price, + get_price( + op.position.item, op.position.variation, + subevent=op.position.subevent, + custom_price=op.position.price, + invoice_address=self._invoice_address + ).gross + ) elif isinstance(op, self.FeeValueOperation): self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={ 'fee': op.fee.pk, diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index e46c8f9ef9..16de473748 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -12,7 +12,8 @@ def get_price(item: Item, variation: ItemVariation = None, voucher: Voucher = None, custom_price: Decimal = 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')) -> TaxedPrice: + force_custom_price: bool = False, bundled_sum: Decimal = Decimal('0.00'), + max_discount: Decimal = None) -> TaxedPrice: if addon_to: try: iao = addon_to.item.addons.get(addon_category_id=item.category_id) @@ -32,7 +33,7 @@ def get_price(item: Item, variation: ItemVariation = None, price = subevent.var_price_overrides[variation.pk] if voucher: - price = voucher.calculate_price(price) + price = voucher.calculate_price(price, max_discount=max_discount) if item.tax_rule: tax_rule = item.tax_rule diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 8d79910d90..83c93889c3 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -38,7 +38,7 @@ class VoucherForm(I18nModelForm): localized_fields = '__all__' fields = [ 'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', - 'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', + 'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget' ] field_classes = { 'valid_until': SplitDateTimeField, @@ -268,7 +268,7 @@ class VoucherBulkForm(VoucherForm): localized_fields = '__all__' fields = [ 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', - 'max_usages', 'price_mode', 'subevent', 'show_hidden_items' + 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget' ] field_classes = { 'valid_until': SplitDateTimeField, diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index e85d194981..2930d2a112 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -80,6 +80,15 @@ {{ position.custom_error }} {% endif %} + {% if position.voucher and position.voucher.budget %} +
+ {% blocktrans trimmed %} + This position has been created with a voucher with a limited budget. If you + change the price or item, the discount will still be calculated from the original + price at the time of purchase. + {% endblocktrans %} +
+ {% endif %}
{% trans "Current value" %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 6bf7fea6ce..2ca5501b91 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -272,7 +272,9 @@ {% endif %} {% if line.voucher %}
{% trans "Voucher code used:" %} - + {{ line.voucher.code }} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html index 083c6b7d0f..7be73a1799 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html @@ -73,6 +73,7 @@ {% trans "Advanced settings" %} {% bootstrap_field form.block_quota layout="control" %} {% bootstrap_field form.allow_ignore_quota layout="control" %} + {% bootstrap_field form.budget addon_after=request.event.currency layout="control" %} {% bootstrap_field form.tag layout="control" %} {% bootstrap_field form.comment layout="control" %} {% bootstrap_field form.show_hidden_items layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html index 55e06388fe..8ff1267ff4 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html @@ -75,6 +75,7 @@ {% trans "Advanced settings" %} {% bootstrap_field form.block_quota layout="control" %} {% bootstrap_field form.allow_ignore_quota layout="control" %} + {% bootstrap_field form.budget addon_after=request.event.currency layout="control" %} {% bootstrap_field form.tag layout="control" %} {% bootstrap_field form.comment layout="control" %} {% bootstrap_field form.show_hidden_items layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/index.html b/src/pretix/control/templates/pretixcontrol/vouchers/index.html index 381e15d21c..280afef1f5 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/index.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/index.html @@ -2,6 +2,7 @@ {% load i18n %} {% load bootstrap3 %} {% load urlreplace %} +{% load money %} {% block title %}{% trans "Vouchers" %}{% endblock %} {% block content %}

{% trans "Vouchers" %}

@@ -143,7 +144,15 @@ {{ v.code }} {% if not v.is_active %}{% endif %} - {{ v.redeemed }} / {{ v.max_usages }} + + {{ v.redeemed }} / {{ v.max_usages }} + {% if v.budget|default_if_none:"NONE" != "NONE" %} +
+ + {{ v.budget_used_orders|money:request.event.currency }} / {{ v.budget|money:request.event.currency }} + + {% endif %} + {{ v.valid_until|date }} {{ v.tag }} diff --git a/src/pretix/control/views/vouchers.py b/src/pretix/control/views/vouchers.py index 3aa78524e1..5b247f9019 100644 --- a/src/pretix/control/views/vouchers.py +++ b/src/pretix/control/views/vouchers.py @@ -37,9 +37,11 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView): permission = 'can_view_vouchers' def get_queryset(self): - qs = self.request.event.vouchers.filter(waitinglistentries__isnull=True).select_related( + qs = Voucher.annotate_budget_used_orders(self.request.event.vouchers.filter( + waitinglistentries__isnull=True + ).select_related( 'item', 'variation', 'seat' - ) + )) if self.filter_form.is_valid(): qs = self.filter_form.filter_qs(qs) diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 90f1ae4843..e36174bc65 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -3105,6 +3105,81 @@ def test_order_create_auto_pricing_reverse_charge_require_valid_vatid(token_clie assert p.tax_rate == Decimal('19.00') +@pytest.mark.django_db +def test_order_create_autopricing_voucher_budget_partially(token_client, organizer, event, item, quota, question, + taxrule): + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('2.50'), + max_usages=999) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['voucher'] = voucher.code + del res['positions'][0]['price'] + del res['positions'][0]['positionid'] + res['positions'].append(res['positions'][0]) + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + print(resp.data) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + p = o.positions.first() + p2 = o.positions.last() + assert p.price == Decimal('21.50') + assert p2.price == Decimal('22.00') + + +@pytest.mark.django_db +def test_order_create_autopricing_voucher_budget_full(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('0.50'), + max_usages=999) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['voucher'] = voucher.code + del res['positions'][0]['price'] + del res['positions'][0]['positionid'] + res['positions'].append(res['positions'][0]) + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'positions': [{}, {'voucher': ['The voucher has a remaining budget of 0.00, therefore a ' + 'discount of 1.50 can not be given.']}]} + + +@pytest.mark.django_db +def test_order_create_voucher_budget_exceeded(token_client, organizer, event, item, quota, question, taxrule): + with scopes_disabled(): + voucher = event.vouchers.create(price_mode="set", value=21.50, item=item, budget=Decimal('3.00'), + max_usages=999) + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['positions'][0]['voucher'] = voucher.code + res['positions'][0]['price'] = '19.00' + del res['positions'][0]['positionid'] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + print(resp.data) + assert resp.status_code == 400 + assert resp.data == {'positions': [{'voucher': ['The voucher has a remaining budget of 3.00, therefore a ' + 'discount of 4.00 can not be given.']}]} + + @pytest.mark.django_db def test_order_create_voucher_price(token_client, organizer, event, item, quota, question): res = copy.deepcopy(ORDER_CREATE_PAYLOAD) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 5b6a3bb3d6..a47e19cef7 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -795,6 +795,31 @@ class VoucherTestCase(BaseQuotaTestCase): v = Voucher.objects.create(event=self.event, price_mode='percent', value=Decimal('23.00')) assert v.calculate_price(Decimal('100.00')) == Decimal('77.00') + @classscope(attr='o') + def test_calculate_price_max_discount(self): + v = Voucher.objects.create(event=self.event, price_mode='subtract', value=Decimal('10.00')) + assert v.calculate_price(Decimal('23.42'), max_discount=Decimal('5.00')) == Decimal('18.42') + + @classscope(attr='o') + def test_calculate_budget_used(self): + v = Voucher.objects.create(event=self.event, price_mode='sset', value=Decimal('20.00')) + + order = Order.objects.create( + status=Order.STATUS_PENDING, event=self.event, + datetime=now() - timedelta(days=5), expires=now() + timedelta(days=5), total=46, + ) + OrderPosition.objects.create(order=order, item=self.item1, voucher=v, price=Decimal('20.00'), + price_before_voucher=Decimal('23.00')) + assert v.budget_used() == Decimal('3.00') + + order = Order.objects.create( + status=Order.STATUS_PAID, event=self.event, + datetime=now() - timedelta(days=5), expires=now() + timedelta(days=5), total=46, + ) + OrderPosition.objects.create(order=order, item=self.item1, voucher=v, price=Decimal('20.00'), + price_before_voucher=Decimal('23.00')) + assert v.budget_used() == Decimal('6.00') + class OrderTestCase(BaseQuotaTestCase): def setUp(self): diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 2146b6d190..d8546b89e7 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -878,6 +878,36 @@ class OrderChangeManagerTests(TestCase): assert self.op1.tax_value == Decimal('3.67') assert self.op1.tax_rule == self.shirt.tax_rule + @classscope(attr='o') + def test_change_item_change_price_before_voucher(self): + self.op1.voucher = self.event.vouchers.create(item=self.shirt, redeemed=1, price_mode='set', value='5.00') + self.op1.price = Decimal('5.00') + self.op1.price_before_voucher = Decimal('23.00') + self.op1.save() + p = self.op1.price + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.shirt + assert self.op1.price == p + assert self.op1.price_before_voucher == Decimal('12.00') + + @classscope(attr='o') + def test_change_item_change_price_before_voucher_minimum_value(self): + self.op1.voucher = self.event.vouchers.create(item=self.shirt, redeemed=1, price_mode='set', value='20.00') + self.op1.price = Decimal('20.00') + self.op1.price_before_voucher = Decimal('23.00') + self.op1.save() + p = self.op1.price + self.ocm.change_item(self.op1, self.shirt, None) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.item == self.shirt + assert self.op1.price == p + assert self.op1.price_before_voucher == Decimal('20.00') + @classscope(attr='o') def test_change_item_success(self): self.ocm.change_item(self.op1, self.shirt, None) diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 8d44bb489c..dd5c878260 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -845,6 +845,69 @@ def test_order_extend_expired_quota_partial(client, env): assert o.status == Order.STATUS_EXPIRED +@pytest.mark.django_db +def test_order_extend_expired_voucher_budget_ok(client, env): + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + o.expires = now() - timedelta(days=5) + o.status = Order.STATUS_EXPIRED + o.save() + v = env[0].vouchers.create( + code="foo", price_mode='subtract', value=Decimal('1.50'), budget=Decimal('1.50') + ) + p = o.positions.first() + p.voucher = v + p.price_before_voucher = p.price + p.price -= Decimal('1.50') + p.save() + + q = Quota.objects.create(event=env[0], size=100) + q.items.add(env[3]) + newdate = (now() + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S") + client.login(email='dummy@dummy.dummy', password='dummy') + response = client.post('/control/event/dummy/dummy/orders/FOO/extend', { + 'expires': newdate + }, follow=True) + assert b'alert-success' in response.content + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + assert o.status == Order.STATUS_PENDING + assert v.budget_used() == Decimal('1.50') + + +@pytest.mark.django_db +def test_order_extend_expired_voucher_budget_fail(client, env): + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + o.expires = now() - timedelta(days=5) + o.status = Order.STATUS_EXPIRED + olddate = o.expires + o.save() + v = env[0].vouchers.create( + code="foo", price_mode='subtract', value=Decimal('1.50'), budget=Decimal('0.00') + ) + p = o.positions.first() + p.voucher = v + p.price_before_voucher = p.price + p.price -= Decimal('1.50') + p.save() + + q = Quota.objects.create(event=env[0], size=100) + q.items.add(env[3]) + newdate = (now() + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S") + client.login(email='dummy@dummy.dummy', password='dummy') + response = client.post('/control/event/dummy/dummy/orders/FOO/extend', { + 'expires': newdate + }, follow=True) + assert b'alert-danger' in response.content + assert b'The voucher "FOO" no longer has sufficient budget.' in response.content + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + assert o.expires.strftime("%Y-%m-%d %H:%M:%S") == olddate.strftime("%Y-%m-%d %H:%M:%S") + assert o.status == Order.STATUS_EXPIRED + assert v.budget_used() == Decimal('0.00') + + @pytest.mark.django_db def test_order_mark_paid_overdue_quota_blocked_by_waiting_list(client, env): with scopes_disabled(): diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 051a487cc6..117c718c9c 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -1388,6 +1388,32 @@ class CartTest(CartTestMixin, TestCase): self.assertEqual(objs[0].item, self.ticket) self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, Decimal('21.00')) + self.assertEqual(objs[0].price_before_voucher, Decimal('23.00')) + + def test_voucher_free_price_before_voucher_cap(self): + with scopes_disabled(): + v = Voucher.objects.create(item=self.ticket, value=Decimal('10.00'), price_mode='percent', event=self.event) + self.ticket.free_price = True + self.ticket.save() + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'price_%d' % self.ticket.id: '41.00', + '_voucher_code': v.code, + }, 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) + self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text) + self.assertIn('41', doc.select('.cart .cart-row')[0].select('.price')[0].text) + self.assertIn('41', doc.select('.cart .cart-row')[0].select('.price')[1].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].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, Decimal('41.00')) + self.assertEqual(objs[0].price_before_voucher, Decimal('41.00')) def test_voucher_free_price_lower_bound(self): with scopes_disabled(): @@ -1412,6 +1438,7 @@ class CartTest(CartTestMixin, TestCase): self.assertEqual(objs[0].item, self.ticket) self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, Decimal('20.70')) + self.assertEqual(objs[0].price_before_voucher, Decimal('23.00')) def test_voucher_redemed(self): with scopes_disabled(): @@ -2438,6 +2465,20 @@ class CartAddonTest(CartTestMixin, TestCase): assert cp2.expires > now() assert cp2.addon_to_id == cp1.pk + @classscope(attr='orga') + def test_expand_expired_refresh_voucher(self): + v = Voucher.objects.create(item=self.ticket, value=Decimal('20.00'), event=self.event, price_mode='set', + valid_until=now() + timedelta(days=2), max_usages=999, redeemed=0) + cp1 = CartPosition.objects.create( + expires=now() - timedelta(minutes=10), item=self.ticket, price=Decimal('21.50'), + event=self.event, cart_id=self.session_key, voucher=v + ) + self.cm.extend_expired_positions() + self.cm.commit() + cp1.refresh_from_db() + assert cp1.expires > now() + assert cp1.price_before_voucher == Decimal('23.00') + class CartBundleTest(CartTestMixin, TestCase): @scopes_disabled() @@ -2490,6 +2531,30 @@ class CartBundleTest(CartTestMixin, TestCase): assert cp.price == 23 - 1.5 assert cp.addons.count() == 1 assert cp.voucher == v + assert cp.price_before_voucher == 23 - 1.5 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 1.5 + assert not a.voucher + + @classscope(attr='orga') + def test_discounted_voucher_on_base_product(self): + v = self.event.vouchers.create(code="foo", item=self.ticket, price_mode='subtract', value=Decimal('1.50')) + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'voucher': v.code, + 'count': 1 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 - 1.5 - 1.5 + assert cp.addons.count() == 1 + assert cp.voucher == v + assert cp.price_before_voucher == 23 - 1.5 a = cp.addons.get() assert a.item == self.trans assert a.price == 1.5 diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index b2a548fb37..03091ef7db 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -3000,3 +3000,140 @@ class CheckoutSeatingTest(BaseCheckoutTestCase, TestCase): with self.assertRaises(OrderError): _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web') assert not CartPosition.objects.filter(pk=self.cp1.pk).exists() + + +class CheckoutVoucherBudgetTest(BaseCheckoutTestCase, TestCase): + @scopes_disabled() + def setUp(self): + super().setUp() + self.v = Voucher.objects.create(item=self.ticket, value=Decimal('21.50'), event=self.event, price_mode='set', + valid_until=now() + timedelta(days=2), max_usages=999, redeemed=0) + self.cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price_before_voucher=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v + ) + self.cp2 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price_before_voucher=23, price=21.5, expires=now() + timedelta(minutes=10), voucher=self.v + ) + + @scopes_disabled() + def test_no_budget(self): + oid = _perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + o = Order.objects.get(pk=oid) + op = o.positions.first() + assert op.item == self.ticket + assert op.price_before_voucher == Decimal('23.00') + + @scopes_disabled() + def test_budget_exceeded_for_second_order(self): + self.v.budget = Decimal('1.50') + self.v.save() + oid = _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, + 'web') + o = Order.objects.get(pk=oid) + op = o.positions.first() + assert op.item == self.ticket + + with self.assertRaises(OrderError): + _perform_order(self.event, 'manual', [self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + self.cp2.refresh_from_db() + assert self.cp2.price == Decimal('23.00') + + @scopes_disabled() + def test_budget_exceeded_between_positions(self): + self.v.budget = Decimal('1.50') + self.v.save() + with self.assertRaises(OrderError): + _perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + self.cp1.refresh_from_db() + assert self.cp1.price == Decimal('21.50') + self.cp2.refresh_from_db() + assert self.cp2.price == Decimal('23.00') + + @scopes_disabled() + def test_budget_exceeded_in_first_position(self): + self.v.budget = Decimal('1.00') + self.v.save() + with self.assertRaises(OrderError): + _perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + self.cp1.refresh_from_db() + assert self.cp1.price == Decimal('22.00') + self.cp2.refresh_from_db() + assert self.cp2.price == Decimal('23.00') + + @scopes_disabled() + def test_budget_exceeded_in_second_position(self): + self.v.budget = Decimal('2.50') + self.v.save() + with self.assertRaises(OrderError): + _perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + self.cp1.refresh_from_db() + assert self.cp1.price == Decimal('21.50') + self.cp2.refresh_from_db() + assert self.cp2.price == Decimal('22.00') + + @scopes_disabled() + def test_budget_exceeded_during_price_change(self): + self.v.budget = Decimal('2.50') + self.v.value = Decimal('21.00') + self.v.save() + self.cp1.expires = now() - timedelta(hours=1) + self.cp1.save() + self.cp2.expires = now() - timedelta(hours=1) + self.cp2.save() + + with self.assertRaises(OrderError): + _perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + self.cp1.refresh_from_db() + assert self.cp1.price == Decimal('21.00') + self.cp2.refresh_from_db() + assert self.cp2.price == Decimal('22.50') + + @scopes_disabled() + def test_budget_exceeded_expired_cart(self): + self.v.budget = Decimal('0.00') + self.v.value = Decimal('21.00') + self.v.save() + self.cp1.expires = now() - timedelta(hours=1) + self.cp1.save() + self.cp2.expires = now() - timedelta(hours=1) + self.cp2.save() + + with self.assertRaises(OrderError): + _perform_order(self.event, 'manual', [self.cp1.pk, self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + self.cp1.refresh_from_db() + assert self.cp1.price == Decimal('23.00') + self.cp2.refresh_from_db() + assert self.cp2.price == Decimal('23.00') + + @scopes_disabled() + def test_budget_overbooked_expired_cart(self): + self.v.budget = Decimal('1.50') + self.v.value = Decimal('21.50') + self.v.save() + self.cp1.expires = now() - timedelta(hours=1) + self.cp1.save() + self.cp2.expires = now() - timedelta(hours=1) + self.cp2.save() + oid = _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, + 'web') + o = Order.objects.get(pk=oid) + op = o.positions.first() + + assert op.item == self.ticket + self.v.budget = Decimal('1.00') + self.v.save() + + with self.assertRaises(OrderError): + _perform_order(self.event, 'manual', [self.cp2.pk], 'admin@example.org', 'en', None, {}, + 'web') + self.cp2.refresh_from_db() + assert self.cp2.price == Decimal('23.00')