From f7f00fe735b99fcc48744b2e404b5413bed38bd6 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 10 Sep 2019 22:21:36 +0200 Subject: [PATCH 01/32] Data model --- .../migrations/0135_auto_20190910_2020.py | 47 ++++++++++ src/pretix/base/models/__init__.py | 1 + src/pretix/base/models/giftcards.py | 90 +++++++++++++++++++ src/pretix/settings.py | 1 + 4 files changed, 139 insertions(+) create mode 100644 src/pretix/base/migrations/0135_auto_20190910_2020.py create mode 100644 src/pretix/base/models/giftcards.py diff --git a/src/pretix/base/migrations/0135_auto_20190910_2020.py b/src/pretix/base/migrations/0135_auto_20190910_2020.py new file mode 100644 index 0000000000..33d55371bc --- /dev/null +++ b/src/pretix/base/migrations/0135_auto_20190910_2020.py @@ -0,0 +1,47 @@ +# Generated by Django 2.2.1 on 2019-09-10 20:20 + +from django.db import migrations, models +import django.db.models.deletion +import pretix.base.models.fields +import pretix.base.models.giftcards + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0134_auto_20190909_1042'), + ] + + operations = [ + migrations.CreateModel( + name='GiftCard', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('issuance', models.DateTimeField(auto_now_add=True)), + ('secret', models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret, max_length=190, unique=True)), + ('currency', models.CharField(max_length=10)), + ('issued_in', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.OrderPosition')), + ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.Organizer')), + ], + ), + migrations.CreateModel( + name='GiftCardTransaction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('datetime', models.DateTimeField(auto_now_add=True)), + ('value', models.DecimalField(decimal_places=2, max_digits=10)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='pretixbase.GiftCard')), + ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gift_card_transactions', to='pretixbase.Order')), + ('payment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gift_card_transactions', to='pretixbase.OrderPayment')), + ('refund', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gift_card_transactions', to='pretixbase.OrderRefund')), + ], + ), + migrations.CreateModel( + name='GiftCardAcceptance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('collector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_card_issuer_acceptance', to='pretixbase.Organizer')), + ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_card_collector_acceptance', to='pretixbase.Organizer')), + ], + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 22175ed9fa..2793d22b23 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -7,6 +7,7 @@ from .event import ( Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token, ) +from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py new file mode 100644 index 0000000000..ac68d6cdbf --- /dev/null +++ b/src/pretix/base/models/giftcards.py @@ -0,0 +1,90 @@ +from decimal import Decimal + +from django.conf import settings +from django.db import models +from django.db.models import Sum +from django.utils.crypto import get_random_string + + +def gen_giftcard_secret(): + charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') + while True: + code = get_random_string(length=settings.ENTROPY['giftcard_secret'], allowed_chars=charset) + if not GiftCard.objects.filter(secret=code).exists(): + return code + + +class GiftCardAcceptance(models.Model): + issuer = models.ForeignKey( + 'Organizer', + related_name='gift_card_collector_acceptance', + on_delete=models.CASCADE + ) + collector = models.ForeignKey( + 'Organizer', + related_name='gift_card_issuer_acceptance', + on_delete=models.CASCADE + ) + + +class GiftCard(models.Model): + issuer = models.ForeignKey( + 'Organizer', + related_name='issued_gift_cards', + on_delete=models.PROTECT, + ) + issued_in = models.ForeignKey( + 'OrderPosition', + related_name='issued_gift_cards', + on_delete=models.PROTECT, + ) + issuance = models.DateTimeField( + auto_now_add=True, + ) + secret = models.CharField( + max_length=190, + default=gen_giftcard_secret, + unique=True, + db_index=True, + ) + currency = models.CharField(max_length=10) + + @property + def value(self): + return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00') + + +class GiftCardTransaction(models.Model): + card = models.ForeignKey( + 'GiftCard', + related_name='transactions', + on_delete=models.PROTECT + ) + datetime = models.DateTimeField( + auto_now_add=True + ) + value = models.DecimalField( + decimal_places=2, + max_digits=10 + ) + order = models.ForeignKey( + 'Order', + related_name='gift_card_transactions', + null=True, + blank=True, + on_delete=models.PROTECT + ) + payment = models.ForeignKey( + 'OrderPayment', + related_name='gift_card_transactions', + null=True, + blank=True, + on_delete=models.PROTECT + ) + refund = models.ForeignKey( + 'OrderRefund', + related_name='gift_card_transactions', + null=True, + blank=True, + on_delete=models.PROTECT + ) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 4dd418060f..dbc6ccbdcb 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -241,6 +241,7 @@ ENTROPY = { 'order_code': config.getint('entropy', 'order_code', fallback=5), 'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32), 'voucher_code': config.getint('entropy', 'voucher_code', fallback=16), + 'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=16), } # Internal settings From ed370fa91317fd5d8b09bc70a67251a3232ca37e Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 10 Sep 2019 23:01:23 +0200 Subject: [PATCH 02/32] Proof of concept --- .../migrations/0135_auto_20190910_2020.py | 3 +- src/pretix/base/payment.py | 17 ++++- src/pretix/base/services/cart.py | 28 +++++++- src/pretix/base/services/orders.py | 69 +++++++++++++++---- src/pretix/presale/checkoutflow.py | 22 +++++- .../pretixpresale/event/checkout_payment.html | 2 + 6 files changed, 119 insertions(+), 22 deletions(-) diff --git a/src/pretix/base/migrations/0135_auto_20190910_2020.py b/src/pretix/base/migrations/0135_auto_20190910_2020.py index 33d55371bc..8db9543e2a 100644 --- a/src/pretix/base/migrations/0135_auto_20190910_2020.py +++ b/src/pretix/base/migrations/0135_auto_20190910_2020.py @@ -1,7 +1,8 @@ # Generated by Django 2.2.1 on 2019-09-10 20:20 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models + import pretix.base.models.fields import pretix.base.models.giftcards diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index fd7ba9dba9..a40e1a9eac 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -888,6 +888,21 @@ class OffsettingProvider(BasePaymentProvider): return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders'])) +class GiftCardPayment(BasePaymentProvider): + is_enabled = True + identifier = "giftcard" + verbose_name = _("Gift card") + is_implicit = True + + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: + return False + + def order_change_allowed(self, order: Order) -> bool: + return False + + # TODO: execute, refund, api, control render + + @receiver(register_payment_providers, dispatch_uid="payment_free") def register_payment_provider(sender, **kwargs): - return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment] + return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment] diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 767f895df0..8ccc0c0bde 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -14,8 +14,8 @@ from django_scopes import scopes_disabled from pretix.base.i18n import language from pretix.base.models import ( - CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat, - SeatCategoryMapping, Voucher, + CartPosition, Event, GiftCard, InvoiceAddress, Item, ItemBundle, + ItemVariation, Seat, SeatCategoryMapping, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import OrderFee @@ -958,14 +958,36 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress def get_fees(event, request, total, invoice_address, provider): - fees = [] + from pretix.presale.views.cart import cart_session + fees = [] for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address, total=total): if resp: fees += resp total = total + sum(f.value for f in fees) + + cs = cart_session(request) + if cs.get('gift_cards'): + gc_qs = GiftCard.objects.filter(pk__in=cs.get('gift_cards')) + summed = 0 + for gc in gc_qs: + fval = Decimal(gc.value) # TODO: don't require an extra query + fval = min(fval, total - summed) + if fval > 0: + total -= fval + summed += fval + fees.append(OrderFee( + fee_type=OrderFee.FEE_TYPE_GIFTCARD, + internal_type='giftcard', + description=gc.secret, + value=-1 * fval, + tax_rate=Decimal('0.00'), + tax_value=Decimal('0.00'), + tax_rule=TaxRule.zero() + )) + if provider and total != 0: provider = event.get_payment_providers().get(provider) if provider: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index b711f47060..5d91950dfe 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -20,8 +20,9 @@ from pretix.api.models import OAuthApplication from pretix.base.email import get_email_context from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( - CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment, - OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, + CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order, + OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, + Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.items import ItemBundle @@ -545,7 +546,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider, address: InvoiceAddress, - meta_info: dict, event: Event): + meta_info: dict, event: Event, gift_cards: List[GiftCard]): fees = [] total = sum([c.price for c in positions]) @@ -553,8 +554,18 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid meta_info=meta_info, positions=positions): if resp: fees += resp - total += sum(f.value for f in fees) + + summed = 0 + gift_card_values = {} + for gc in gift_cards: + fval = Decimal(gc.value) # TODO: don't require an extra query + fval = min(fval, total - summed) + if fval > 0: + total -= fval + summed += fval + gift_card_values[gc] = fval + if payment_provider: payment_fee = payment_provider.calculate_fee(total) else: @@ -565,17 +576,24 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid internal_type=payment_provider.identifier) fees.append(pf) - return fees, pf + return fees, pf, gift_card_values def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, - meta_info: dict=None, sales_channel: str='web'): - fees, pf = _get_fees(positions, payment_provider, address, meta_info, event) - total = sum([c.price for c in positions]) + sum([c.value for c in fees]) + meta_info: dict=None, sales_channel: str='web', gift_cards: list=None): p = None - with transaction.atomic(): + checked_gift_cards = [] + if gift_cards: + gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards) # TODO: Make sure to prevent race conditions + for gc in gc_qs: + # TODO: Re-check acceptance + checked_gift_cards.append(gc) + + fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards) + total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) + order = Order( status=Order.STATUS_PENDING, event=event, @@ -606,11 +624,30 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d fee.tax_rule = None # TODO: deprecate fee.save() + for gc, val in gift_card_values.items(): + p = order.payments.create( + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='giftcard', + amount=val, + fee=pf + ) + trans = gc.transactions.create( + value=-1 * val, + order=order, + payment=p + ) + p.info_data = { + 'gift_card': gc.pk, + 'transaction_id': trans.pk, + } + p.save() + pending_sum -= val + if payment_provider and not order.require_approval: p = order.payments.create( state=OrderPayment.PAYMENT_STATE_CREATED, provider=payment_provider.identifier, - amount=total, + amount=pending_sum, fee=pf ) @@ -658,7 +695,8 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi def _perform_order(event: Event, payment_provider: str, position_ids: List[str], - email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'): + email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', + gift_cards: list=None): if payment_provider: pprov = event.get_payment_providers().get(payment_provider) if not pprov: @@ -707,9 +745,10 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], raise OrderError(error_messages['internal']) _check_positions(event, now_dt, positions, address=addr) order, payment = _create_order(event, email, positions, now_dt, pprov, - locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel) + locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, + gift_cards=gift_cards) - free_order_flow = payment and payment_provider == 'free' and order.total == Decimal('0.00') and not order.require_approval + free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval if free_order_flow: try: payment.confirm(send_mail=False, lock=not locked) @@ -1466,12 +1505,12 @@ class OrderChangeManager: @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def perform_order(self, event: Event, payment_provider: str, positions: List[str], email: str=None, locale: str=None, address: int=None, meta_info: dict=None, - sales_channel: str='web'): + sales_channel: str='web', gift_cards: list=None): with language(locale): try: try: return _perform_order(event, payment_provider, positions, email, locale, address, meta_info, - sales_channel) + sales_channel, gift_cards) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 59ae609d89..1d94225461 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -15,7 +15,7 @@ from django.utils.translation import ( from django.views.generic.base import TemplateResponseMixin from django_scopes import scopes_disabled -from pretix.base.models import Order +from pretix.base.models import GiftCard, Order from pretix.base.models.orders import InvoiceAddress, OrderPayment from pretix.base.services.cart import ( get_fees, set_cart_addons, update_tax_rates, @@ -530,6 +530,24 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): def post(self, request): self.request = request + if request.POST.get("giftcard"): + # TODO: cross-organizer acceptance, check for valid money, … + try: + gc = GiftCard.objects.get( + issuer=request.organizer, + secret=request.POST.get("giftcard") + ) + if gc.currency != request.event.currency: + messages.error(self.request, _("This gift card does not support this currency.")) + return self.render() + if 'gift_cards' not in self.cart_session: + self.cart_session['gift_cards'] = [] + self.cart_session['gift_cards'] = self.cart_session['gift_cards'] + [gc.pk] + return self.render() + except GiftCard.DoesNotExist: + messages.error(self.request, _("This gift card is not known.")) + return self.render() + for p in self.provider_forms: if p['provider'].identifier == request.POST.get('payment', ''): self.cart_session['payment'] = p['provider'].identifier @@ -709,7 +727,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None, [p.id for p in self.positions], self.cart_session.get('email'), translation.get_language(), self.invoice_address.pk, meta_info, - request.sales_channel) + request.sales_channel, self.cart_session.get('gift_cards')) def get_success_message(self, value): create_empty_cart_id(self.request) diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index 4df8de073f..58dcb81c6a 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -11,6 +11,8 @@
{% csrf_token %}
+ {# TODO: make this proper #} + {% for p in providers %}
@@ -48,7 +55,8 @@ {% if t.order %} - {{ t.order.full_code }} + {{ t.order.full_code }} + {% else %} {% trans "Manual transaction" %} {% endif %} @@ -59,22 +67,22 @@ {% endfor %} - - - - - -
- {% csrf_token %} - - -
- + + + + + +
+ {% csrf_token %} + + +
+ - - + + {% endblock %} From 4aeada0bfb03546f233bef75df7b02cc96176d10 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 19 Sep 2019 08:50:21 +0200 Subject: [PATCH 11/32] Fix KeyError --- src/pretix/presale/checkoutflow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 4429288a19..319648bd2f 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -540,14 +540,14 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): if gc.currency != request.event.currency: messages.error(self.request, _("This gift card does not support this currency.")) return self.render() - if gc.pk in self.cart_session['gift_cards']: - messages.error(self.request, _("This gift card is already used for your payment.")) - return self.render() if gc.value <= Decimal("0.00"): messages.error(self.request, _("All credit on this gift card has been used.")) return self.render() if 'gift_cards' not in self.cart_session: self.cart_session['gift_cards'] = [] + elif gc.pk in self.cart_session['gift_cards']: + messages.error(self.request, _("This gift card is already used for your payment.")) + return self.render() self.cart_session['gift_cards'] = self.cart_session['gift_cards'] + [gc.pk] remainder = self._total_order_value - gc.value From e099fad0ca4dfdd14db315949fa1239ccc3436e2 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 19 Sep 2019 10:06:26 +0200 Subject: [PATCH 12/32] Refator payment provider, deal with cancellations --- src/pretix/base/services/orders.py | 4 - .../base/services/orders.py___jb_tmp___ | 1667 ----------------- 2 files changed, 1671 deletions(-) delete mode 100644 src/pretix/base/services/orders.py___jb_tmp___ diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index d2eb5b8175..651cba36a9 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1627,7 +1627,6 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0) order.save(update_fields=['total']) -<<<<<<< HEAD if not new_payment: new_payment = order.payments.create( @@ -1655,8 +1654,6 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay generate_invoice(order) return old_fee, new_fee, fee, new_payment -======= - return old_fee, new_fee, fee @receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards") @@ -1668,4 +1665,3 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): currency=sender.currency, issued_in=p ) gc.transactions.create(value=p.price, order=order) ->>>>>>> 1c09d226e... Actually issue giftcards diff --git a/src/pretix/base/services/orders.py___jb_tmp___ b/src/pretix/base/services/orders.py___jb_tmp___ deleted file mode 100644 index 651cba36a9..0000000000 --- a/src/pretix/base/services/orders.py___jb_tmp___ +++ /dev/null @@ -1,1667 +0,0 @@ -import json -import logging -from collections import Counter, namedtuple -from datetime import datetime, time, timedelta -from decimal import Decimal -from typing import List, Optional - -from celery.exceptions import MaxRetriesExceededError -from django.conf import settings -from django.db import transaction -from django.db.models import Exists, F, Max, OuterRef, Q, Sum -from django.db.models.functions import Greatest -from django.dispatch import receiver -from django.utils.functional import cached_property -from django.utils.timezone import make_aware, now -from django.utils.translation import ugettext as _ -from django_scopes import scopes_disabled - -from pretix.api.models import OAuthApplication -from pretix.base.email import get_email_context -from pretix.base.i18n import LazyLocaleException, language -from pretix.base.models import ( - CartPosition, Device, Event, GiftCard, Item, ItemVariation, Order, - OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, - Voucher, -) -from pretix.base.models.event import SubEvent -from pretix.base.models.items import ItemBundle -from pretix.base.models.orders import ( - InvoiceAddress, OrderFee, OrderRefund, generate_position_secret, - generate_secret, -) -from pretix.base.models.organizer import TeamAPIToken -from pretix.base.models.tax import TaxedPrice -from pretix.base.payment import BasePaymentProvider, PaymentException -from pretix.base.reldate import RelativeDateWrapper -from pretix.base.services import tickets -from pretix.base.services.invoices import ( - generate_cancellation, generate_invoice, invoice_qualified, -) -from pretix.base.services.locking import LockTimeoutException, NoLockManager -from pretix.base.services.mail import SendMailException -from pretix.base.services.pricing import get_price -from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask -from pretix.base.signals import ( - allow_ticket_download, order_approved, order_canceled, order_changed, - order_denied, order_expired, order_fee_calculation, order_paid, - order_placed, order_split, periodic_task, validate_order, -) -from pretix.celery_app import app -from pretix.helpers.models import modelcopy - -error_messages = { - 'unavailable': _('Some of the products you selected were no longer available. ' - 'Please see below for details.'), - 'in_part': _('Some of the products you selected were no longer available in ' - 'the quantity you selected. Please see below for details.'), - 'price_changed': _('The price of some of the items in your cart has changed in the ' - 'meantime. Please see below for details.'), - 'internal': _("An internal error occurred, please try again."), - 'empty': _("Your cart is empty."), - 'max_items_per_product': _("You cannot select more than %(max)s items of the product %(product)s. We removed the " - "surplus items from your cart."), - 'busy': _('We were not able to process your request completely as the ' - 'server was too busy. Please try again.'), - 'not_started': _('The presale period for this event has not yet started.'), - 'ended': _('The presale period has ended.'), - '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_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 ' - 'removed this item from your cart.'), - 'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this ' - 'item from your cart.'), - 'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The ' - 'affected positions have been removed from your cart.'), - 'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected ' - '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.'), -} - -logger = logging.getLogger(__name__) - - -def mark_order_paid(*args, **kwargs): - raise NotImplementedError("This method is no longer supported since pretix 1.17.") - - -def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None): - """ - Extends the deadline of an order. If the order is already expired, the quota will be checked to - see if this is actually still possible. If ``force`` is set to ``True``, the result of this check - will be ignored. - """ - if new_date < now(): - raise OrderError(_('The new expiry date needs to be in the future.')) - - def change(was_expired=True): - order.expires = new_date - if was_expired: - order.status = Order.STATUS_PENDING - order.save(update_fields=['expires'] + (['status'] if was_expired else [])) - order.log_action( - 'pretix.event.order.expirychanged', - user=user, - auth=auth, - data={ - 'expires': order.expires, - 'state_change': was_expired - } - ) - if was_expired: - num_invoices = order.invoices.filter(is_cancellation=False).count() - if num_invoices > 0 and order.invoices.filter(is_cancellation=True).count() >= num_invoices: - generate_invoice(order) - - if order.status == Order.STATUS_PENDING: - change(was_expired=False) - else: - with order.event.lock() as now_dt: - is_available = order._is_still_available(now_dt, count_waitinglist=False) - if is_available is True or force is True: - change(was_expired=True) - else: - raise OrderError(is_available) - - -@transaction.atomic -def mark_order_refunded(order, user=None, auth=None, api_token=None): - oautha = auth.pk if isinstance(auth, OAuthApplication) else None - device = auth.pk if isinstance(auth, Device) else None - api_token = (api_token.pk if api_token else None) or (auth if isinstance(auth, TeamAPIToken) else None) - return _cancel_order( - order.pk, user.pk if user else None, send_mail=False, api_token=api_token, device=device, oauth_application=oautha - ) - - -def mark_order_expired(order, user=None, auth=None): - """ - Mark this order as expired. This sets the payment status and returns the order object. - :param order: The order to change - :param user: The user that performed the change - """ - with transaction.atomic(): - if isinstance(order, int): - order = Order.objects.get(pk=order) - if isinstance(user, int): - user = User.objects.get(pk=user) - with order.event.lock(): - order.status = Order.STATUS_EXPIRED - order.save(update_fields=['status']) - - order.log_action('pretix.event.order.expired', user=user, auth=auth) - i = order.invoices.filter(is_cancellation=False).last() - if i: - generate_cancellation(i) - - order_expired.send(order.event, order=order) - return order - - -def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False): - """ - Mark this order as approved - :param order: The order to change - :param user: The user that performed the change - """ - with transaction.atomic(): - if not order.require_approval or not order.status == Order.STATUS_PENDING: - raise OrderError(_('This order is not pending approval.')) - - order.require_approval = False - order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()])) - order.save(update_fields=['require_approval', 'expires']) - - order.log_action('pretix.event.order.approved', user=user, auth=auth) - if order.total == Decimal('0.00'): - p = order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider='free', - amount=0, - fee=None - ) - try: - p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth, ignore_date=True, force=force) - except Quota.QuotaExceededException: - raise OrderError(error_messages['unavailable']) - - order_approved.send(order.event, order=order) - - invoice = order.invoices.last() # Might be generated by plugin already - if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): - if not invoice: - invoice = generate_invoice( - order, - trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email - ) - # send_mail will trigger PDF generation later - - if send_mail: - with language(order.locale): - if order.total == Decimal('0.00'): - email_template = order.event.settings.mail_text_order_free - email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code} - else: - email_template = order.event.settings.mail_text_order_approved - email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code} - - email_context = get_email_context(event=order.event, order=order) - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_approved', user, - invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else [] - ) - except SendMailException: - logger.exception('Order approved email could not be sent') - - return order.pk - - -def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): - """ - Mark this order as canceled - :param order: The order to change - :param user: The user that performed the change - """ - with transaction.atomic(): - if not order.require_approval or not order.status == Order.STATUS_PENDING: - raise OrderError(_('This order is not pending approval.')) - - with order.event.lock(): - order.status = Order.STATUS_CANCELED - order.save(update_fields=['status']) - - order.log_action('pretix.event.order.denied', user=user, auth=auth, data={ - 'comment': comment - }) - i = order.invoices.filter(is_cancellation=False).last() - if i: - generate_cancellation(i) - - for position in order.positions.all(): - if position.voucher: - Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - - order_denied.send(order.event, order=order) - - if send_mail: - email_template = order.event.settings.mail_text_order_denied - email_context = get_email_context(event=order.event, order=order, comment=comment) - with language(order.locale): - email_subject = _('Order denied: %(code)s') % {'code': order.code} - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_denied', user - ) - except SendMailException: - logger.exception('Order denied email could not be sent') - - return order.pk - - -def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None, - cancellation_fee=None): - """ - Mark this order as canceled - :param order: The order to change - :param user: The user that performed the change - """ - with transaction.atomic(): - if isinstance(order, int): - order = Order.objects.get(pk=order) - if isinstance(user, int): - user = User.objects.get(pk=user) - if isinstance(api_token, int): - api_token = TeamAPIToken.objects.get(pk=api_token) - if isinstance(device, int): - device = Device.objects.get(pk=device) - if isinstance(oauth_application, int): - oauth_application = OAuthApplication.objects.get(pk=oauth_application) - if isinstance(cancellation_fee, str): - cancellation_fee = Decimal(cancellation_fee) - - if not order.cancel_allowed(): - raise OrderError(_('You cannot cancel this order.')) - i = order.invoices.filter(is_cancellation=False).last() - if i: - generate_cancellation(i) - - if cancellation_fee: - with order.event.lock(): - for position in order.positions.all(): - if position.voucher: - Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - position.canceled = True - position.save(update_fields=['canceled']) - for fee in order.fees.all(): - fee.canceled = True - fee.save(update_fields=['canceled']) - - f = OrderFee( - fee_type=OrderFee.FEE_TYPE_CANCELLATION, - value=cancellation_fee, - tax_rule=order.event.settings.tax_rate_default, - order=order, - ) - f._calculate_tax() - f.save() - - if order.payment_refund_sum < cancellation_fee: - raise OrderError(_('The cancellation fee cannot be higher than the payment credit of this order.')) - order.status = Order.STATUS_PAID - order.total = f.value - order.save(update_fields=['status', 'total']) - - if i: - generate_invoice(order) - else: - with order.event.lock(): - order.status = Order.STATUS_CANCELED - order.save(update_fields=['status']) - - for position in order.positions.all(): - if position.voucher: - Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - - order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, - data={'cancellation_fee': cancellation_fee}) - - if send_mail: - email_template = order.event.settings.mail_text_order_canceled - with language(order.locale): - email_context = get_email_context(event=order.event, order=order) - email_subject = _('Order canceled: %(code)s') % {'code': order.code} - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_canceled', user - ) - except SendMailException: - logger.exception('Order canceled email could not be sent') - - order_canceled.send(order.event, order=order) - return order.pk - - -class OrderError(LazyLocaleException): - def __init__(self, *args): - msg = args[0] - msgargs = args[1] if len(args) > 1 else None - self.args = args - if msgargs: - msg = _(msg) % msgargs - else: - msg = _(msg) - super().__init__(msg) - - -def _check_date(event: Event, now_dt: datetime): - if event.presale_start and now_dt < event.presale_start: - raise OrderError(error_messages['not_started']) - if event.presale_has_ended: - raise OrderError(error_messages['ended']) - - if not event.has_subevents: - tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if tlv: - term_last = make_aware(datetime.combine( - tlv.datetime(event).date(), - time(hour=23, minute=59, second=59) - ), event.timezone) - if term_last < now_dt: - raise OrderError(error_messages['ended']) - - -def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None): - err = None - errargs = None - _check_date(event, now_dt) - - products_seen = Counter() - changed_prices = {} - deleted_positions = set() - seats_seen = set() - - def delete(cp): - # Delete a cart position, including parents and children, if applicable - if cp.is_bundled: - delete(cp.addon_to) - else: - for p in cp.addons.all(): - deleted_positions.add(p.pk) - p.delete() - deleted_positions.add(cp.pk) - cp.delete() - - for i, cp in enumerate(sorted(positions, key=lambda s: -int(s.is_bundled))): - if cp.pk in deleted_positions: - continue - - if not cp.item.is_available() or (cp.variation and not cp.variation.active): - err = err or error_messages['unavailable'] - delete(cp) - continue - quotas = list(cp.quotas) - - products_seen[cp.item] += 1 - if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order: - err = error_messages['max_items_per_product'] - errargs = {'max': cp.item.max_per_order, - 'product': cp.item.name} - delete(cp) - break - - if cp.voucher: - redeemed_in_carts = CartPosition.objects.filter( - Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt) - ).exclude(pk=cp.pk) - v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count() - if v_avail < 1: - err = err or error_messages['voucher_redeemed'] - delete(cp) - continue - - 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) - break - - if cp.subevent: - tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if tlv: - term_last = make_aware(datetime.combine( - tlv.datetime(cp.subevent).date(), - time(hour=23, minute=59, second=59) - ), event.timezone) - if term_last < now_dt: - err = err or error_messages['some_subevent_ended'] - delete(cp) - break - - if cp.subevent and cp.subevent.presale_has_ended: - err = err or error_messages['some_subevent_ended'] - delete(cp) - break - - if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen: - err = err or error_messages['seat_invalid'] - delete(cp) - break - if cp.seat: - seats_seen.add(cp.seat) - - if cp.item.require_voucher and cp.voucher is None and not cp.is_bundled: - delete(cp) - err = err or error_messages['voucher_required'] - break - - if cp.item.hide_without_voucher and ( - cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation) - ) and not cp.is_bundled: - delete(cp) - cp.delete() - err = error_messages['voucher_required'] - break - - if cp.seat: - # Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely - # can not overbook a seat. - if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked: - err = err or error_messages['seat_unavailable'] - cp.delete() - continue - - if cp.expires >= now_dt and not cp.voucher: - # Other checks are not necessary - continue - - 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 - price = get_price(cp.item, cp.variation, cp.voucher, bprice, cp.subevent, custom_price_is_net=False, - invoice_address=address, force_custom_price=True) - 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) - - if price is False or len(quotas) == 0: - err = err or error_messages['unavailable'] - delete(cp) - continue - - if cp.voucher: - if cp.voucher.valid_until and cp.voucher.valid_until < now_dt: - err = err or error_messages['voucher_expired'] - delete(cp) - continue - - 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) - cp.save() - err = err or error_messages['price_changed'] - continue - - quota_ok = True - - ignore_all_quotas = cp.expires >= now_dt or ( - cp.voucher and (cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None))) - - if not ignore_all_quotas: - for quota in quotas: - if cp.voucher and cp.voucher.block_quota and cp.voucher.quota_id == quota.pk: - continue - avail = quota.availability(now_dt) - if avail[0] != Quota.AVAILABILITY_OK: - # This quota is sold out/currently unavailable, so do not sell this at all - err = err or error_messages['unavailable'] - quota_ok = False - break - - if quota_ok: - cp.expires = now_dt + timedelta( - minutes=event.settings.get('reservation_time', as_type=int)) - cp.save() - else: - # Sorry, can't let you keep that! - delete(cp) - if err: - raise OrderError(err, errargs) - - -def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider, address: InvoiceAddress, - meta_info: dict, event: Event, gift_cards: List[GiftCard]): - fees = [] - total = sum([c.price for c in positions]) - - for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, - meta_info=meta_info, positions=positions): - if resp: - fees += resp - total += sum(f.value for f in fees) - - summed = 0 - gift_card_values = {} - for gc in gift_cards: - fval = Decimal(gc.value) # TODO: don't require an extra query - fval = min(fval, total - summed) - if fval > 0: - total -= fval - summed += fval - gift_card_values[gc] = fval - - if payment_provider: - payment_fee = payment_provider.calculate_fee(total) - else: - payment_fee = 0 - pf = None - if payment_fee: - pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, - internal_type=payment_provider.identifier) - fees.append(pf) - - return fees, pf, gift_card_values - - -def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, - payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, - meta_info: dict=None, sales_channel: str='web', gift_cards: list=None): - p = None - with transaction.atomic(): - checked_gift_cards = [] - if gift_cards: - gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards) # TODO: Make sure to prevent race conditions - for gc in gc_qs: - # TODO: Re-check acceptance - checked_gift_cards.append(gc) - - fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards) - total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) - - order = Order( - status=Order.STATUS_PENDING, - event=event, - email=email, - datetime=now_dt, - locale=locale, - total=total, - testmode=event.testmode, - meta_info=json.dumps(meta_info or {}), - require_approval=any(p.item.require_approval for p in positions), - sales_channel=sales_channel - ) - order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions])) - order.save() - - if address: - if address.order is not None: - address.pk = None - address.order = order - address.save() - - order.save() - - for fee in fees: - fee.order = order - fee._calculate_tax() - if fee.tax_rule and not fee.tax_rule.pk: - fee.tax_rule = None # TODO: deprecate - fee.save() - - for gc, val in gift_card_values.items(): - p = order.payments.create( - state=OrderPayment.PAYMENT_STATE_CONFIRMED, - provider='giftcard', - amount=val, - fee=pf - ) - trans = gc.transactions.create( - value=-1 * val, - order=order, - payment=p - ) - p.info_data = { - 'gift_card': gc.pk, - 'transaction_id': trans.pk, - } - p.save() - pending_sum -= val - - if payment_provider and not order.require_approval: - p = order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider=payment_provider.identifier, - amount=pending_sum, - fee=pf - ) - - OrderPosition.transform_cart_positions(positions, order) - order.log_action('pretix.event.order.placed') - if order.require_approval: - order.log_action('pretix.event.order.placed.require_approval') - if meta_info: - for msg in meta_info.get('confirm_messages', []): - order.log_action('pretix.event.order.consent', data={'msg': msg}) - - order_placed.send(event, order=order) - return order, p - - -def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str, - invoice, payment: OrderPayment): - email_context = get_email_context(event=event, order=order, payment=payment if pprov else None) - email_subject = _('Your order: %(code)s') % {'code': order.code} - try: - order.send_mail( - email_subject, email_template, email_context, - log_entry, - invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [], - attach_tickets=True - ) - except SendMailException: - logger.exception('Order received email could not be sent') - - -def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str): - email_context = get_email_context(event=event, order=order, position=position) - email_subject = _('Your event registration: %(code)s') % {'code': order.code} - - try: - order.send_mail( - email_subject, email_template, email_context, - log_entry, - invoices=[], - attach_tickets=True, - position=position - ) - except SendMailException: - logger.exception('Order received email could not be sent to attendee') - - -def _perform_order(event: Event, payment_provider: str, position_ids: List[str], - email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', - gift_cards: list=None): - if payment_provider: - pprov = event.get_payment_providers().get(payment_provider) - if not pprov: - raise OrderError(error_messages['internal']) - else: - pprov = None - - if email == settings.PRETIX_EMAIL_NONE_VALUE: - email = None - - addr = None - if address is not None: - try: - with scopes_disabled(): - addr = InvoiceAddress.objects.get(pk=address) - except InvoiceAddress.DoesNotExist: - pass - - positions = CartPosition.objects.annotate( - requires_seat=Exists( - SeatCategoryMapping.objects.filter( - Q(product=OuterRef('item')) - & (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True)) - ) - ) - ).filter( - id__in=position_ids, event=event - ) - - validate_order.send(event, payment_provider=pprov, email=email, positions=positions, - locale=locale, invoice_address=addr, meta_info=meta_info) - - lockfn = NoLockManager - locked = False - if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists(): - # Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date, - # creating this order shouldn't be prone to any race conditions and we don't need to lock the event. - locked = True - lockfn = event.lock - - with lockfn() as now_dt: - positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')) - if len(positions) == 0: - raise OrderError(error_messages['empty']) - if len(position_ids) != len(positions): - raise OrderError(error_messages['internal']) - _check_positions(event, now_dt, positions, address=addr) - order, payment = _create_order(event, email, positions, now_dt, pprov, - locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, - gift_cards=gift_cards) - - free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval - if free_order_flow: - try: - payment.confirm(send_mail=False, lock=not locked) - except Quota.QuotaExceededException: - pass - - invoice = order.invoices.last() # Might be generated by plugin already - if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): - if not invoice: - invoice = generate_invoice( - order, - trigger_pdf=not event.settings.invoice_email_attachment or not order.email - ) - # send_mail will trigger PDF generation later - - if order.email: - if order.require_approval: - email_template = event.settings.mail_text_order_placed_require_approval - log_entry = 'pretix.event.order.email.order_placed_require_approval' - - email_attendees = False - elif free_order_flow: - email_template = event.settings.mail_text_order_free - log_entry = 'pretix.event.order.email.order_free' - - email_attendees = event.settings.mail_send_order_free_attendee - email_attendees_template = event.settings.mail_text_order_free_attendee - else: - email_template = event.settings.mail_text_order_placed - log_entry = 'pretix.event.order.email.order_placed' - - email_attendees = event.settings.mail_send_order_placed_attendee - email_attendees_template = event.settings.mail_text_order_placed_attendee - - _order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment) - if email_attendees: - for p in order.positions.all(): - if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: - _order_placed_email_attendee(event, order, p, email_attendees_template, log_entry) - - return order.id - - -@receiver(signal=periodic_task) -@scopes_disabled() -def expire_orders(sender, **kwargs): - eventcache = {} - - for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING, - require_approval=False).select_related('event'): - expire = eventcache.get(o.event.pk, None) - if expire is None: - expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool) - eventcache[o.event.pk] = expire - if expire: - mark_order_expired(o) - - -@receiver(signal=periodic_task) -@scopes_disabled() -def send_expiry_warnings(sender, **kwargs): - eventcache = {} - today = now().replace(hour=0, minute=0, second=0) - - for o in Order.objects.filter( - expires__gte=today, expiry_reminder_sent=False, status=Order.STATUS_PENDING, - datetime__lte=now() - timedelta(hours=2), require_approval=False - ).only('pk'): - with transaction.atomic(): - o = Order.objects.select_related('event').select_for_update().get(pk=o.pk) - if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent: - # Race condition - continue - eventsettings = eventcache.get(o.event.pk, None) - if eventsettings is None: - eventsettings = o.event.settings - eventcache[o.event.pk] = eventsettings - - days = eventsettings.get('mail_days_order_expire_warning', as_type=int) - if days and (o.expires - today).days <= days: - with language(o.locale): - o.expiry_reminder_sent = True - o.save(update_fields=['expiry_reminder_sent']) - email_template = eventsettings.mail_text_order_expire_warning - email_context = get_email_context(event=o.event, order=o) - if eventsettings.payment_term_expire_automatically: - email_subject = _('Your order is about to expire: %(code)s') % {'code': o.code} - else: - email_subject = _('Your order is pending payment: %(code)s') % {'code': o.code} - - try: - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.expire_warning_sent' - ) - except SendMailException: - logger.exception('Reminder email could not be sent') - - -@receiver(signal=periodic_task) -@scopes_disabled() -def send_download_reminders(sender, **kwargs): - today = now().replace(hour=0, minute=0, second=0, microsecond=0) - - for e in Event.objects.filter(date_from__gte=today): - - days = e.settings.get('mail_days_download_reminder', as_type=int) - if days is None: - continue - - reminder_date = (e.date_from - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0) - - if now() < reminder_date: - continue - for o in e.orders.filter(status=Order.STATUS_PAID, download_reminder_sent=False, datetime__lte=now() - timedelta(hours=2)).only('pk'): - with transaction.atomic(): - o = Order.objects.select_related('event').select_for_update().get(pk=o.pk) - if o.download_reminder_sent: - # Race condition - continue - if not all([r for rr, r in allow_ticket_download.send(e, order=o)]): - continue - - with language(o.locale): - o.download_reminder_sent = True - o.save(update_fields=['download_reminder_sent']) - email_template = e.settings.mail_text_download_reminder - email_context = get_email_context(event=e, order=o) - email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code} - try: - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.download_reminder_sent', - attach_tickets=True - ) - except SendMailException: - logger.exception('Reminder email could not be sent') - - if e.settings.mail_send_download_reminder_attendee: - for p in o.positions.all(): - if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email: - email_template = e.settings.mail_text_download_reminder_attendee - email_context = get_email_context(event=e, order=o, position=p) - try: - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.download_reminder_sent', - attach_tickets=True, position=p - ) - except SendMailException: - logger.exception('Reminder email could not be sent to attendee') - - -def notify_user_changed_order(order, user=None, auth=None): - with language(order.locale): - email_template = order.event.settings.mail_text_order_changed - email_context = get_email_context(event=order.event, order=order) - email_subject = _('Your order has been changed: %(code)s') % {'code': order.code} - try: - order.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.order_changed', user, auth=auth - ) - except SendMailException: - logger.exception('Order changed email could not be sent') - - -class OrderChangeManager: - error_messages = { - 'product_without_variation': _('You need to select a variation of the product.'), - 'quota': _('The quota {name} does not have enough capacity left to perform the operation.'), - 'quota_missing': _('There is no quota defined that allows this operation.'), - 'product_invalid': _('The selected product is not active or has no price set.'), - 'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'), - 'not_pending_or_paid': _('Only pending or paid orders can be changed.'), - 'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however ' - 'no quota is available.'), - 'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'), - 'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'), - 'subevent_required': _('You need to choose a subevent for the new position.'), - 'seat_unavailable': _('The selected seat "{seat}" is not available.'), - '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.'), - } - ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation')) - SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) - SeatOperation = namedtuple('SubeventOperation', ('position', 'seat')) - PriceOperation = namedtuple('PriceOperation', ('position', 'price')) - CancelOperation = namedtuple('CancelOperation', ('position',)) - AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat')) - SplitOperation = namedtuple('SplitOperation', ('position',)) - RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',)) - - def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True): - self.order = order - self.user = user - self.auth = auth - self.event = order.event - self.split_order = None - self.reissue_invoice = reissue_invoice - self._committed = False - self._totaldiff = 0 - self._quotadiff = Counter() - self._seatdiff = Counter() - self._operations = [] - self.notify = notify - self._invoice_dirty = False - - def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]): - if (not variation and item.has_variations) or (variation and variation.item_id != item.pk): - raise OrderError(self.error_messages['product_without_variation']) - - new_quotas = (variation.quotas.filter(subevent=position.subevent) - if variation else item.quotas.filter(subevent=position.subevent)) - if not new_quotas: - raise OrderError(self.error_messages['quota_missing']) - - self._quotadiff.update(new_quotas) - self._quotadiff.subtract(position.quotas) - self._operations.append(self.ItemOperation(position, item, variation)) - - def change_seat(self, position: OrderPosition, seat: Seat): - if position.seat: - self._seatdiff.subtract([position.seat]) - if seat: - self._seatdiff.update([seat]) - 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) - - if price is None: # NOQA - raise OrderError(self.error_messages['product_invalid']) - - new_quotas = (position.variation.quotas.filter(subevent=subevent) - if position.variation else position.item.quotas.filter(subevent=subevent)) - if not new_quotas: - raise OrderError(self.error_messages['quota_missing']) - - self._quotadiff.update(new_quotas) - self._quotadiff.subtract(position.quotas) - self._operations.append(self.SubeventOperation(position, subevent)) - - def regenerate_secret(self, position: OrderPosition): - self._operations.append(self.RegenerateSecretOperation(position)) - - def change_price(self, position: OrderPosition, price: Decimal): - price = position.item.tax(price, base_price_is='gross') - - self._totaldiff += price.gross - position.price - - if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'): - self._invoice_dirty = True - - self._operations.append(self.PriceOperation(position, price)) - - def recalculate_taxes(self): - positions = self.order.positions.select_related('item', 'item__tax_rule') - ia = self._invoice_address - for pos in positions: - if not pos.item.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)) - - def cancel(self, position: OrderPosition): - self._totaldiff += -position.price - self._quotadiff.subtract(position.quotas) - self._operations.append(self.CancelOperation(position)) - if position.seat: - self._seatdiff.subtract([position.seat]) - - if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): - self._invoice_dirty = True - - def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, - subevent: SubEvent = None, seat: Seat = None): - 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='') - - if price is None: - raise OrderError(self.error_messages['product_invalid']) - if not addon_to and item.category and item.category.is_addon: - raise OrderError(self.error_messages['addon_to_required']) - if addon_to: - if not item.category or item.category_id not in addon_to.item.addons.values_list('addon_category', flat=True): - raise OrderError(self.error_messages['addon_invalid']) - if self.order.event.has_subevents and not subevent: - raise OrderError(self.error_messages['subevent_required']) - - seated = item.seat_category_mappings.filter(subevent=subevent).exists() - if seated and not seat: - raise OrderError(self.error_messages['seat_required']) - elif not seated and seat: - raise OrderError(self.error_messages['seat_forbidden']) - if seat and subevent and seat.subevent_id != subevent: - raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name)) - - new_quotas = (variation.quotas.filter(subevent=subevent) - if variation else item.quotas.filter(subevent=subevent)) - if not new_quotas: - raise OrderError(self.error_messages['quota_missing']) - - if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): - self._invoice_dirty = True - - self._totaldiff += price.gross - self._quotadiff.update(new_quotas) - if seat: - self._seatdiff.update([seat]) - self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat)) - - def split(self, position: OrderPosition): - if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): - self._invoice_dirty = True - - self._operations.append(self.SplitOperation(position)) - - def _check_seats(self): - for seat, diff in self._seatdiff.items(): - if diff <= 0: - continue - if not seat.is_available() or diff > 1: - raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name)) - - if self.event.has_subevents: - state = {} - for p in self.order.positions.all(): - state[p] = {'seat': p.seat, 'subevent': p.subevent} - for op in self._operations: - if isinstance(op, self.SeatOperation): - state[op.position]['seat'] = op.seat - elif isinstance(op, self.SubeventOperation): - state[op.position]['subevent'] = op.subevent - for v in state.values(): - if v['seat'] and v['seat'].subevent_id != v['subevent'].pk: - raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name)) - - def _check_quotas(self): - for quota, diff in self._quotadiff.items(): - if diff <= 0: - continue - avail = quota.availability() - if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff): - raise OrderError(self.error_messages['quota'].format(name=quota.name)) - - def _check_paid_price_change(self): - if self.order.status == Order.STATUS_PAID and self._totaldiff > 0: - if self.order.pending_sum > Decimal('0.00'): - self.order.status = Order.STATUS_PENDING - self.order.set_expires( - now(), - self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) - ) - self.order.save() - elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0: - if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval: - self.order.status = Order.STATUS_PAID - self.order.save() - elif self.open_payment: - self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED - self.open_payment.save() - self.order.log_action('pretix.event.order.payment.canceled', { - 'local_id': self.open_payment.local_id, - 'provider': self.open_payment.provider, - }, user=self.user, auth=self.auth) - elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0: - if self.open_payment: - self.open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED - self.open_payment.save() - self.order.log_action('pretix.event.order.payment.canceled', { - 'local_id': self.open_payment.local_id, - 'provider': self.open_payment.provider, - }, user=self.user, auth=self.auth) - - def _check_paid_to_free(self): - if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval: - # if the order becomes free, mark it paid using the 'free' provider - # this could happen if positions have been made cheaper or removed (_totaldiff < 0) - # or positions got split off to a new order (split_order with positive total) - p = self.order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider='free', - amount=0, - fee=None - ) - try: - p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth) - except Quota.QuotaExceededException: - raise OrderError(self.error_messages['paid_to_free_exceeded']) - - if self.split_order and self.split_order.total == 0 and not self.split_order.require_approval: - p = self.split_order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider='free', - amount=0, - fee=None - ) - try: - p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth) - except Quota.QuotaExceededException: - raise OrderError(self.error_messages['paid_to_free_exceeded']) - - def _perform_operations(self): - nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1 - split_positions = [] - - for op in self._operations: - if isinstance(op, self.ItemOperation): - self.order.log_action('pretix.event.order.changed.item', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_item': op.position.item.pk, - 'old_variation': op.position.variation.pk if op.position.variation else None, - 'new_item': op.item.pk, - 'new_variation': op.variation.pk if op.variation else None, - 'old_price': op.position.price, - 'addon_to': op.position.addon_to_id, - 'new_price': op.position.price - }) - op.position.item = op.item - op.position.variation = op.variation - op.position._calculate_tax() - op.position.save() - elif isinstance(op, self.SeatOperation): - self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_seat': op.position.seat.name if op.position.seat else "-", - 'new_seat': op.seat.name if op.seat else "-", - 'old_seat_id': op.position.seat.pk if op.position.seat else None, - 'new_seat_id': op.seat.pk if op.seat else None, - }) - op.position.seat = op.seat - op.position.save() - elif isinstance(op, self.SubeventOperation): - self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_subevent': op.position.subevent.pk, - 'new_subevent': op.subevent.pk, - 'old_price': op.position.price, - 'new_price': op.position.price - }) - op.position.subevent = op.subevent - op.position.save() - elif isinstance(op, self.PriceOperation): - self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_price': op.position.price, - 'addon_to': op.position.addon_to_id, - 'new_price': op.price.gross - }) - op.position.price = op.price.gross - op.position._calculate_tax() - op.position.save() - elif isinstance(op, self.CancelOperation): - for opa in op.position.addons.all(): - self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ - 'position': opa.pk, - 'positionid': opa.positionid, - 'old_item': opa.item.pk, - 'old_variation': opa.variation.pk if opa.variation else None, - 'addon_to': opa.addon_to_id, - 'old_price': opa.price, - }) - opa.canceled = True - if opa.voucher: - Voucher.objects.filter(pk=opa.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - opa.save(update_fields=['canceled']) - self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - 'old_item': op.position.item.pk, - 'old_variation': op.position.variation.pk if op.position.variation else None, - 'old_price': op.position.price, - 'addon_to': None, - }) - op.position.canceled = True - if op.position.voucher: - Voucher.objects.filter(pk=op.position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - op.position.save(update_fields=['canceled']) - elif isinstance(op, self.AddOperation): - pos = OrderPosition.objects.create( - item=op.item, variation=op.variation, addon_to=op.addon_to, - price=op.price.gross, order=self.order, tax_rate=op.price.rate, - tax_value=op.price.tax, tax_rule=op.item.tax_rule, - positionid=nextposid, subevent=op.subevent, seat=op.seat - ) - nextposid += 1 - self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ - 'position': pos.pk, - 'item': op.item.pk, - 'variation': op.variation.pk if op.variation else None, - 'addon_to': op.addon_to.pk if op.addon_to else None, - 'price': op.price.gross, - 'positionid': pos.positionid, - 'subevent': op.subevent.pk if op.subevent else None, - 'seat': op.seat.pk if op.seat else None, - }) - elif isinstance(op, self.SplitOperation): - split_positions.append(op.position) - elif isinstance(op, self.RegenerateSecretOperation): - op.position.secret = generate_position_secret() - op.position.save() - tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, - 'order': self.order.pk}) - self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={ - 'position': op.position.pk, - 'positionid': op.position.positionid, - }) - - if split_positions: - self.split_order = self._create_split_order(split_positions) - - def _create_split_order(self, split_positions): - split_order = Order.objects.get(pk=self.order.pk) - split_order.pk = None - split_order.code = None - split_order.datetime = now() - split_order.secret = generate_secret() - split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions) - split_order.save() - split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={ - 'original_order': self.order.code - }) - - for op in split_positions: - self.order.log_action('pretix.event.order.changed.split', user=self.user, auth=self.auth, data={ - 'position': op.pk, - 'positionid': op.positionid, - 'old_item': op.item.pk, - 'old_variation': op.variation.pk if op.variation else None, - 'old_price': op.price, - 'new_order': split_order.code, - }) - op.order = split_order - op.secret = generate_position_secret() - op.save() - - try: - ia = modelcopy(self.order.invoice_address) - ia.pk = None - ia.order = split_order - ia.save() - except InvoiceAddress.DoesNotExist: - pass - - split_order.total = sum([p.price for p in split_positions if not p.canceled]) - - for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT): - new_fee = modelcopy(fee) - new_fee.pk = None - new_fee.order = split_order - split_order.total += new_fee.value - new_fee.save() - - if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: - pp = self._get_payment_provider() - if pp: - payment_fee = pp.calculate_fee(split_order.total) - else: - payment_fee = Decimal('0.00') - fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0] - fee.value = payment_fee - fee._calculate_tax() - if payment_fee != 0: - fee.save() - elif fee.pk: - fee.delete() - split_order.total += fee.value - - split_order.save() - - if split_order.status == Order.STATUS_PAID: - split_order.payments.create( - state=OrderPayment.PAYMENT_STATE_CONFIRMED, - amount=split_order.total, - payment_date=now(), - provider='offsetting', - info=json.dumps({'orders': [self.order.code]}) - ) - self.order.refunds.create( - state=OrderRefund.REFUND_STATE_DONE, - amount=split_order.total, - execution_date=now(), - provider='offsetting', - info=json.dumps({'orders': [split_order.code]}) - ) - - if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last(): - generate_invoice(split_order) - - order_split.send(sender=self.order.event, original=self.order, split_order=split_order) - return split_order - - @cached_property - def open_payment(self): - lp = self.order.payments.last() - if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, - OrderPayment.PAYMENT_STATE_REFUNDED): - return lp - - @cached_property - def completed_payment_sum(self): - payment_sum = self.order.payments.filter( - state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) - ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') - refund_sum = self.order.refunds.filter( - state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE) - ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') - return payment_sum - refund_sum - - def _recalculate_total_and_payment_fee(self): - total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) - payment_fee = Decimal('0.00') - if self.open_payment: - current_fee = Decimal('0.00') - fee = None - if self.open_payment.fee: - fee = self.open_payment.fee - current_fee = self.open_payment.fee.value - total -= current_fee - - if self.order.pending_sum - current_fee != 0: - prov = self.open_payment.payment_provider - if prov: - payment_fee = prov.calculate_fee(total - self.completed_payment_sum) - - if payment_fee: - fee = fee or OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, order=self.order) - fee.value = payment_fee - fee._calculate_tax() - fee.save() - if not self.open_payment.fee: - self.open_payment.fee = fee - self.open_payment.save(update_fields=['fee']) - elif fee: - fee.delete() - - self.order.total = total + payment_fee - self.order.save() - - def _payment_fee_diff(self): - total = self.order.total + self._totaldiff - if self.open_payment: - current_fee = Decimal('0.00') - if self.open_payment and self.open_payment.fee: - current_fee = self.open_payment.fee.value - total -= current_fee - - # Do not change payment fees of paid orders - payment_fee = Decimal('0.00') - if self.order.pending_sum - current_fee != 0: - prov = self.open_payment.payment_provider - if prov: - payment_fee = prov.calculate_fee(total - self.completed_payment_sum) - - self._totaldiff += payment_fee - current_fee - - def _reissue_invoice(self): - i = self.order.invoices.filter(is_cancellation=False).last() - if self.reissue_invoice and i and self._invoice_dirty: - generate_cancellation(i) - generate_invoice(self.order) - - def _check_complete_cancel(self): - cancels = len([o for o in self._operations if isinstance(o, (self.CancelOperation, self.SplitOperation))]) - adds = len([o for o in self._operations if isinstance(o, self.AddOperation)]) - if self.order.positions.count() - cancels + adds < 1: - raise OrderError(self.error_messages['complete_cancel']) - - @property - def _invoice_address(self): - try: - return self.order.invoice_address - except InvoiceAddress.DoesNotExist: - return None - - def commit(self, check_quotas=True): - if self._committed: - # an order change can only be committed once - raise OrderError(error_messages['internal']) - self._committed = True - - if not self._operations: - # Do nothing - return - - # finally, incorporate difference in payment fees - self._payment_fee_diff() - - with transaction.atomic(): - with self.order.event.lock(): - if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID): - raise OrderError(self.error_messages['not_pending_or_paid']) - if check_quotas: - self._check_quotas() - self._check_seats() - self._check_complete_cancel() - self._perform_operations() - self._recalculate_total_and_payment_fee() - self._reissue_invoice() - self._clear_tickets_cache() - self.order.touch() - self._check_paid_price_change() - self._check_paid_to_free() - - if self.notify: - notify_user_changed_order(self.order, self.user, self.auth) - if self.split_order: - notify_user_changed_order(self.split_order, self.user, self.auth) - - order_changed.send(self.order.event, order=self.order) - - def _clear_tickets_cache(self): - tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, - 'order': self.order.pk}) - if self.split_order: - tickets.invalidate_cache.apply_async(kwargs={'event': self.event.pk, - 'order': self.split_order.pk}) - - def _get_payment_provider(self): - lp = self.order.payments.last() - if not lp: - return None - pprov = lp.payment_provider - if not pprov: - return None - return pprov - - -@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) -def perform_order(self, event: Event, payment_provider: str, positions: List[str], - email: str=None, locale: str=None, address: int=None, meta_info: dict=None, - sales_channel: str='web', gift_cards: list=None): - with language(locale): - try: - try: - return _perform_order(event, payment_provider, positions, email, locale, address, meta_info, - sales_channel, gift_cards) - except LockTimeoutException: - self.retry() - except (MaxRetriesExceededError, LockTimeoutException): - raise OrderError(str(error_messages['busy'])) - - -@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) -@scopes_disabled() -def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, - device=None, cancellation_fee=None, try_auto_refund=False): - try: - try: - ret = _cancel_order(order, user, send_mail, api_token, device, oauth_application, - cancellation_fee) - if try_auto_refund: - notify_admin = False - error = False - order = Order.objects.get(pk=order) - refund_amount = order.pending_sum * -1 - proposals = order.propose_auto_refunds(refund_amount) - can_auto_refund = sum(proposals.values()) == refund_amount - if can_auto_refund: - for p, value in proposals.items(): - with transaction.atomic(): - r = order.refunds.create( - payment=p, - source=OrderRefund.REFUND_SOURCE_BUYER, - state=OrderRefund.REFUND_STATE_CREATED, - amount=value, - provider=p.provider - ) - order.log_action('pretix.event.order.refund.created', { - 'local_id': r.local_id, - 'provider': r.provider, - }) - - try: - r.payment_provider.execute_refund(r) - except PaymentException as e: - with transaction.atomic(): - r.state = OrderRefund.REFUND_STATE_FAILED - r.save() - order.log_action('pretix.event.order.refund.failed', { - 'local_id': r.local_id, - 'provider': r.provider, - 'error': str(e) - }) - error = True - notify_admin = True - else: - if r.state != OrderRefund.REFUND_STATE_DONE: - notify_admin = True - elif refund_amount != Decimal('0.00'): - notify_admin = True - - if notify_admin: - order.log_action('pretix.event.order.refund.requested') - if error: - raise OrderError( - _('There was an error while trying to send the money back to you. Please contact the event organizer for further information.') - ) - return ret - except LockTimeoutException: - self.retry() - except (MaxRetriesExceededError, LockTimeoutException): - raise OrderError(error_messages['busy']) - - -def change_payment_provider(order: Order, payment_provider, amount=None, new_payment=None, create_log=True, - recreate_invoices=True): - oldtotal = order.total - e = OrderPayment.objects.filter(fee=OuterRef('pk'), state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, - OrderPayment.PAYMENT_STATE_REFUNDED)) - open_fees = list( - order.fees.annotate(has_p=Exists(e)).filter( - Q(fee_type=OrderFee.FEE_TYPE_PAYMENT) & ~Q(has_p=True) - ) - ) - if open_fees: - fee = open_fees[0] - if len(open_fees) > 1: - for f in open_fees[1:]: - f.delete() - else: - fee = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.00'), order=order) - old_fee = fee.value - - new_fee = payment_provider.calculate_fee( - order.pending_sum - old_fee if amount is None else amount - ) - with transaction.atomic(): - if new_fee: - fee.value = new_fee - fee.internal_type = payment_provider.identifier - fee._calculate_tax() - fee.save() - else: - if fee.pk: - fee.delete() - fee = None - - open_payment = None - if new_payment: - lp = order.payments.exclude(pk=new_payment.pk).last() - else: - lp = order.payments.last() - if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED): - open_payment = lp - - if open_payment and open_payment.state in (OrderPayment.PAYMENT_STATE_PENDING, - OrderPayment.PAYMENT_STATE_CREATED): - open_payment.state = OrderPayment.PAYMENT_STATE_CANCELED - open_payment.save(update_fields=['state']) - - order.total = (order.positions.aggregate(sum=Sum('price'))['sum'] or 0) + (order.fees.aggregate(sum=Sum('value'))['sum'] or 0) - order.save(update_fields=['total']) - - if not new_payment: - new_payment = order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider=payment_provider.identifier, - amount=order.pending_sum, - fee=fee - ) - if create_log and new_payment: - order.log_action( - 'pretix.event.order.payment.changed' if open_payment else 'pretix.event.order.payment.started', - { - 'fee': new_fee, - 'old_fee': old_fee, - 'provider': payment_provider.identifier, - 'payment': new_payment.pk, - 'local_id': new_payment.local_id, - } - ) - - if recreate_invoices: - i = order.invoices.filter(is_cancellation=False).last() - if i and order.total != oldtotal: - generate_cancellation(i) - generate_invoice(order) - - return old_fee, new_fee, fee, new_payment - - -@receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards") -@transaction.atomic() -def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): - for p in order.positions.all(): - if p.item.issue_giftcard: - gc = sender.organizer.issued_gift_cards.create( - currency=sender.currency, issued_in=p - ) - gc.transactions.create(value=p.price, order=order) From 346f215c5080cac0011d86627d5d8298c598d19f Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 19 Sep 2019 10:06:26 +0200 Subject: [PATCH 13/32] Refator payment provider, deal with cancellations --- src/pretix/base/models/orders.py | 6 ++- src/pretix/base/payment.py | 48 +++++++++++++++++-- src/pretix/base/services/orders.py | 16 +++++++ src/pretix/base/settings.py | 4 ++ .../pretixcontrol/giftcards/checkout.html | 10 ++++ src/pretix/control/views/orders.py | 35 +++++++------- src/pretix/presale/checkoutflow.py | 38 +-------------- .../pretixpresale/event/checkout_payment.html | 27 ----------- 8 files changed, 99 insertions(+), 85 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/giftcards/checkout.html diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 0aa8450f50..617cf815bb 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -458,11 +458,15 @@ class Order(LockModel, LoggedModel): positions = list( self.positions.all().annotate( has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'))) - ).select_related('item') + ).select_related('item').prefetch_related('issued_gift_cards') ) cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions]) if not cancelable or not positions: return False + for op in positions: + for gc in op.issued_gift_cards.all(): + if gc.value != op.price: + return False if self.user_cancel_deadline and now() > self.user_cancel_deadline: return False if self.status == Order.STATUS_PENDING: diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index e3cfe4dac9..848c65ecb9 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -7,6 +7,7 @@ from typing import Any, Dict, Union import pytz from django import forms from django.conf import settings +from django.contrib import messages from django.core.exceptions import ImproperlyConfigured from django.db import transaction from django.dispatch import receiver @@ -21,8 +22,8 @@ from i18nfield.strings import LazyI18nString from pretix.base.forms import PlaceholderValidator from pretix.base.models import ( - CartPosition, Event, InvoiceAddress, Order, OrderPayment, OrderRefund, - Quota, + CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment, + OrderRefund, Quota, ) from pretix.base.reldate import RelativeDateField, RelativeDateWrapper from pretix.base.settings import SettingsSandbox @@ -30,6 +31,7 @@ from pretix.base.signals import register_payment_providers from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import rich_text from pretix.helpers.money import DecimalTextInput +from pretix.multidomain.urlreverse import eventreverse from pretix.presale.views import get_cart_total from pretix.presale.views.cart import cart_session, get_or_create_cart_id @@ -890,12 +892,11 @@ class OffsettingProvider(BasePaymentProvider): class GiftCardPayment(BasePaymentProvider): - is_enabled = True identifier = "giftcard" verbose_name = _("Gift card") def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: - return False + return super().is_allowed(request, total) and self.event.organizer.has_gift_cards def order_change_allowed(self, order: Order) -> bool: return False @@ -903,6 +904,9 @@ class GiftCardPayment(BasePaymentProvider): def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: raise PaymentException("Invalid state, should never occur.") + def payment_form_render(self, request: HttpRequest, total: Decimal) -> str: + return get_template('pretixcontrol/giftcards/checkout.html').render({}) + def payment_control_render(self, request, payment) -> str: from .models import GiftCard @@ -933,6 +937,42 @@ class GiftCardPayment(BasePaymentProvider): def payment_refund_supported(self, payment: OrderPayment) -> bool: return True + def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: + cs = cart_session(request) + try: + gc = self.event.organizer.accepted_gift_cards.get( + secret=request.POST.get("giftcard") + ) + if gc.currency != self.event.currency: + messages.error(request, _("This gift card does not support this currency.")) + return + if gc.value <= Decimal("0.00"): + messages.error(request, _("All credit on this gift card has been used.")) + return + if 'gift_cards' not in cs: + cs['gift_cards'] = [] + elif gc.pk in cs['gift_cards']: + messages.error(request, _("This gift card is already used for your payment.")) + return + cs['gift_cards'] = cs['gift_cards'] + [gc.pk] + + remainder = cart['total'] - gc.value + if remainder >= Decimal('0.00'): + messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format( + money_filter(remainder, self.event.currency) + )) + else: + messages.success(request, _("Your gift card has been applied.")) + + kwargs = {'step': 'payment'} + if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs: + kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace'] + return eventreverse(self.event, 'presale:event.checkout', kwargs=kwargs) + except GiftCard.DoesNotExist: + messages.error(request, _("This gift card is not known.")) + except GiftCard.MultipleObjectsReturned: + messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) + @transaction.atomic() def execute_refund(self, refund: OrderRefund): from .models import GiftCard diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 651cba36a9..29a5dd7802 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -329,6 +329,15 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + for position in order.positions.all(): + for gc in position.issued_gift_cards.all(): + if gc.value < position.price: + raise OrderError(_('This order can not be canceled since the gift card {card} purchased in this order has already been redeemed.').format( + card=gc.secret + )) + else: + gc.transactions.create(value=-position.price, order=order) + order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, data={'cancellation_fee': cancellation_fee}) @@ -1659,9 +1668,16 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay @receiver(order_paid, dispatch_uid="pretixbase_order_paid_giftcards") @transaction.atomic() def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): + any_giftcards = False for p in order.positions.all(): if p.item.issue_giftcard: gc = sender.organizer.issued_gift_cards.create( currency=sender.currency, issued_in=p ) gc.transactions.create(value=p.price, order=order) + any_giftcards = True + p.secret = gc.secret + p.save(update_fields=['secret']) + + if any_giftcards: + tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk}) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 94ae8fbef5..790d346a3e 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -133,6 +133,10 @@ DEFAULTS = { 'default': 'True', 'type': bool }, + 'payment_giftcard__enabled': { + 'default': 'True', + 'type': bool + }, 'payment_term_accept_late': { 'default': 'True', 'type': bool diff --git a/src/pretix/control/templates/pretixcontrol/giftcards/checkout.html b/src/pretix/control/templates/pretixcontrol/giftcards/checkout.html new file mode 100644 index 0000000000..b5b1a08346 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/giftcards/checkout.html @@ -0,0 +1,10 @@ +{% load i18n %} + +

+ {% blocktrans %} + If you have a gift card, please enter the gift card code here. If the gift card does not have + enough credit to pay for the full order, you will be shown this page again and you can either + redeem another gift card or select a different payment method for the difference. + {% endblocktrans %} +

+ diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 75e1580d69..b0004f0617 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -897,23 +897,26 @@ class OrderTransition(OrderView): else: messages.success(self.request, _('The payment has been created successfully.')) elif self.order.cancel_allowed() and to == 'c' and self.mark_canceled_form.is_valid(): - cancel_order(self.order, user=self.request.user, - send_mail=self.mark_canceled_form.cleaned_data['send_email'], - cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee')) - self.order.refresh_from_db() + try: + cancel_order(self.order, user=self.request.user, + send_mail=self.mark_canceled_form.cleaned_data['send_email'], + cancellation_fee=self.mark_canceled_form.cleaned_data.get('cancellation_fee')) + except OrderError as e: + messages.error(self.request, str(e)) + else: + self.order.refresh_from_db() + if self.order.pending_sum < 0: + messages.success(self.request, _('The order has been canceled. You can now select how you want to ' + 'transfer the money back to the user.')) + return redirect(reverse('control:event.order.refunds.start', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + 'code': self.order.code + }) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}'.format( + self.order.pending_sum * -1 + )) - if self.order.pending_sum < 0: - messages.success(self.request, _('The order has been canceled. You can now select how you want to ' - 'transfer the money back to the user.')) - return redirect(reverse('control:event.order.refunds.start', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - 'code': self.order.code - }) + '?start-action=do_nothing&start-mode=partial&start-partial_amount={}'.format( - self.order.pending_sum * -1 - )) - - messages.success(self.request, _('The order has been canceled.')) + messages.success(self.request, _('The order has been canceled.')) elif self.order.status == Order.STATUS_PENDING and to == 'e': mark_order_expired(self.order, user=self.request.user) messages.success(self.request, _('The order has been marked as expired.')) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 319648bd2f..a0f9c0c8d0 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -15,13 +15,12 @@ from django.utils.translation import ( from django.views.generic.base import TemplateResponseMixin from django_scopes import scopes_disabled -from pretix.base.models import GiftCard, Order +from pretix.base.models import Order from pretix.base.models.orders import InvoiceAddress, OrderPayment from pretix.base.services.cart import ( get_fees, set_cart_addons, update_tax_rates, ) from pretix.base.services.orders import perform_order -from pretix.base.templatetags.money import money_filter from pretix.base.views.tasks import AsyncAction from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.checkout import ( @@ -531,40 +530,6 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): def post(self, request): self.request = request - if request.POST.get("giftcard") and request.POST.get("payment") == "giftcard": - # TODO: cross-organizer acceptance, … - try: - gc = request.organizer.accepted_gift_cards.get( - secret=request.POST.get("giftcard") - ) - if gc.currency != request.event.currency: - messages.error(self.request, _("This gift card does not support this currency.")) - return self.render() - if gc.value <= Decimal("0.00"): - messages.error(self.request, _("All credit on this gift card has been used.")) - return self.render() - if 'gift_cards' not in self.cart_session: - self.cart_session['gift_cards'] = [] - elif gc.pk in self.cart_session['gift_cards']: - messages.error(self.request, _("This gift card is already used for your payment.")) - return self.render() - self.cart_session['gift_cards'] = self.cart_session['gift_cards'] + [gc.pk] - - remainder = self._total_order_value - gc.value - if remainder >= Decimal('0.00'): - messages.success(self.request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format( - money_filter(remainder, self.event.currency) - )) - else: - messages.success(self.request, _("Your gift card has been applied.")) - return redirect(self.get_step_url(request)) - except GiftCard.DoesNotExist: - messages.error(self.request, _("This gift card is not known.")) - return self.render() - except GiftCard.MultipleObjectsReturned: - messages.error(self.request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) - return self.render() - for p in self.provider_forms: if p['provider'].identifier == request.POST.get('payment', ''): self.cart_session['payment'] = p['provider'].identifier @@ -589,7 +554,6 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): if len(self.provider_forms) == 1: ctx['selected'] = self.provider_forms[0]['provider'].identifier ctx['cart'] = self.get_cart() - ctx['has_gift_cards'] = self.request.organizer.has_gift_cards return ctx @cached_property diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index 53f1ff9baf..21d10ee536 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -51,33 +51,6 @@ {% endfor %} - {% if has_gift_cards %} -
- - -
- {% endif %} {% if not providers %}

{% trans "There are no payment providers enabled." %}

{% if not event.live %} From e97ae045811a896b553583255d8e2eda8e4341cd Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 19 Sep 2019 10:09:59 +0200 Subject: [PATCH 14/32] Helpful error messages --- src/pretix/base/payment.py | 6 +++++- src/pretix/base/services/cart.py | 1 + src/pretix/presale/views/cart.py | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 848c65ecb9..593bdbdab6 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -969,7 +969,11 @@ class GiftCardPayment(BasePaymentProvider): kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace'] return eventreverse(self.event, 'presale:event.checkout', kwargs=kwargs) except GiftCard.DoesNotExist: - messages.error(request, _("This gift card is not known.")) + if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): + messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " + "the product selection.")) + else: + messages.error(request, _("This gift card is not known.")) except GiftCard.MultipleObjectsReturned: messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 999246849d..6431f9eb67 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -97,6 +97,7 @@ error_messages = { 'seat_forbidden': _('You can not select a seat for this position.'), '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."), } diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 4064f2e5e4..b019e154e7 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -478,7 +478,10 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, TemplateView): if v_avail < 1 and not err: err = error_messages['voucher_redeemed_cart'] % self.request.event.settings.reservation_time except Voucher.DoesNotExist: - err = error_messages['voucher_invalid'] + if self.request.event.organizer.accepted_gift_cards.filter(secret__iexact=request.GET.get("voucher")).exists(): + err = error_messages['gift_card'] + else: + err = error_messages['voucher_invalid'] else: return redirect(self.get_index_url()) From 9842fcf7da886715b34794c84b2387c7d5bd4c68 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 19 Sep 2019 17:54:05 +0200 Subject: [PATCH 15/32] Allow order change --- src/pretix/base/models/giftcards.py | 3 + src/pretix/base/payment.py | 79 ++++++++++++++++--- src/pretix/base/services/orders.py | 7 +- .../giftcards/checkout_confirm.html | 8 ++ 4 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py index 88839a62d6..be255904a6 100644 --- a/src/pretix/base/models/giftcards.py +++ b/src/pretix/base/models/giftcards.py @@ -61,6 +61,9 @@ class GiftCard(LoggedModel): def value(self): return self.transactions.aggregate(s=Sum('value'))['s'] or Decimal('0.00') + def accepted_by(self, organizer): + return self.issuer == organizer or GiftCardAcceptance.objects.filter(issuer=self.issuer, collector=organizer).exists() + class Meta: unique_together = (('secret', 'issuer'),) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 593bdbdab6..28e68f007a 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -899,26 +899,27 @@ class GiftCardPayment(BasePaymentProvider): return super().is_allowed(request, total) and self.event.organizer.has_gift_cards def order_change_allowed(self, order: Order) -> bool: - return False - - def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: - raise PaymentException("Invalid state, should never occur.") + return super().order_change_allowed(order) and self.event.organizer.has_gift_cards def payment_form_render(self, request: HttpRequest, total: Decimal) -> str: return get_template('pretixcontrol/giftcards/checkout.html').render({}) + def checkout_confirm_render(self, request) -> str: + return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({}) + def payment_control_render(self, request, payment) -> str: from .models import GiftCard - gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card')) - template = get_template('pretixcontrol/giftcards/payment.html') + if 'gift_card' in payment.info_data: + gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card')) + template = get_template('pretixcontrol/giftcards/payment.html') - ctx = { - 'request': request, - 'event': self.event, - 'gc': gc, - } - return template.render(ctx) + ctx = { + 'request': request, + 'event': self.event, + 'gc': gc, + } + return template.render(ctx) def api_payment_details(self, payment: OrderPayment): from .models import GiftCard @@ -977,6 +978,60 @@ class GiftCardPayment(BasePaymentProvider): except GiftCard.MultipleObjectsReturned: messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) + def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str, None]: + try: + gc = self.event.organizer.accepted_gift_cards.get( + secret=request.POST.get("giftcard") + ) + if gc.currency != self.event.currency: + messages.error(request, _("This gift card does not support this currency.")) + return + if gc.value <= Decimal("0.00"): + messages.error(request, _("All credit on this gift card has been used.")) + return + payment.info_data = { + 'gift_card': gc.pk, + 'retry': True + } + payment.amount = min(payment.amount, gc.value) + payment.save() + + return True + except GiftCard.DoesNotExist: + if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): + messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " + "the product selection.")) + else: + messages.error(request, _("This gift card is not known.")) + except GiftCard.MultipleObjectsReturned: + messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) + + def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: + gcpk = payment.info_data.get('gift_card') + if not gcpk or not payment.info_data.get('retry'): + raise PaymentException("Invalid state, should never occur.") + with transaction.atomic(): + gc = GiftCard.objects.select_for_update().get(pk=gcpk) + if gc.currency != self.event.currency: + raise PaymentException(_("This gift card does not support this currency.")) + if not gc.accepted_by(self.event.organizer): + raise PaymentException(_("This gift card is not accepted by this event organizer.")) + if payment.amount > gc.value: + raise PaymentException(_("This gift card was used in the meantime. Please try again")) + trans = gc.transactions.create( + value=-1 * payment.amount, + order=payment.order, + payment=payment + ) + payment.info_data = { + 'gift_card': gc.pk, + 'transaction_id': trans.pk, + } + payment.confirm() + + def payment_is_valid_session(self, request: HttpRequest) -> bool: + return True + @transaction.atomic() def execute_refund(self, refund: OrderRefund): from .models import GiftCard diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 29a5dd7802..63b94f9f89 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -595,9 +595,12 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d with transaction.atomic(): checked_gift_cards = [] if gift_cards: - gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards) # TODO: Make sure to prevent race conditions + gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards) for gc in gc_qs: - # TODO: Re-check acceptance + if gc.currency != event.currency: + raise OrderError(_("This gift card does not support this currency.")) + if not gc.accepted_by(event.organizer): + raise OrderError(_("This gift card is not accepted by this event organizer.")) checked_gift_cards.append(gc) fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards) diff --git a/src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html b/src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html new file mode 100644 index 0000000000..49108cbb4a --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html @@ -0,0 +1,8 @@ +{% load i18n %} + +

+ {% blocktrans %} + Your gift card will be used to pay for this order. If the credit on the gift card is lower than the order total, you will be able to pay the + difference with a different payment method. If the credit is higher than the order total, you will be able to re-use the gift card in the future. + {% endblocktrans %} +

From ba286d96cb33bf238deff59881d5ebd24e702413 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 13:51:52 +0200 Subject: [PATCH 16/32] Rebase and manually squash migrations --- .../migrations/0135_auto_20190910_2020.py | 48 -------- .../migrations/0136_auto_20190918_1537.py | 29 ----- .../migrations/0137_auto_20190918_1820.py | 26 ----- .../migrations/0138_auto_20190918_1910.py | 20 ---- .../migrations/0138_auto_20191017_1151.py | 106 ++++++++++++++++++ 5 files changed, 106 insertions(+), 123 deletions(-) delete mode 100644 src/pretix/base/migrations/0135_auto_20190910_2020.py delete mode 100644 src/pretix/base/migrations/0136_auto_20190918_1537.py delete mode 100644 src/pretix/base/migrations/0137_auto_20190918_1820.py delete mode 100644 src/pretix/base/migrations/0138_auto_20190918_1910.py create mode 100644 src/pretix/base/migrations/0138_auto_20191017_1151.py diff --git a/src/pretix/base/migrations/0135_auto_20190910_2020.py b/src/pretix/base/migrations/0135_auto_20190910_2020.py deleted file mode 100644 index ec4803180e..0000000000 --- a/src/pretix/base/migrations/0135_auto_20190910_2020.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 2.2.1 on 2019-09-10 20:20 - -import django.db.models.deletion -from django.db import migrations, models - -import pretix.base.models.fields -import pretix.base.models.giftcards - - -class Migration(migrations.Migration): - - dependencies = [ - ('pretixbase', '0134_auto_20190909_1042'), - ] - - operations = [ - migrations.CreateModel( - name='GiftCard', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('issuance', models.DateTimeField(auto_now_add=True)), - ('secret', models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret, max_length=190, unique=True)), - ('currency', models.CharField(max_length=10)), - ('issued_in', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.OrderPosition', null=True, blank=True)), - ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.Organizer')), - ], - ), - migrations.CreateModel( - name='GiftCardTransaction', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('datetime', models.DateTimeField(auto_now_add=True)), - ('value', models.DecimalField(decimal_places=2, max_digits=10)), - ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='pretixbase.GiftCard')), - ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gift_card_transactions', to='pretixbase.Order')), - ('payment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gift_card_transactions', to='pretixbase.OrderPayment')), - ('refund', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='gift_card_transactions', to='pretixbase.OrderRefund')), - ], - ), - migrations.CreateModel( - name='GiftCardAcceptance', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('collector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_card_issuer_acceptance', to='pretixbase.Organizer')), - ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gift_card_collector_acceptance', to='pretixbase.Organizer')), - ], - ), - ] diff --git a/src/pretix/base/migrations/0136_auto_20190918_1537.py b/src/pretix/base/migrations/0136_auto_20190918_1537.py deleted file mode 100644 index fe56026023..0000000000 --- a/src/pretix/base/migrations/0136_auto_20190918_1537.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 2.2.1 on 2019-09-18 15:37 - -import django.db.models.deletion -from django.db import migrations, models - -import pretix.base.models.fields - - -def fwd(app, schema_editor): - Team = app.get_model('pretixbase', 'Team') - Team.objects.filter(can_change_organizer_settings=True).update(can_manage_gift_cards=True) - - -class Migration(migrations.Migration): - - dependencies = [ - ('pretixbase', '0135_auto_20190910_2020'), - ] - - operations = [ - migrations.AddField( - model_name='team', - name='can_manage_gift_cards', - field=models.BooleanField(default=False), - ), - migrations.RunPython( - fwd, migrations.RunPython.noop - ), - ] diff --git a/src/pretix/base/migrations/0137_auto_20190918_1820.py b/src/pretix/base/migrations/0137_auto_20190918_1820.py deleted file mode 100644 index 56e5f6608e..0000000000 --- a/src/pretix/base/migrations/0137_auto_20190918_1820.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 2.2.1 on 2019-09-18 18:20 - -import django.db.models.deletion -from django.db import migrations, models - -import pretix.base.models.fields -import pretix.base.models.giftcards - - -class Migration(migrations.Migration): - - dependencies = [ - ('pretixbase', '0136_auto_20190918_1537'), - ] - - operations = [ - migrations.AlterField( - model_name='giftcard', - name='secret', - field=models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret, max_length=190), - ), - migrations.AlterUniqueTogether( - name='giftcard', - unique_together={('secret', 'issuer')}, - ), - ] diff --git a/src/pretix/base/migrations/0138_auto_20190918_1910.py b/src/pretix/base/migrations/0138_auto_20190918_1910.py deleted file mode 100644 index d25ead6714..0000000000 --- a/src/pretix/base/migrations/0138_auto_20190918_1910.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 2.2.1 on 2019-09-18 19:10 - -from django.db import migrations, models -import django.db.models.deletion -import pretix.base.models.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('pretixbase', '0137_auto_20190918_1820'), - ] - - operations = [ - migrations.AddField( - model_name='item', - name='issue_giftcard', - field=models.BooleanField(default=False), - ), - ] diff --git a/src/pretix/base/migrations/0138_auto_20191017_1151.py b/src/pretix/base/migrations/0138_auto_20191017_1151.py new file mode 100644 index 0000000000..dfc79d5128 --- /dev/null +++ b/src/pretix/base/migrations/0138_auto_20191017_1151.py @@ -0,0 +1,106 @@ +# Generated by Django 2.2.4 on 2019-10-17 11:51 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base +import pretix.base.models.fields +import pretix.base.models.giftcards + + +def fwd(app, schema_editor): + Team = app.get_model('pretixbase', 'Team') + Team.objects.filter(can_change_organizer_settings=True).update(can_manage_gift_cards=True) + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0137_auto_20191015_1141'), + ] + + operations = [ + migrations.CreateModel( + name='GiftCard', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('issuance', models.DateTimeField(auto_now_add=True)), + ('secret', models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret, + max_length=190)), + ('currency', models.CharField(max_length=10)), + ('issued_in', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='issued_gift_cards', to='pretixbase.OrderPosition')), + ('issuer', + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', + to='pretixbase.Organizer')), + ], + options={ + 'unique_together': {('secret', 'issuer')}, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.AddField( + model_name='item', + name='issue_giftcard', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='team', + name='can_manage_gift_cards', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='question', + name='dependency_values', + field=pretix.base.models.fields.MultiStringField(default=[]), + ), + migrations.AlterField( + model_name='voucher', + name='item', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers', + to='pretixbase.Item'), + ), + migrations.AlterField( + model_name='voucher', + name='quota', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers', + to='pretixbase.Quota'), + ), + migrations.AlterField( + model_name='voucher', + name='variation', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vouchers', + to='pretixbase.ItemVariation'), + ), + migrations.CreateModel( + name='GiftCardTransaction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('datetime', models.DateTimeField(auto_now_add=True)), + ('value', models.DecimalField(decimal_places=2, max_digits=10)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', + to='pretixbase.GiftCard')), + ('order', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='gift_card_transactions', to='pretixbase.Order')), + ('payment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='gift_card_transactions', to='pretixbase.OrderPayment')), + ('refund', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, + related_name='gift_card_transactions', to='pretixbase.OrderRefund')), + ], + options={ + 'ordering': ('datetime',), + }, + ), + migrations.CreateModel( + name='GiftCardAcceptance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('collector', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='gift_card_issuer_acceptance', to='pretixbase.Organizer')), + ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='gift_card_collector_acceptance', to='pretixbase.Organizer')), + ], + ), + migrations.RunPython( + fwd, migrations.RunPython.noop + ), + ] From 195d418e005124529d483c870ea99e4fdd7b438e Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 13:52:53 +0200 Subject: [PATCH 17/32] Add spelling word --- doc/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 3b2c05eb61..6ddbea01e4 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -41,6 +41,7 @@ formsets frontend frontpage gettext +giftcard gunicorn guid hardcoded From ac212b798df45ef09fb950e7201a825dc57cb802 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 14:53:31 +0200 Subject: [PATCH 18/32] Remove unneeded settings --- src/pretix/base/payment.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 28e68f007a..3a2f41d635 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -895,6 +895,17 @@ class GiftCardPayment(BasePaymentProvider): identifier = "giftcard" verbose_name = _("Gift card") + @property + def settings_form_fields(self): + f = super().settings_form_fields + del f['_fee_abs'] + del f['_fee_percent'] + del f['_fee_reverse_calc'] + del f['_total_min'] + del f['_total_max'] + del f['_invoice_text'] + return f + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: return super().is_allowed(request, total) and self.event.organizer.has_gift_cards From ac2df35db6eb188ac233c25bb5add9480db0d781 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 15:39:14 +0200 Subject: [PATCH 19/32] Allow configuring cross-organizer acceptance --- src/pretix/base/models/auth.py | 19 ++++++++ .../pretixcontrol/organizers/giftcards.html | 46 +++++++++++++++++-- src/pretix/control/views/organizer.py | 36 +++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 74c3365f1d..fd0faf17eb 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -335,6 +335,25 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): | Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True)) ) + @scopes_disabled() + def get_organizers_with_permission(self, permission, request=None): + """ + Returns a queryset of organizers the user has a specific permissions to. + + :param request: The current request (optional). Required to detect staff sessions properly. + :return: Iterable of Organizers + """ + from .event import Organizer + + if request and self.has_active_staff_session(request.session.session_key): + return Organizer.objects.all() + + kwargs = {permission: True} + + return Organizer.objects.filter( + id__in=self.teams.filter(**kwargs).values_list('organizer', flat=True) + ) + def has_active_staff_session(self, session_key=None): """ Returns whether or not a user has an active staff session (formerly known as superuser session) diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html index 34ce6ccf15..449ac53506 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -16,7 +16,8 @@

{% trans "Manually issue a gift card" %} + class="btn btn-default btn-lg"> {% trans "Manually issue a gift card" %} + {% else %}
@@ -34,7 +35,7 @@

{% trans "Manually issue a gift card" %} + class="btn btn-default"> {% trans "Manually issue a gift card" %}

@@ -60,7 +61,7 @@ @@ -71,4 +72,43 @@ {% include "pretixcontrol/pagination.html" %} {% endif %} + {% if not is_paginated or page_obj.number == 1 %} + + {% csrf_token %} +
+ {% trans "Accepted gift cards of other organizers" %} +

+ {% blocktrans trimmed %} + If you have access to multiple organizer accounts, you can configure that ticket shops in + this account will also accept gift codes issued through a different organizer account, and + vice versa. + {% endblocktrans %} +

+
    + + {% for gca in request.organizer.gift_card_issuer_acceptance.all %} +
  • + {{ gca.issuer }} + +
  • + {% empty %} +
  • + {% trans "You are currently not accepting gift cards from other organizers." %} +
  • + {% endfor %} +
  • + + +
  • +
+
+ + {% endif %} {% endblock %} diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index e7bd96f310..ee36624d35 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -918,9 +918,45 @@ class GiftCardListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi qs = self.filter_form.filter_qs(qs) return qs + def post(self, request, *args, **kwargs): + if "add" in request.POST: + o = self.request.user.get_organizers_with_permission( + 'can_manage_gift_cards', self.request + ).exclude(pk=self.request.organizer.pk).filter( + slug=request.POST.get("add") + ).first() + if o: + self.request.organizer.gift_card_issuer_acceptance.get_or_create( + issuer=o + ) + self.request.organizer.log_action( + 'pretix.giftcards.acceptance.added', + data={'issuer': o.slug}, + user=request.user + ) + messages.success(self.request, _('The selected gift card issuer has been added.')) + if "del" in request.POST: + o = Organizer.objects.filter( + slug=request.POST.get("del") + ).first() + if o: + self.request.organizer.gift_card_issuer_acceptance.filter( + issuer=o + ).delete() + self.request.organizer.log_action( + 'pretix.giftcards.acceptance.removed', + data={'issuer': o.slug}, + user=request.user + ) + messages.success(self.request, _('The selected gift card issuer has been removed.')) + return redirect(reverse('control:organizer.giftcards', kwargs={'organizer': self.request.organizer.slug})) + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['filter_form'] = self.filter_form + ctx['other_organizers'] = self.request.user.get_organizers_with_permission( + 'can_manage_gift_cards', self.request + ).exclude(pk=self.request.organizer.pk) return ctx @cached_property From b3e6f440275d5b04f979b0b78a8f1e160ed7e124 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 16:03:57 +0200 Subject: [PATCH 20/32] Add double-spend safeguard --- doc/api/resources/giftcards.rst | 45 +++++++++++++++++++++++++-- src/pretix/api/views/organizer.py | 25 +++++++++++++-- src/pretix/base/models/giftcards.py | 3 +- src/pretix/base/services/orders.py | 22 ++++++++++--- src/pretix/control/views/organizer.py | 2 +- src/pretix/presale/checkoutflow.py | 5 ++- src/pretix/settings.py | 2 +- src/tests/api/test_giftcards.py | 14 +++++++++ 8 files changed, 104 insertions(+), 14 deletions(-) diff --git a/doc/api/resources/giftcards.rst b/doc/api/resources/giftcards.rst index e785bd621d..99b0bed1df 100644 --- a/doc/api/resources/giftcards.rst +++ b/doc/api/resources/giftcards.rst @@ -142,7 +142,7 @@ Endpoints want to change. You can change all fields of the resource except the ``id``, ``secret``, and ``currency`` fields. Be careful when - modifying the ``value`` field to avoid race conditions. + modifying the ``value`` field to avoid race conditions. We recommend to use the ``transact`` method described below. **Example request**: @@ -170,7 +170,48 @@ Endpoints "id": 1, "secret": "HLBYVELFRC77NCQY", "currency": "EUR", - "value": "13.37" + "value": "14.00" + } + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the gift card to modify + :statuscode 200: no error + :statuscode 400: The gift card could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + +.. http:post:: /api/v1/organizers/(organizer)/giftcards/(id)/transact/ + + Atomically change the value of a gift card. A positive amount will increase the value of the gift card, + a negative amount will decrease it. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/giftcards/1/transact/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "value": "2.00" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "secret": "HLBYVELFRC77NCQY", + "currency": "EUR", + "value": "15.37" } :param organizer: The ``slug`` field of the organizer to modify diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index b0b9de0e0a..f3a5508bf6 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -1,6 +1,8 @@ from django.db import transaction -from rest_framework import filters, viewsets +from rest_framework import filters, serializers, status, viewsets +from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed, PermissionDenied +from rest_framework.response import Response from pretix.api.models import OAuthAccessToken from pretix.api.serializers.organizer import ( @@ -103,7 +105,7 @@ class GiftCardViewSet(viewsets.ModelViewSet): value = serializer.validated_data.pop('value') inst = serializer.save(issuer=self.request.organizer) inst.transactions.create(value=value) - self.request.organizer.log_action( + inst.log_action( 'pretix.giftcards.transaction.manual', user=self.request.user, auth=self.request.auth, @@ -112,12 +114,13 @@ class GiftCardViewSet(viewsets.ModelViewSet): @transaction.atomic() def perform_update(self, serializer): + GiftCard.objects.select_for_update().get(pk=self.get_object().pk) old_value = serializer.instance.value value = serializer.validated_data.pop('value') inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency) diff = value - old_value inst.transactions.create(value=diff) - self.request.organizer.log_action( + inst.log_action( 'pretix.giftcards.transaction.manual', user=self.request.user, auth=self.request.auth, @@ -125,5 +128,21 @@ class GiftCardViewSet(viewsets.ModelViewSet): ) return inst + @action(detail=True, methods=["POST"]) + @transaction.atomic() + def transact(self, request, **kwargs): + gc = GiftCard.objects.select_for_update().get(pk=self.get_object().pk) + value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value( + request.data.get('value') + ) + gc.transactions.create(value=value) + gc.log_action( + 'pretix.giftcards.transaction.manual', + user=self.request.user, + auth=self.request.auth, + data={'value': value} + ) + return Response(GiftCardSerializer(gc).data, status=status.HTTP_200_OK) + def perform_destroy(self, instance): raise MethodNotAllowed("Gift cards cannot be deleted.") diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py index be255904a6..d5b6702da8 100644 --- a/src/pretix/base/models/giftcards.py +++ b/src/pretix/base/models/giftcards.py @@ -6,6 +6,7 @@ from django.db.models import Sum from django.utils.crypto import get_random_string from django.utils.translation import ugettext_lazy as _ +from pretix.base.banlist import banned from pretix.base.models import LoggedModel @@ -13,7 +14,7 @@ def gen_giftcard_secret(): charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') while True: code = get_random_string(length=settings.ENTROPY['giftcard_secret'], allowed_chars=charset) - if not GiftCard.objects.filter(secret=code).exists(): + if not banned(code) and not GiftCard.objects.filter(secret=code).exists(): return code diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 63b94f9f89..6417749037 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -590,7 +590,8 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, - meta_info: dict=None, sales_channel: str='web', gift_cards: list=None): + meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, + shown_total=None): p = None with transaction.atomic(): checked_gift_cards = [] @@ -655,6 +656,17 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d p.save() pending_sum -= val + # Safety check: Is the amount we're now going to charge the same amount the user has been shown when they + # pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again. + # The only *known* case where this happens is if a gift card is used in two concurrent sessions. + if shown_total is not None: + if Decimal(shown_total) != pending_sum: + raise OrderError( + _('While trying to place your order, we noticed that the order total has changed. Either one of ' + 'the prices changed just now, or a gift card you used has been used in the meantime. Please ' + 'check the prices below and try again.') + ) + if payment_provider and not order.require_approval: p = order.payments.create( state=OrderPayment.PAYMENT_STATE_CREATED, @@ -708,7 +720,7 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi def _perform_order(event: Event, payment_provider: str, position_ids: List[str], email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', - gift_cards: list=None): + gift_cards: list=None, shown_total=None): if payment_provider: pprov = event.get_payment_providers().get(payment_provider) if not pprov: @@ -758,7 +770,7 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], _check_positions(event, now_dt, positions, address=addr) order, payment = _create_order(event, email, positions, now_dt, pprov, locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, - gift_cards=gift_cards) + gift_cards=gift_cards, shown_total=shown_total) free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval if free_order_flow: @@ -1517,12 +1529,12 @@ class OrderChangeManager: @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def perform_order(self, event: Event, payment_provider: str, positions: List[str], email: str=None, locale: str=None, address: int=None, meta_info: dict=None, - sales_channel: str='web', gift_cards: list=None): + sales_channel: str='web', gift_cards: list=None, shown_total=None): with language(locale): try: try: return _perform_order(event, payment_provider, positions, email, locale, address, meta_info, - sales_channel, gift_cards) + sales_channel, gift_cards, shown_total) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index ee36624d35..3d9a5a0b07 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -977,7 +977,7 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi @transaction.atomic() def post(self, request, *args, **kwargs): - self.object = self.get_object() + self.object = GiftCard.objects.select_for_update().get(pk=self.get_object().pk) if 'value' in request.POST: try: value = DecimalField().to_python(request.POST.get('value')) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index a0f9c0c8d0..e35295ed71 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -632,6 +632,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): ctx['cart_session'] = self.cart_session ctx['invoice_address_asked'] = self.address_asked + self.cart_session['shown_total'] = str(ctx['cart']['total']) + email = self.cart_session.get('contact_form_data', {}).get('email') if email != settings.PRETIX_EMAIL_NONE_VALUE: ctx['contact_info'] = [ @@ -709,7 +711,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None, [p.id for p in self.positions], self.cart_session.get('email'), translation.get_language(), self.invoice_address.pk, meta_info, - request.sales_channel, self.cart_session.get('gift_cards')) + request.sales_channel, self.cart_session.get('gift_cards'), + self.cart_session['shown_total']) def get_success_message(self, value): create_empty_cart_id(self.request) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index dbc6ccbdcb..3eb6ee1479 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -241,7 +241,7 @@ ENTROPY = { 'order_code': config.getint('entropy', 'order_code', fallback=5), 'ticket_secret': config.getint('entropy', 'ticket_secret', fallback=32), 'voucher_code': config.getint('entropy', 'voucher_code', fallback=16), - 'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=16), + 'giftcard_secret': config.getint('entropy', 'giftcard_secret', fallback=12), } # Internal settings diff --git a/src/tests/api/test_giftcards.py b/src/tests/api/test_giftcards.py index 97cedb7119..012bc8aeb5 100644 --- a/src/tests/api/test_giftcards.py +++ b/src/tests/api/test_giftcards.py @@ -95,6 +95,20 @@ def test_giftcard_patch(token_client, organizer, event, giftcard): assert giftcard.currency == "EUR" +@pytest.mark.django_db +def test_giftcard_transact(token_client, organizer, event, giftcard): + resp = token_client.post( + '/api/v1/organizers/{}/giftcards/{}/transact/'.format(organizer.slug, giftcard.pk), + { + 'value': '10.00', + }, + format='json' + ) + assert resp.status_code == 200 + giftcard.refresh_from_db() + assert giftcard.value == Decimal('33.00') + + @pytest.mark.django_db def test_giftcard_no_deletion(token_client, organizer, event, giftcard): resp = token_client.delete( From 89a85392a90f834f1d73b745a15d5526baae89e2 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 16:39:42 +0200 Subject: [PATCH 21/32] Fixes --- doc/user/events/giftcards.rst | 14 ++++++++++++++ doc/user/index.rst | 3 ++- src/pretix/presale/checkoutflow.py | 2 +- src/tests/presale/test_checkout.py | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 doc/user/events/giftcards.rst diff --git a/doc/user/events/giftcards.rst b/doc/user/events/giftcards.rst new file mode 100644 index 0000000000..dcac228c0b --- /dev/null +++ b/doc/user/events/giftcards.rst @@ -0,0 +1,14 @@ +Gift cards +========== + +Gift cards, also known as "gift coupons" or "gift certificates" are a mechanism that allows you to sell tokens that +can later be used to pay for tickets. + +Gift cards are very different feature than **vouchers**. The difference is: + +* Vouchers can be used to give a discount. When a voucher is used, the price of a ticket is reduced by the configured + discount and sold at a lower price. They therefore reduce both revenue as well as taxes. Vouchers (in pretix) are + always specific to a certain product in an order. Vouchers are usually not sold but given out as part of a + marketing campaign or to specific groups of people. + +* Gift cards are not a discount. If you buy a €20 ticket with a €10 gift card, it is still a €20 ticket and diff --git a/doc/user/index.rst b/doc/user/index.rst index ce5467a843..872407b9ec 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -12,5 +12,6 @@ wanting to use pretix to sell tickets. events/settings events/structureguide events/widget + events/giftcards faq - markdown \ No newline at end of file + markdown diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index e35295ed71..00e34ab850 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -712,7 +712,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): [p.id for p in self.positions], self.cart_session.get('email'), translation.get_language(), self.invoice_address.pk, meta_info, request.sales_channel, self.cart_session.get('gift_cards'), - self.cart_session['shown_total']) + self.cart_session.get('shown_total')) def get_success_message(self, value): create_empty_cart_id(self.request) diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index ac948cd937..7198fc98a9 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -1379,6 +1379,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): cr1.voucher = v cr1.save() + self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) doc = BeautifulSoup(response.rendered_content, "lxml") self.assertEqual(len(doc.select(".thank-you")), 1) From 7db87af7544fe456764e69353f0ca8f276ee3195 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 16:59:44 +0200 Subject: [PATCH 22/32] Update src/pretix/control/templates/pretixcontrol/organizers/giftcards.html Co-Authored-By: Martin Gross --- .../control/templates/pretixcontrol/organizers/giftcards.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html index 449ac53506..d5bc34f980 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -99,7 +99,9 @@ {% endfor %}
  • - {% for o in other_organizers %} From 7b4c3a00a006a02e7e4ddd0c8532597c89d04efa Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 16:59:53 +0200 Subject: [PATCH 23/32] Update src/pretix/control/templates/pretixcontrol/organizers/giftcards.html Co-Authored-By: Martin Gross --- .../control/templates/pretixcontrol/organizers/giftcards.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html index d5bc34f980..b933bde8a2 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -107,7 +107,7 @@ {% endfor %} - +
  • From 767e67914009e53efa0690a78bb1448040cbd54a Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 17:07:59 +0200 Subject: [PATCH 24/32] Show issued gift card in order view --- .../templates/pretixcontrol/order/index.html | 8 ++++++++ .../pretixcontrol/organizers/giftcards.html | 17 ++++++++--------- src/pretix/control/views/orders.py | 2 +- .../pretixpresale/event/fragment_cart.html | 9 ++++++++- src/pretix/presale/views/order.py | 2 +- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 6e09438d76..62b270c808 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -310,6 +310,14 @@ {% eventsignal event "pretix.control.signals.order_position_buttons" order=order position=line request=request %} {% endif %} + {% if line.issued_gift_cards %} +
    + {% for gc in line.issued_gift_cards.all %} +
    {% trans "Gift card code" %}
    +
    {{ gc.secret }}
    + {% endfor %} +
    + {% endif %} {% if line.has_questions %}
    {% if line.item.admission and event.settings.attendee_names_asked %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html index b933bde8a2..3d3eea518e 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -85,7 +85,6 @@ {% endblocktrans %}

      - {% for gca in request.organizer.gift_card_issuer_acceptance.all %}
    • {{ gca.issuer }} @@ -98,17 +97,17 @@ {% trans "You are currently not accepting gift cards from other organizers." %}
    • {% endfor %} -
    • {% if other_organizers %}
    • - -
    • + + {% for o in other_organizers %} + + {% endfor %} + + + + {% endif %}
    diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index b0004f0617..baeb225a48 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -237,7 +237,7 @@ class OrderDetail(OrderView): ).select_related( 'item', 'variation', 'addon_to', 'tax_rule' ).prefetch_related( - 'item__questions', + 'item__questions', 'issued_gift_cards', Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')), 'checkins', 'checkins__list' ).order_by('positionid') diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 5488b9f50b..d04d7588f8 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -33,7 +33,14 @@ {% endif %} {% endif %} - + {% if line.issued_gift_cards %} +
    + {% for gc in line.issued_gift_cards.all %} +
    {% trans "Gift card code" %}
    +
    {{ gc.secret }}
    + {% endfor %} +
    + {% endif %} {% if line.has_questions %}
    {% if line.item.admission and event.settings.attendee_names_asked %} diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index e133d065f3..146ef3b59c 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -163,7 +163,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin, ctx = super().get_context_data(**kwargs) ctx['cart'] = self.get_cart( answers=True, downloads=ctx['can_download'], - queryset=self.order.positions.select_related('tax_rule'), + queryset=self.order.positions.prefetch_related('issued_gift_cards').select_related('tax_rule'), order=self.order ) ctx['can_download_multi'] = any([b['multi'] for b in self.download_buttons]) and ( From 302966808e81c0f6dceab460043846880e690eaf Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 17:19:31 +0200 Subject: [PATCH 25/32] More docs and payments --- doc/user/events/giftcards.rst | 61 +++++++++++++++++++++++++++++- src/pretix/api/serializers/item.py | 11 ++++++ src/pretix/control/forms/item.py | 7 ++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/doc/user/events/giftcards.rst b/doc/user/events/giftcards.rst index dcac228c0b..6e1f833414 100644 --- a/doc/user/events/giftcards.rst +++ b/doc/user/events/giftcards.rst @@ -1,3 +1,8 @@ +.. spelling:: + + Warengutschein + Wertgutschein + Gift cards ========== @@ -9,6 +14,58 @@ Gift cards are very different feature than **vouchers**. The difference is: * Vouchers can be used to give a discount. When a voucher is used, the price of a ticket is reduced by the configured discount and sold at a lower price. They therefore reduce both revenue as well as taxes. Vouchers (in pretix) are always specific to a certain product in an order. Vouchers are usually not sold but given out as part of a - marketing campaign or to specific groups of people. + marketing campaign or to specific groups of people. Vouchers in pretix are bound to a specific event. -* Gift cards are not a discount. If you buy a €20 ticket with a €10 gift card, it is still a €20 ticket and +* Gift cards are not a discount, but rather a means of payment. If you buy a €20 ticket with a €10 gift card, it is + still a €20 ticket and will still count towards your revenue with €20. Gift cards are usually bought for the money + that they are worth. Gift cards in pretix can be used across events (and even organizers). + +Selling gift cards +------------------ + +Selling gift cards works like selling every other type of product in pretix: Create a new product, then head to +"Additional settings" and select the option "This product is a gift card". Whenever someone buys this product and +pays for it, a new gift card will be created. + +In this case, the gift card code corresponds to the "ticket secret" in the PDF ticket. Therefore, if selling gift cards, +you can use ticket downloads just as with normal tickets and use our ticket editor to create beautiful gift certificates +people can give to their loved ones. + +Of course, you can use pretix' flexible options to modify your product. For example, you can configure that the customer +can freely choose the price of the gift card. + +.. note:: + + pretix currently does not support charging sales tax or VAT when selling gift cards, but instead charges VAT on + the full price when the gift card is redeemed. This is the correct behavior in Germany and some other countries for + gift cards which are not bound to a very specific service ("Warengutschein"), but instead to a monetary amount + ("Wertgutschein"). + +.. note:: + + The ticket PDF will not contain the correct gift card code before the order has been paid, so we recommend not + selling gift cards in events where tickets are issued before payments arrive. + + +Accepting gift cards +-------------------- + +All your events have have the payment provider "Gift card" enabled by default, but it will only show up in the ticket +shop once the very first gift card has been issued on your organizer account. Of course, you can turn off gift card +payments if you do not want them for a specific event. + +If gift card payments are enabled, buyers will be able to select "Gift card" as a payment method during checkout. If +a gift card with a value less than the order total is used, the buyer will be asked to select a second payment method +for the remaining payment. If a gift card with a value greater than the order total is used, the surplus amount +remains on the gift card and can be used in a different purchase. + +If it possible to accept gift cards across organizer accounts. To do so, you need to have access to both organizer +accounts. Then, you will see a configuration section at the bottom of the "Gift cards" page of your organizer settings +where you can specify which gift cards should be accepted. + +Manually issuing or using gift cards +------------------------------------ + +Of course, you can also issue or redeem gift cards manually through our backend using the "Gift cards" menu item in your +organizer profile or using our API. These gift cards will be tracked by pretix, but do not correspond to any purchase +within pretix. You will therefore need to account for them in your books separately. diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 7aea644d0c..b28207ae22 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -134,6 +134,17 @@ class ItemSerializer(I18nAwareModelSerializer): Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) Item.clean_available(data.get('available_from'), data.get('available_until')) + if data.get('issue_giftcard'): + if data.get('tax_rule') and data.get('tax_rule').rate > 0: + raise ValidationError( + _("Gift card products should not be associated with non-zero tax rates since sales tax will be " + "applied when the gift card is redeemed.") + ) + if data.get('admission'): + raise ValidationError(_( + "Gift card products should not be admission products at the same time." + )) + return data def validate_category(self, value): diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index b8d13d9b69..21e30a61dd 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -429,6 +429,13 @@ class ItemUpdateForm(I18nModelForm): 'tax_rule', _("Gift card products should not be associated with non-zero tax rates since sales tax will be applied when the gift card is redeemed.") ) + if d['admission']: + self.add_error( + 'admission', + _( + "Gift card products should not be admission products at the same time." + ) + ) return d class Meta: From 4b2f25ce8a22512e2577f9db35540735ad31c654 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 18:05:04 +0200 Subject: [PATCH 26/32] Add testmode for gift cards --- doc/api/resources/giftcards.rst | 11 +++++++++-- src/pretix/api/serializers/organizer.py | 2 +- src/pretix/api/views/organizer.py | 3 ++- .../base/migrations/0138_auto_20191017_1151.py | 1 + src/pretix/base/models/giftcards.py | 4 ++++ src/pretix/base/models/orders.py | 6 +++++- src/pretix/base/payment.py | 18 ++++++++++++++++++ src/pretix/base/services/cart.py | 5 +++++ src/pretix/base/services/orders.py | 6 +++++- src/pretix/control/forms/organizer.py | 2 +- .../pretixcontrol/organizers/giftcard.html | 3 +++ .../organizers/giftcard_create.html | 1 + .../pretixcontrol/organizers/giftcards.html | 3 +++ src/tests/api/test_giftcards.py | 4 ++++ 14 files changed, 62 insertions(+), 7 deletions(-) diff --git a/doc/api/resources/giftcards.rst b/doc/api/resources/giftcards.rst index 99b0bed1df..0d441a3af8 100644 --- a/doc/api/resources/giftcards.rst +++ b/doc/api/resources/giftcards.rst @@ -17,6 +17,7 @@ id integer Internal ID of secret string Gift card code (can not be modified later) value money (string) Current gift card value currency string Currency of the value (can not be modified later) +testmode boolean Whether this is a test gift card ===================================== ========================== ======================================================= Endpoints @@ -51,6 +52,7 @@ Endpoints "id": 1, "secret": "HLBYVELFRC77NCQY", "currency": "EUR", + "testmode": false, "value": "13.37" } ] @@ -86,6 +88,7 @@ Endpoints "id": 1, "secret": "HLBYVELFRC77NCQY", "currency": "EUR", + "testmode": false, "value": "13.37" } @@ -125,6 +128,7 @@ Endpoints { "id": 1, "secret": "HLBYVELFRC77NCQY", + "testmode": false, "currency": "EUR", "value": "13.37" } @@ -141,8 +145,9 @@ Endpoints the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you want to change. - You can change all fields of the resource except the ``id``, ``secret``, and ``currency`` fields. Be careful when - modifying the ``value`` field to avoid race conditions. We recommend to use the ``transact`` method described below. + You can change all fields of the resource except the ``id``, ``secret``, ``testmode``, and ``currency`` fields. Be + careful when modifying the ``value`` field to avoid race conditions. We recommend to use the ``transact`` method + described below. **Example request**: @@ -169,6 +174,7 @@ Endpoints { "id": 1, "secret": "HLBYVELFRC77NCQY", + "testmode": false, "currency": "EUR", "value": "14.00" } @@ -211,6 +217,7 @@ Endpoints "id": 1, "secret": "HLBYVELFRC77NCQY", "currency": "EUR", + "testmode": false, "value": "15.37" } diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index a7980d5f4c..622179d601 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -46,4 +46,4 @@ class GiftCardSerializer(I18nAwareModelSerializer): class Meta: model = GiftCard - fields = ('id', 'secret', 'issuance', 'value', 'currency') + fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode') diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index f3a5508bf6..4f3f676e44 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -117,7 +117,8 @@ class GiftCardViewSet(viewsets.ModelViewSet): GiftCard.objects.select_for_update().get(pk=self.get_object().pk) old_value = serializer.instance.value value = serializer.validated_data.pop('value') - inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency) + inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, + testmode=serializer.instance.testmode) diff = value - old_value inst.transactions.create(value=diff) inst.log_action( diff --git a/src/pretix/base/migrations/0138_auto_20191017_1151.py b/src/pretix/base/migrations/0138_auto_20191017_1151.py index dfc79d5128..09c365faf5 100644 --- a/src/pretix/base/migrations/0138_auto_20191017_1151.py +++ b/src/pretix/base/migrations/0138_auto_20191017_1151.py @@ -32,6 +32,7 @@ class Migration(migrations.Migration): ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.Organizer')), + ('testmode', django.db.models.BooleanField(default=False)), ], options={ 'unique_together': {('secret', 'issuer')}, diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py index d5b6702da8..424167a720 100644 --- a/src/pretix/base/models/giftcards.py +++ b/src/pretix/base/models/giftcards.py @@ -52,6 +52,10 @@ class GiftCard(LoggedModel): db_index=True, verbose_name=_('Gift card code'), ) + testmode = models.BooleanField( + verbose_name=_('Test mode card'), + default=False + ) CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES] currency = models.CharField(max_length=10, choices=CURRENCY_CHOICES) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 617cf815bb..1b147182ad 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -201,7 +201,7 @@ class Order(LockModel, LoggedModel): return self.full_code def gracefully_delete(self, user=None, auth=None): - from . import Voucher + from . import Voucher, GiftCard, GiftCardTransaction if not self.testmode: raise TypeError("Only test mode orders can be deleted.") @@ -217,6 +217,10 @@ class Order(LockModel, LoggedModel): if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + GiftCardTransaction.objects.filter(payment__in=self.payments.all()).update(payment=None) + GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None) + GiftCardTransaction.objects.filter(order=self).update(order=None) + GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None) OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() OrderPosition.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete() diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 3a2f41d635..fbdbdc4d11 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -906,6 +906,10 @@ class GiftCardPayment(BasePaymentProvider): del f['_invoice_text'] return f + @property + def test_mode_message(self) -> str: + return _("In test mode, only test cards will work.") + def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: return super().is_allowed(request, total) and self.event.organizer.has_gift_cards @@ -958,6 +962,12 @@ class GiftCardPayment(BasePaymentProvider): if gc.currency != self.event.currency: messages.error(request, _("This gift card does not support this currency.")) return + if gc.testmode and not self.event.testmode: + messages.error(request, _("This gift card can only be used in test mode.")) + return + if not gc.testmode and self.event.testmode: + messages.error(request, _("Only test gifts cards can be used in test mode.")) + return if gc.value <= Decimal("0.00"): messages.error(request, _("All credit on this gift card has been used.")) return @@ -997,6 +1007,12 @@ class GiftCardPayment(BasePaymentProvider): if gc.currency != self.event.currency: messages.error(request, _("This gift card does not support this currency.")) return + if gc.testmode and not self.event.testmode: + messages.error(request, _("This gift card can only be used in test mode.")) + return + if not gc.testmode and self.event.testmode: + messages.error(request, _("Only test gift cards can be used in test mode.")) + return if gc.value <= Decimal("0.00"): messages.error(request, _("All credit on this gift card has been used.")) return @@ -1027,6 +1043,8 @@ class GiftCardPayment(BasePaymentProvider): raise PaymentException(_("This gift card does not support this currency.")) if not gc.accepted_by(self.event.organizer): raise PaymentException(_("This gift card is not accepted by this event organizer.")) + if gc.testmode != payment.order.testmode: + raise PaymentException(_("Only the gift card or only the order are created in test mode.")) if payment.amount > gc.value: raise PaymentException(_("This gift card was used in the meantime. Please try again")) trans = gc.transactions.create( diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 6431f9eb67..a8146e5706 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -971,9 +971,13 @@ def get_fees(event, request, total, invoice_address, provider): cs = cart_session(request) if cs.get('gift_cards'): + gcs = cs['gift_cards'] gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency) summed = 0 for gc in gc_qs: + if gc.testmode != event.testmode: + gcs.remove(gc.pk) + continue fval = Decimal(gc.value) # TODO: don't require an extra query fval = min(fval, total - summed) if fval > 0: @@ -988,6 +992,7 @@ def get_fees(event, request, total, invoice_address, provider): tax_value=Decimal('0.00'), tax_rule=TaxRule.zero() )) + cs['gift_cards'] = gcs if provider and total != 0: provider = event.get_payment_providers().get(provider) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 6417749037..d8e433e9e2 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -600,6 +600,10 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d for gc in gc_qs: if gc.currency != event.currency: raise OrderError(_("This gift card does not support this currency.")) + if gc.testmode and not event.testmode: + raise OrderError(_("This gift card can only be used in test mode.")) + if not gc.testmode and event.testmode: + raise OrderError(_("Only test gift cards can be used in test mode.")) if not gc.accepted_by(event.organizer): raise OrderError(_("This gift card is not accepted by this event organizer.")) checked_gift_cards.append(gc) @@ -1687,7 +1691,7 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): for p in order.positions.all(): if p.item.issue_giftcard: gc = sender.organizer.issued_gift_cards.create( - currency=sender.currency, issued_in=p + currency=sender.currency, issued_in=p, testmode=order.testmode ) gc.transactions.create(value=p.price, order=order) any_giftcards = True diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 503720f002..69427ca8c5 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -355,4 +355,4 @@ class GiftCardCreateForm(forms.ModelForm): class Meta: model = GiftCard - fields = ['secret', 'currency'] + fields = ['secret', 'currency', 'testmode'] diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html index 21f58c02fd..34f7398e16 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html @@ -7,6 +7,9 @@ {% blocktrans trimmed with card=card.secret %} Gift card: {{ card }} {% endblocktrans %} + {% if card.testmode %} + {% trans "TEST MODE" %} + {% endif %}
    diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html index bc81beb2a4..7d5c9a654b 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html @@ -9,6 +9,7 @@ {% bootstrap_field form.secret layout="control" %} {% bootstrap_field form.value layout="control" %} {% bootstrap_field form.currency layout="control" %} + {% bootstrap_field form.testmode layout="control" %}
    + class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Details" %}">
    {{ g.issuance|date:"SHORT_DATETIME_FORMAT" }} diff --git a/src/tests/api/test_giftcards.py b/src/tests/api/test_giftcards.py index 012bc8aeb5..4f1333068c 100644 --- a/src/tests/api/test_giftcards.py +++ b/src/tests/api/test_giftcards.py @@ -18,6 +18,7 @@ TEST_GC_RES = { "id": 1, "secret": "ABCDEF", "value": "23.00", + "testmode": False, "currency": "EUR" } @@ -46,6 +47,7 @@ def test_giftcard_detail(token_client, organizer, event, giftcard): TEST_GIFTCARD_CREATE_PAYLOAD = { "secret": "DEFABC", "value": "12.00", + "testmode": False, "currency": "EUR", } @@ -84,6 +86,7 @@ def test_giftcard_patch(token_client, organizer, event, giftcard): { 'secret': 'foo', 'value': '10.00', + 'testmode': True, 'currency': 'USD' }, format='json' @@ -93,6 +96,7 @@ def test_giftcard_patch(token_client, organizer, event, giftcard): assert giftcard.value == Decimal('10.00') assert giftcard.secret == "ABCDEF" assert giftcard.currency == "EUR" + assert not giftcard.testmode @pytest.mark.django_db From 1fe93ac6b749d3ca69abd615a832eb4c4f63b7ee Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 18:12:06 +0200 Subject: [PATCH 27/32] Do not allow to pay gift cards with gift cards --- src/pretix/base/payment.py | 16 +++++++++++++++- src/pretix/base/services/orders.py | 2 ++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index fbdbdc4d11..f9f0af89d5 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -32,7 +32,7 @@ from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import rich_text from pretix.helpers.money import DecimalTextInput from pretix.multidomain.urlreverse import eventreverse -from pretix.presale.views import get_cart_total +from pretix.presale.views import get_cart, get_cart_total from pretix.presale.views.cart import cart_session, get_or_create_cart_id logger = logging.getLogger(__name__) @@ -954,6 +954,11 @@ class GiftCardPayment(BasePaymentProvider): return True def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: + for p in get_cart(request): + if p.item.issue_giftcard: + messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) + return + cs = cart_session(request) try: gc = self.event.organizer.accepted_gift_cards.get( @@ -1000,6 +1005,11 @@ class GiftCardPayment(BasePaymentProvider): messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str, None]: + for p in payment.order.positions.all(): + if p.item.issue_giftcard: + messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) + return + try: gc = self.event.organizer.accepted_gift_cards.get( secret=request.POST.get("giftcard") @@ -1034,6 +1044,10 @@ class GiftCardPayment(BasePaymentProvider): messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: + for p in payment.order.positions.all(): + if p.item.issue_giftcard: + raise PaymentException(_("You cannot pay with gift cards when buying a gift card.")) + gcpk = payment.info_data.get('gift_card') if not gcpk or not payment.info_data.get('retry'): raise PaymentException("Invalid state, should never occur.") diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index d8e433e9e2..06bd5f9313 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -607,6 +607,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d if not gc.accepted_by(event.organizer): raise OrderError(_("This gift card is not accepted by this event organizer.")) checked_gift_cards.append(gc) + if checked_gift_cards and any(c.item.issue_giftcard for c in positions): + raise OrderError(_("You cannot pay with gift cards when buying a gift card.")) fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards) total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) From a1c7a6f2b07ad36dc873436e9be300c7c764bff1 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 17 Oct 2019 21:31:45 +0200 Subject: [PATCH 28/32] Fix items test --- src/tests/api/test_items.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 1b8b6c5080..b3cf597e6f 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -428,7 +428,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego "tax_rate": "19.00", "tax_rule": taxrule.pk, "admission": True, - "issue_giftcard": True, + "issue_giftcard": False, "position": 0, "picture": None, "available_from": None, @@ -479,7 +479,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, "tax_rate": "19.00", "tax_rule": taxrule.pk, "admission": True, - "issue_giftcard": True, + "issue_giftcard": False, "position": 0, "picture": None, "available_from": None, @@ -524,7 +524,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, "tax_rate": "19.00", "tax_rule": taxrule.pk, "admission": True, - "issue_giftcard": True, + "issue_giftcard": False, "position": 0, "picture": None, "available_from": None, @@ -567,7 +567,7 @@ def test_item_create_with_addon(token_client, organizer, event, item, category, "tax_rate": "19.00", "tax_rule": taxrule.pk, "admission": True, - "issue_giftcard": True, + "issue_giftcard": False, "position": 0, "picture": None, "available_from": None, @@ -661,7 +661,7 @@ def test_item_create_with_bundle(token_client, organizer, event, item, category, "tax_rate": "19.00", "tax_rule": taxrule.pk, "admission": True, - "issue_giftcard": True, + "issue_giftcard": False, "position": 0, "picture": None, "available_from": None, @@ -707,7 +707,7 @@ def test_item_create_with_bundle(token_client, organizer, event, item, category, "tax_rate": "19.00", "tax_rule": taxrule.pk, "admission": True, - "issue_giftcard": True, + "issue_giftcard": False, "position": 0, "picture": None, "available_from": None, From f8433b5cc9db81ca1f9042f032d574c39ac10b1f Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 18 Oct 2019 13:08:25 +0200 Subject: [PATCH 29/32] Add some tests --- src/pretix/base/payment.py | 6 +- src/pretix/base/services/orders.py | 38 +++-- src/pretix/control/forms/organizer.py | 4 +- src/tests/api/conftest.py | 6 + src/tests/api/test_items.py | 69 ++++++++ src/tests/base/test_models.py | 11 ++ src/tests/base/test_orders.py | 62 +++++++ src/tests/control/test_giftcards.py | 125 ++++++++++++++ src/tests/control/test_items.py | 16 ++ src/tests/presale/test_checkout.py | 233 ++++++++++++++++++++++++++ 10 files changed, 555 insertions(+), 15 deletions(-) create mode 100644 src/tests/control/test_giftcards.py diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index f9f0af89d5..6f17b19faa 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -971,7 +971,7 @@ class GiftCardPayment(BasePaymentProvider): messages.error(request, _("This gift card can only be used in test mode.")) return if not gc.testmode and self.event.testmode: - messages.error(request, _("Only test gifts cards can be used in test mode.")) + messages.error(request, _("Only test gift cards can be used in test mode.")) return if gc.value <= Decimal("0.00"): messages.error(request, _("All credit on this gift card has been used.")) @@ -1017,10 +1017,10 @@ class GiftCardPayment(BasePaymentProvider): if gc.currency != self.event.currency: messages.error(request, _("This gift card does not support this currency.")) return - if gc.testmode and not self.event.testmode: + if gc.testmode and not payment.order.testmode: messages.error(request, _("This gift card can only be used in test mode.")) return - if not gc.testmode and self.event.testmode: + if not gc.testmode and payment.order.testmode: messages.error(request, _("Only test gift cards can be used in test mode.")) return if gc.value <= Decimal("0.00"): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 06bd5f9313..49169b0124 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -292,6 +292,19 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device if i: generate_cancellation(i) + for position in order.positions.all(): + for gc in position.issued_gift_cards.all(): + gc = GiftCard.objects.select_for_update().get(pk=gc.pk) + if gc.value < position.price: + raise OrderError( + _('This order can not be canceled since the gift card {card} purchased in ' + 'this order has already been redeemed.').format( + card=gc.secret + ) + ) + else: + gc.transactions.create(value=-position.price, order=order) + if cancellation_fee: with order.event.lock(): for position in order.positions.all(): @@ -329,15 +342,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) - for position in order.positions.all(): - for gc in position.issued_gift_cards.all(): - if gc.value < position.price: - raise OrderError(_('This order can not be canceled since the gift card {card} purchased in this order has already been redeemed.').format( - card=gc.secret - )) - else: - gc.transactions.create(value=-position.price, order=order) - order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device, data={'cancellation_fee': cancellation_fee}) @@ -1257,6 +1261,17 @@ class OrderChangeManager: op.position._calculate_tax() op.position.save() elif isinstance(op, self.CancelOperation): + for gc in op.position.issued_gift_cards.all(): + gc = GiftCard.objects.select_for_update().get(pk=gc.pk) + if gc.value < op.position.price: + raise OrderError(_( + 'A position can not be canceled since the gift card {card} purchased in this order has ' + 'already been redeemed.').format( + card=gc.secret + )) + else: + gc.transactions.create(value=-op.position.price, order=self.order) + for opa in op.position.addons.all(): self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={ 'position': opa.pk, @@ -1692,10 +1707,13 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): any_giftcards = False for p in order.positions.all(): if p.item.issue_giftcard: + issued = Decimal('0.00') + for gc in p.issued_gift_cards.all(): + issued += gc.transactions.first().value gc = sender.organizer.issued_gift_cards.create( currency=sender.currency, issued_in=p, testmode=order.testmode ) - gc.transactions.create(value=p.price, order=order) + gc.transactions.create(value=p.price - issued, order=order) any_giftcards = True p.secret = gc.secret p.save(update_fields=['secret']) diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 69427ca8c5..4424582699 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -341,10 +341,10 @@ class GiftCardCreateForm(forms.ModelForm): self.organizer = kwargs.pop('organizer') super().__init__(*args, **kwargs) - def validate_secret(self): + def clean_secret(self): s = self.cleaned_data['secret'] if GiftCard.objects.filter( - secret=s + secret__iexact=s ).filter( Q(issuer=self.organizer) | Q(issuer__gift_card_collector_acceptance__collector=self.organizer) ).exists(): diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index f0beff1512..a3b2bdcc3d 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -156,6 +156,12 @@ def taxrule(event): return event.tax_rules.create(name="VAT", rate=19) +@pytest.fixture +@scopes_disabled() +def taxrule0(event): + return event.tax_rules.create(name="VAT", rate=0) + + @pytest.fixture @scopes_disabled() def taxrule2(event2): diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index b3cf597e6f..85a602b8a1 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -463,6 +463,75 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego assert new_item.variations.first().value.localize('en') == "Comment" +@pytest.mark.django_db +def test_item_create_giftcard_validation(token_client, organizer, event, item, category, category2, taxrule, taxrule0): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule0.pk, + "admission": True, + "issue_giftcard": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "addons": [] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["Gift card products should not be admission products at the same time."]}' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug), + { + "category": category.pk, + "name": { + "en": "Ticket" + }, + "active": True, + "description": None, + "default_price": "23.00", + "free_price": False, + "tax_rate": "19.00", + "tax_rule": taxrule.pk, + "admission": False, + "issue_giftcard": True, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "checkin_attention": False, + "has_variations": True, + "addons": [] + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["Gift card products should not be associated with non-zero ' \ + 'tax rates since sales tax will be applied when the gift card is redeemed."]}' + + @pytest.mark.django_db def test_item_create_with_addon(token_client, organizer, event, item, category, category2, taxrule): resp = token_client.post( diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index c203ec5424..e5db1752ee 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1074,6 +1074,17 @@ class OrderTestCase(BaseQuotaTestCase): self.event.settings.cancel_allow_user = False assert not self.order.user_cancel_allowed + @classscope(attr='o') + def test_can_cancel_order_with_giftcard(self): + item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True, allow_cancel=True, issue_giftcard=True) + p = OrderPosition.objects.create(order=self.order, item=item1, + variation=None, price=23) + self.event.organizer.issued_gift_cards.create( + currency="EUR", issued_in=p + ) + assert not self.order.user_cancel_allowed + @classscope(attr='o') def test_can_cancel_order_free(self): self.order.status = Order.STATUS_PAID diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index c9614257d0..07b31e5cf1 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -592,6 +592,52 @@ class OrderCancelTests(TestCase): assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists() assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() + @classscope(attr='o') + def test_auto_refund_possible_giftcard(self): + gc = self.o.issued_gift_cards.create(currency="EUR") + p1 = self.order.payments.create( + amount=Decimal('46.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='giftcard', + info='{"gift_card": %d}' % gc.pk + ) + cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + r = self.order.refunds.get() + assert r.state == OrderRefund.REFUND_STATE_DONE + assert r.amount == Decimal('44.00') + assert r.source == OrderRefund.REFUND_SOURCE_BUYER + assert r.payment == p1 + assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.created').exists() + assert not self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() + assert gc.value == Decimal('44.00') + + @classscope(attr='o') + def test_auto_refund_possible_issued_giftcard(self): + gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1) + gc.transactions.create(value=23) + self.order.payments.create( + amount=Decimal('46.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='testdummy_partialrefund' + ) + cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + r = self.order.refunds.get() + assert r.state == OrderRefund.REFUND_STATE_DONE + assert gc.value == Decimal('0.00') + + @classscope(attr='o') + def test_auto_refund_impossible_issued_giftcard_used(self): + gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1) + gc.transactions.create(value=20) + self.order.payments.create( + amount=Decimal('46.00'), + state=OrderPayment.PAYMENT_STATE_CONFIRMED, + provider='testdummy_partialrefund' + ) + with pytest.raises(OrderError): + cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + assert gc.value == Decimal('20.00') + @classscope(attr='o') def test_auto_refund_impossible(self): self.order.payments.create( @@ -859,6 +905,22 @@ class OrderChangeManagerTests(TestCase): assert self.op1.price == Decimal('24.00') assert self.order.status == Order.STATUS_PENDING + @classscope(attr='o') + def test_cancel_issued_giftcard(self): + gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1) + gc.transactions.create(value=23) + self.ocm.cancel(self.op1) + self.ocm.commit() + assert gc.value == Decimal('0.00') + + @classscope(attr='o') + def test_cancel_issued_giftcard_used(self): + gc = self.o.issued_gift_cards.create(currency="EUR", issued_in=self.op1) + gc.transactions.create(value=20) + self.ocm.cancel(self.op1) + with self.assertRaises(OrderError): + self.ocm.commit() + @classscope(attr='o') def test_cancel_all_in_order(self): self.ocm.cancel(self.op1) diff --git a/src/tests/control/test_giftcards.py b/src/tests/control/test_giftcards.py new file mode 100644 index 0000000000..47c787bb89 --- /dev/null +++ b/src/tests/control/test_giftcards.py @@ -0,0 +1,125 @@ +import pytest + +from pretix.base.models import Organizer, Team, User + + +@pytest.fixture +def organizer(): + return Organizer.objects.create(name='Dummy', slug='dummy') + + +@pytest.fixture +def organizer2(): + return Organizer.objects.create(name='Partner', slug='partner') + + +@pytest.fixture +def gift_card(organizer): + gc = organizer.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=42) + return gc + + +@pytest.fixture +def admin_user(organizer): + u = User.objects.create_user('dummy@dummy.dummy', 'dummy') + admin_team = Team.objects.create(organizer=organizer, can_manage_gift_cards=True, name='Admin team') + admin_team.members.add(u) + return u + + +@pytest.fixture +def team2(admin_user, organizer2): + admin_team = Team.objects.create(organizer=organizer2, can_manage_gift_cards=True, name='Admin team') + admin_team.members.add(admin_user) + + +@pytest.mark.django_db +def test_list_of_cards(organizer, admin_user, client, gift_card): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/giftcards') + assert gift_card.secret in resp.content.decode() + resp = client.get('/control/organizer/dummy/giftcards?query=' + gift_card.secret[:3]) + assert gift_card.secret in resp.content.decode() + resp = client.get('/control/organizer/dummy/giftcards?query=1234_FOO') + assert gift_card.secret not in resp.content.decode() + + +@pytest.mark.django_db +def test_card_detail_view(organizer, admin_user, gift_card, client): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.get('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk)) + assert gift_card.secret in resp.content.decode() + assert '42.00' in resp.content.decode() + + +@pytest.mark.django_db +def test_card_add(organizer, admin_user, client): + client.login(email='dummy@dummy.dummy', password='dummy') + resp = client.post('/control/organizer/dummy/giftcard/add', { + 'currency': 'EUR', + 'secret': 'FOOBAR', + 'value': '42.00', + 'testmode': 'on' + }, follow=True) + assert 'TEST MODE' in resp.content.decode() + assert '42.00' in resp.content.decode() + resp = client.post('/control/organizer/dummy/giftcard/add', { + 'currency': 'EUR', + 'secret': 'FOOBAR', + 'value': '42.00', + 'testmode': 'on' + }, follow=True) + assert 'has-error' in resp.content.decode() + + +@pytest.mark.django_db +def test_card_detail_view_transact(organizer, admin_user, gift_card, client): + client.login(email='dummy@dummy.dummy', password='dummy') + client.post('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk), { + 'value': '23.00' + }) + assert gift_card.value == 23 + 42 + assert gift_card.all_logentries().count() == 1 + + +@pytest.mark.django_db +def test_card_detail_view_transact_min_value(organizer, admin_user, gift_card, client): + client.login(email='dummy@dummy.dummy', password='dummy') + r = client.post('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk), { + 'value': '-50.00' + }) + assert 'alert-danger' in r.rendered_content + assert gift_card.value == 42 + + +@pytest.mark.django_db +def test_card_detail_view_transact_invalid_value(organizer, admin_user, gift_card, client): + client.login(email='dummy@dummy.dummy', password='dummy') + r = client.post('/control/organizer/dummy/giftcard/{}/'.format(gift_card.pk), { + 'value': 'foo' + }) + assert 'alert-danger' in r.rendered_content + assert gift_card.value == 42 + + +@pytest.mark.django_db +def test_manage_acceptance(organizer, organizer2, admin_user, gift_card, client, team2): + client.login(email='dummy@dummy.dummy', password='dummy') + client.post('/control/organizer/dummy/giftcards'.format(gift_card.pk), { + 'add': organizer2.slug + }) + assert organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists() + client.post('/control/organizer/dummy/giftcards'.format(gift_card.pk), { + 'del': organizer2.slug + }) + assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists() + + +@pytest.mark.django_db +def test_manage_acceptance_permission_required(organizer, organizer2, admin_user, gift_card, client): + client.login(email='dummy@dummy.dummy', password='dummy') + client.post('/control/organizer/dummy/giftcards'.format(gift_card.pk), { + 'add': organizer2.slug + }) + assert not organizer.gift_card_issuer_acceptance.filter(issuer=organizer2).exists() diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index 10b97195c8..1d5d2289e9 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -419,6 +419,22 @@ class ItemsTest(ItemFormTest): self.item1.refresh_from_db() assert self.item1.default_price == Decimal('23.00') + def test_update_validate_giftcard(self): + doc = self.get_doc('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item2.id)) + d = extract_form_fields(doc.select('.container-fluid form')[0]) + d.update({ + 'name_0': 'Standard', + 'default_price': '23.00', + 'admission': 'on', + 'issue_giftcard': 'on', + 'active': 'yes', + 'allow_cancel': 'yes', + 'sales_channels': 'web' + }) + self.client.post('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item1.id), d) + self.item1.refresh_from_db() + assert not self.item1.issue_giftcard + def test_manipulate_addons(self): doc = self.get_doc('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item2.id)) d = extract_form_fields(doc.select('.container-fluid form')[0]) diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 7198fc98a9..610eee2abd 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -795,6 +795,239 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): doc = BeautifulSoup(response.rendered_content, "lxml") assert doc.select(".alert-danger") + def test_giftcard_partial(self): + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=20) + self.event.settings.set('payment_stripe__enabled', True) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select('input[name="payment"]')), 3) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert '-€20.00' in response.rendered_content + assert '3.00' in response.rendered_content + assert 'alert-success' in response.rendered_content + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert '-€20.00' in response.rendered_content + assert '3.00' in response.rendered_content + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + assert o.payments.get(provider='giftcard').amount == Decimal('20.00') + assert o.payments.get(provider='banktransfer').amount == Decimal('3.00') + + assert '-€20.00' in response.rendered_content + assert '3.00' in response.rendered_content + + def test_giftcard_full(self): + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=30) + self.event.settings.set('payment_stripe__enabled', True) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select('input[name="payment"]')), 3) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert '-€23.00' in response.rendered_content + assert '0.00' in response.rendered_content + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + assert o.payments.get(provider='giftcard').amount == Decimal('23.00') + + def test_giftcard_racecondition(self): + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=20) + self.event.settings.set('payment_stripe__enabled', True) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select('input[name="payment"]')), 3) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert '-€20.00' in response.rendered_content + assert '3.00' in response.rendered_content + assert 'alert-success' in response.rendered_content + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=True) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert '-€20.00' in response.rendered_content + assert '3.00' in response.rendered_content + + gc.transactions.create(value=-2) + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".alert-danger")), 1) + assert '-€18.00' in response.rendered_content + assert '5.00' in response.rendered_content + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + o = Order.objects.last() + assert o.payments.get(provider='giftcard').amount == Decimal('18.00') + assert o.payments.get(provider='banktransfer').amount == Decimal('5.00') + + def test_giftcard_invalid_currency(self): + gc = self.orga.issued_gift_cards.create(currency="USD") + gc.transactions.create(value=20) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + assert 'This gift card does not support this currency.' in response.rendered_content + + def test_giftcard_invalid_organizer(self): + self.orga.issued_gift_cards.create(currency="EUR") + orga2 = Organizer.objects.create(slug="foo2", name="foo2") + gc = orga2.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=20) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + print(response.rendered_content) + assert 'This gift card is not known.' in response.rendered_content + + def test_giftcard_in_test_mode(self): + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=20) + self.event.settings.set('payment_banktransfer__enabled', True) + self.event.testmode = True + self.event.save() + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + assert 'Only test gift cards can be used in test mode.' in response.rendered_content + + def test_giftcard_not_in_test_mode(self): + gc = self.orga.issued_gift_cards.create(currency="EUR", testmode=True) + gc.transactions.create(value=20) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + assert 'This gift card can only be used in test mode.' in response.rendered_content + + def test_giftcard_empty(self): + gc = self.orga.issued_gift_cards.create(currency="EUR") + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + assert 'All credit on this gift card has been used.' in response.rendered_content + + def test_giftcard_twice(self): + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=20) + self.event.settings.set('payment_banktransfer__enabled', True) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + assert 'This gift card is already used for your payment.' in response.rendered_content + + def test_giftcard_swap(self): + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=20) + self.event.settings.set('payment_banktransfer__enabled', True) + self.ticket.issue_giftcard = True + self.ticket.save() + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'giftcard', + 'giftcard': gc.secret + }, follow=True) + assert 'You cannot pay with gift cards when buying a gift card.' in response.rendered_content + def test_premature_confirm(self): response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), From 2c87d5ece3dbce02d0f4fe5cebf8abd2918cb13c Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 18 Oct 2019 15:09:06 +0200 Subject: [PATCH 30/32] Update src/pretix/control/templates/pretixcontrol/organizers/giftcards.html Co-Authored-By: Martin Gross --- .../control/templates/pretixcontrol/organizers/giftcards.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html index 4d280a66f6..3789ed9516 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -102,7 +102,7 @@ {% endfor %} {% if other_organizers %}
  • - {% for o in other_organizers %} From 8fe9b35dea79de9e067c550491edc2b47c76b44c Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 18 Oct 2019 15:11:53 +0200 Subject: [PATCH 31/32] Add more tests --- doc/api/resources/orders.rst | 2 + src/pretix/base/payment.py | 12 +- src/pretix/base/services/orders.py | 23 ++- .../pretixcontrol/organizers/giftcards.html | 3 +- .../pretixpresale/event/checkout_payment.html | 1 - src/tests/base/test_orders.py | 177 ++++++++++++++++++ src/tests/presale/test_checkout.py | 29 ++- src/tests/presale/test_orders.py | 167 ++++++++++++++++- 8 files changed, 393 insertions(+), 21 deletions(-) diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 107e8811b9..4aaf3e18b5 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -769,6 +769,8 @@ Creating orders * does not support file upload questions + * does not support redeeming gift cards + You can supply the following fields of the resource: * ``code`` (optional) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 6f17b19faa..6895195187 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1044,7 +1044,9 @@ class GiftCardPayment(BasePaymentProvider): messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: - for p in payment.order.positions.all(): + # This method will only be called when retrying payments, e.g. after a payment_prepare call. It is not called + # during the order creation phase because this payment provider is a special case. + for p in payment.order.positions.all(): # noqa - just a safeguard if p.item.issue_giftcard: raise PaymentException(_("You cannot pay with gift cards when buying a gift card.")) @@ -1053,13 +1055,11 @@ class GiftCardPayment(BasePaymentProvider): raise PaymentException("Invalid state, should never occur.") with transaction.atomic(): gc = GiftCard.objects.select_for_update().get(pk=gcpk) - if gc.currency != self.event.currency: + if gc.currency != self.event.currency: # noqa - just a safeguard raise PaymentException(_("This gift card does not support this currency.")) - if not gc.accepted_by(self.event.organizer): + if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard raise PaymentException(_("This gift card is not accepted by this event organizer.")) - if gc.testmode != payment.order.testmode: - raise PaymentException(_("Only the gift card or only the order are created in test mode.")) - if payment.amount > gc.value: + if payment.amount > gc.value: # noqa - just a safeguard raise PaymentException(_("This gift card was used in the meantime. Please try again")) trans = gc.transactions.create( value=-1 * payment.amount, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 49169b0124..76471e1464 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -569,14 +569,12 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid fees += resp total += sum(f.value for f in fees) - summed = 0 gift_card_values = {} for gc in gift_cards: fval = Decimal(gc.value) # TODO: don't require an extra query - fval = min(fval, total - summed) + fval = min(fval, total) if fval > 0: total -= fval - summed += fval gift_card_values[gc] = fval if payment_provider: @@ -967,6 +965,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.'), + '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')) SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) @@ -1034,6 +1033,9 @@ class OrderChangeManager: def change_price(self, position: OrderPosition, price: Decimal): price = position.item.tax(price, base_price_is='gross') + if position.issued_gift_cards.exists(): + raise OrderError(self.error_messages['gift_card_change']) + self._totaldiff += price.gross - position.price if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'): @@ -1710,13 +1712,14 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs): issued = Decimal('0.00') for gc in p.issued_gift_cards.all(): issued += gc.transactions.first().value - gc = sender.organizer.issued_gift_cards.create( - currency=sender.currency, issued_in=p, testmode=order.testmode - ) - gc.transactions.create(value=p.price - issued, order=order) - any_giftcards = True - p.secret = gc.secret - p.save(update_fields=['secret']) + if p.price - issued > 0: + gc = sender.organizer.issued_gift_cards.create( + currency=sender.currency, issued_in=p, testmode=order.testmode + ) + gc.transactions.create(value=p.price - issued, order=order) + any_giftcards = True + p.secret = gc.secret + p.save(update_fields=['secret']) if any_giftcards: tickets.invalidate_cache.apply_async(kwargs={'event': sender.pk, 'order': order.pk}) diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html index 3789ed9516..7445310505 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -52,8 +52,7 @@
  • - {{ g.secret }} - + {{ g.secret }} {% if g.testmode %} {% trans "TEST MODE" %} {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index 21d10ee536..43b26a5940 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -11,7 +11,6 @@
    {% csrf_token %}
    - {# TODO: make this proper #} {% for p in providers %}