diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index e09b8e09d..c0171b48a 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -117,6 +117,8 @@ cancellation_date datetime Time of order c reliable for orders that have been cancelled, reactivated and cancelled again. plugin_data object Additional data added by plugins. +use_gift_cards list of strings List of unique gift card secrets that are used to pay + for this order. ===================================== ========================== ======================================================= @@ -156,6 +158,10 @@ plugin_data object Additional data The ``tax_rounding_mode`` attribute has been added. +.. versionchanged:: 2026.03 + + The ``use_gift_cards `` attribute has been added. + .. _order-position-resource: Order position resource @@ -987,8 +993,6 @@ Creating orders * does not support file upload questions - * does not support redeeming gift cards - * does not support or validate memberships @@ -1095,6 +1099,14 @@ Creating orders whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix' settings based on the sales channels (added in pretix 4.7). Defaults to ``false``. Used to be ``send_mail`` before pretix 3.14. + * ``use_gift_cards`` (optional) The provided gift cards will be used to pay for this order. They will be debited and + all the necessary payment records for these transactions will be created. The gift cards will be used in sequence to + pay for the order. Processing of the gift cards stops as soon as the order is payed for. All gift card transactions + are listed under ``payments`` in the response. + This option can only be used with orders that are in the pending state. + The ``use_gift_cards`` attribute can not be combined with ``payment_info`` and ``payment_provider`` fields. If the + order isn't completely paid after its creation with ``use_gift_cards``, then a subsequent request to the payment + endpoint is needed. If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually to incrementing integers starting with ``1``. Then, you can reference one of these diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 3f6b0405a..b88cf2a42 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -53,7 +53,7 @@ from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.models import ( - CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress, + CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher, @@ -62,6 +62,7 @@ from pretix.base.models.orders import ( BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, PrintLog, RevokedTicketSecret, Transaction, ) +from pretix.base.payment import GiftCardPayment, PaymentException from pretix.base.pdf import get_images, get_variables from pretix.base.services.cart import error_messages from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects @@ -1200,6 +1201,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): ) tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,) locale = serializers.ChoiceField(choices=[], required=False, allow_null=True) + use_gift_cards = serializers.ListField(child=serializers.CharField(required=False), required=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1215,7 +1217,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', 'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date', 'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at', - 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode') + 'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards') def validate_payment_provider(self, pp): if pp is None: @@ -1310,6 +1312,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer): payment_date = validated_data.pop('payment_date', now()) force = validated_data.pop('force', False) simulate = validated_data.pop('simulate', False) + gift_card_secrets = validated_data.pop('use_gift_cards') if 'use_gift_cards' in validated_data else [] + + if (payment_provider is not None or payment_info != '{}') and len(gift_card_secrets) > 0: + raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is not compatible with payment_provider or payment_info']}) + if validated_data.get('status') != Order.STATUS_PENDING and len(gift_card_secrets) > 0: + raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is only supported for orders that are created as pending']}) + if len(set(gift_card_secrets)) != len(gift_card_secrets): + raise ValidationError({"use_gift_cards": ['Multiple copies of the same gift card secret are not allowed']}) if not validated_data.get("sales_channel"): validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web") @@ -1794,6 +1804,45 @@ class OrderCreateSerializer(I18nAwareModelSerializer): if order.total != Decimal('0.00') and order.event.currency == "XXX": raise ValidationError('Paid products not supported without a valid currency.') + for gift_card_secret in gift_card_secrets: + try: + if order.status != Order.STATUS_PAID: + gift_card_payment_provider = GiftCardPayment(event=order.event) + + gc = order.event.organizer.accepted_gift_cards.get( + secret=gift_card_secret + ) + + payment = order.payments.create( + amount=min(order.pending_sum, gc.value), + provider=gift_card_payment_provider.identifier, + info_data={ + 'gift_card': gc.pk, + 'gift_card_secret': gc.secret, + 'retry': True + }, + state=OrderPayment.PAYMENT_STATE_CREATED + ) + gift_card_payment_provider.execute_payment(request=None, payment=payment, is_early_special_case=True) + + if order.pending_sum <= Decimal('0.00'): + order.status = Order.STATUS_PAID + + except PaymentException: + pass + + except GiftCard.DoesNotExist as e: + payment = order.payments.create( + amount=order.pending_sum, + provider=GiftCardPayment.identifier, + info_data={ + 'gift_card_secret': gift_card_secret, + }, + state=OrderPayment.PAYMENT_STATE_CREATED + ) + payment.fail(info={**payment.info_data, 'error': str(e)}, + send_mail=False) + if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'): order.status = Order.STATUS_PAID order.save() diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index e7ad88cbc..ddd42318f 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1526,16 +1526,26 @@ class GiftCardPayment(BasePaymentProvider): def payment_control_render(self, request, payment) -> str: from .models import GiftCard - if 'gift_card' in payment.info_data: - gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card')) + if any(key in payment.info_data for key in ('gift_card', 'error')): template = get_template('pretixcontrol/giftcards/payment.html') - ctx = { 'request': request, 'event': self.event, - 'gc': gc, + **({'error': payment.info_data[ + 'error']} if 'error' in payment.info_data else {}), + **({'gift_card_secret': payment.info_data[ + 'gift_card_secret']} if 'gift_card_secret' in payment.info_data else {}) } - return template.render(ctx) + + try: + gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card')) + ctx = { + 'gc': gc, + } + except GiftCard.DoesNotExist: + pass + finally: + return template.render(ctx) def payment_control_render_short(self, payment: OrderPayment) -> str: d = payment.info_data @@ -1550,12 +1560,16 @@ class GiftCardPayment(BasePaymentProvider): try: gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card')) except GiftCard.DoesNotExist: - return {} + return { + **({'error': payment.info_data[ + 'error']} if 'error' in payment.info_data else {}) + } return { 'gift_card': { 'id': gc.pk, 'secret': gc.secret, - 'organizer': gc.issuer.slug + 'organizer': gc.issuer.slug, + ** ({'error': payment.info_data['error']} if 'error' in payment.info_data else {}) } } @@ -1627,6 +1641,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.value <= Decimal("0.00"): + raise PaymentException(_("All credit on this gift card has been used.")) if payment.amount > gc.value: raise PaymentException(_("This gift card was used in the meantime. Please try again.")) if gc.testmode and not payment.order.testmode: @@ -1656,7 +1672,7 @@ class GiftCardPayment(BasePaymentProvider): } ) except PaymentException as e: - payment.fail(info={'error': str(e)}) + payment.fail(info={**payment.info_data, 'error': str(e)}, send_mail=not is_early_special_case) raise e def payment_is_valid_session(self, request: HttpRequest) -> bool: diff --git a/src/pretix/control/templates/pretixcontrol/giftcards/payment.html b/src/pretix/control/templates/pretixcontrol/giftcards/payment.html index d66803a61..bdde92e04 100644 --- a/src/pretix/control/templates/pretixcontrol/giftcards/payment.html +++ b/src/pretix/control/templates/pretixcontrol/giftcards/payment.html @@ -3,16 +3,26 @@
{% trans "Gift card code" %}
- - {{ gc.secret }} - - {% if gc.issuer != request.organizer %} - -
- {{ gc.issuer }} -
+ {% if gc %} + + {{ gc.secret }} + + {% if gc.issuer.slug != request.organizer %} + +
+ {{ gc.issuer }} +
+ {% endif %} + {% elif gift_card_secret %} + {{ gift_card_secret }} {% endif %}
-
{% trans "Issuer" %}
-
{{ gc.issuer }}
+ {% if gc %} +
{% trans "Issuer" %}
+
{{ gc.issuer }}
+ {% endif %} + {% if error %} +
{% trans "Error" %}
+
{{ error }}
+ {% endif %}
diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index e69b1afa7..fe0377946 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -35,8 +35,8 @@ from django_scopes import scopes_disabled from tests.const import SAMPLE_PNG from pretix.base.models import ( - InvoiceAddress, Item, Order, OrderPosition, Organizer, Question, - SeatingPlan, + GiftCard, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, + Organizer, Question, SeatingPlan, ) from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer @@ -3371,3 +3371,251 @@ def test_order_create_rounding_default_pretixpos_fallback(device, device_client, assert resp.data["total"] == "500.00" assert resp.data["positions"][0]["price"] == "100.00" assert resp.data["positions"][-1]["price"] == "100.00" + + +@pytest.mark.parametrize( + "order_status,status_code", + [ + ( + Order.STATUS_PENDING, 201 + ), + ( + Order.STATUS_PAID, 400 + ), + ], +) +@pytest.mark.django_db +def test_order_create_use_gift_cards_only_pending(token_client, organizer, event, item, quota, question, order_status, status_code): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + res['customer'] = customer.identifier + res['api_meta'] = { + 'test': 1 + } + + gc = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc.transactions.create(value=Decimal("100.00"), acceptor=organizer).save() + + res['status'] = order_status + del res['payment_provider'] + res['use_gift_cards'] = [gc.secret] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == status_code + if status_code != 201: + assert resp.data == {'use_gift_cards': ['The attribute use_gift_cards is only supported for orders that are created as pending']} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "send_mail,mail_amount", + [ + ( + False, 0 + ), + ( + True, 2 + ), + ], +) +def test_order_create_use_gift_card(token_client, organizer, event, item, quota, question, send_mail, mail_amount): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + + res['customer'] = customer.identifier + res['api_meta'] = { + 'test': 1 + } + + if send_mail: + res['send_email'] = True + + gc = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc.transactions.create(value=Decimal("100.00"), acceptor=organizer).save() + del res['payment_provider'] + res['use_gift_cards'] = [gc.secret] + + djmail.outbox = [] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.status == Order.STATUS_PAID + + assert gc.transactions.count() == 2 + assert -gc.transactions.last().value == o.total + + assert len(djmail.outbox) == mail_amount + + +@pytest.mark.django_db +def test_order_create_use_multiple_gift_cards(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + res['customer'] = customer.identifier + res['api_meta'] = { + 'test': 1 + } + del res['payment_provider'] + + gc_one_eur = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc_one_eur.transactions.create(value=Decimal("1.00"), acceptor=organizer).save() + + gc_empty = GiftCard.objects.create(issuer=organizer, currency='EUR') + + gc_wrong_currency = GiftCard.objects.create(issuer=organizer, currency='USD') + gc_wrong_currency.transactions.create(value=Decimal("100.00"), acceptor=organizer).save() + + gc_enough_eur = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc_enough_eur.transactions.create(value=Decimal("100.00"), acceptor=organizer).save() + + res['use_gift_cards'] = [gc_one_eur.secret, gc_empty.secret, gc_wrong_currency.secret, gc_enough_eur.secret] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + # order has a payment entry per giftcard + assert o.status == Order.STATUS_PAID + assert o.payments.count() == 4 + + assert gc_one_eur.transactions.count() == 2 # +1€ charge and -1€ payment + assert o.payments.all()[0].state == OrderPayment.PAYMENT_STATE_CONFIRMED + assert Decimal(-1.00) == gc_one_eur.transactions.last().value + + assert gc_empty.transactions.count() == 0 # no charge and no payment transaction + assert o.payments.all()[1].state == OrderPayment.PAYMENT_STATE_FAILED + + assert gc_wrong_currency.transactions.count() == 1 # charge transaction + assert o.payments.all()[2].state == OrderPayment.PAYMENT_STATE_FAILED + + assert gc_enough_eur.transactions.count() == 2 # +100€ charge and -remainder € payment + assert o.payments.all()[3].state == OrderPayment.PAYMENT_STATE_CONFIRMED + assert -(o.total - Decimal(1.00)) == gc_enough_eur.transactions.last().value + + +@pytest.mark.django_db +def test_order_create_use_gift_card_exclusive_with_payment_provider(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + res['customer'] = customer.identifier + res['api_meta'] = { + 'test': 1 + } + gc_value = Decimal("1.00") + gc = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc.transactions.create(value=gc_value, acceptor=organizer).save() + + res['use_gift_cards'] = [gc.secret] + + res_with_payment_provider = copy.deepcopy(res) + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res_with_payment_provider + ) + assert resp.status_code == 400 + assert resp.json() == {"use_gift_cards": ["The attribute use_gift_cards is not compatible with payment_provider or payment_info"]} + + res_with_payment_info = copy.deepcopy(res) + res_with_payment_info['payment_info'] = {"a": "b"} + del res_with_payment_info['payment_provider'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res_with_payment_info + ) + assert resp.status_code == 400 + assert resp.json() == {"use_gift_cards": ["The attribute use_gift_cards is not compatible with payment_provider or payment_info"]} + + +@pytest.mark.django_db +def test_order_create_use_gift_card_repeated(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + res['customer'] = customer.identifier + res['api_meta'] = { + 'test': 1 + } + del res['payment_provider'] + + gc_one_eur = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc_one_eur.transactions.create(value=Decimal("1.00"), acceptor=organizer).save() + + gc_enough_eur = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc_enough_eur.transactions.create(value=Decimal("100.00"), acceptor=organizer).save() + + res['use_gift_cards'] = [gc_one_eur.secret, gc_one_eur.secret, gc_enough_eur.secret] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.json() == {'use_gift_cards': ['Multiple copies of the same gift card secret are not allowed']} + + +@pytest.mark.django_db +def test_order_create_use_gift_card_invalid_secret(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + with scopes_disabled(): + customer = organizer.customers.create() + + res['customer'] = customer.identifier + res['api_meta'] = { + 'test': 1 + } + del res['payment_provider'] + + gc_enough_eur = GiftCard.objects.create(issuer=organizer, currency='EUR') + gc_enough_eur.transactions.create(value=Decimal("100.00"), + acceptor=organizer).save() + + res['use_gift_cards'] = ["INVALID", gc_enough_eur.secret] + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + assert o.status == Order.STATUS_PAID + assert o.payments.count() == 2 + assert o.payments.all()[0].state == OrderPayment.PAYMENT_STATE_FAILED + assert o.payments.all()[1].state == OrderPayment.PAYMENT_STATE_CONFIRMED