Compare commits

..

2 Commits

Author SHA1 Message Date
Lukas Bockstaller
ba8dbad733 implement giftcard payment via order create 2026-03-06 16:59:56 +01:00
Lukas Bockstaller
3b76ae48fd adds safeguard to prevent empty giftcard transactions on giftcards of value 0.00 2026-03-06 16:56:15 +01:00
3 changed files with 214 additions and 3 deletions

View File

@@ -62,6 +62,8 @@ from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
PrintLog, RevokedTicketSecret, Transaction,
)
from pretix.base.payment import GiftCardPayment
from pretix.base.payment import 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 +1202,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 +1218,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 +1313,10 @@ 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 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 not validated_data.get("sales_channel"):
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
@@ -1794,6 +1801,33 @@ 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=not self._send_mail)
if order.pending_sum <= Decimal('0.00'):
order.status = Order.STATUS_PAID
except PaymentException:
pass
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()
@@ -1817,7 +1851,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
)
elif payment_provider:
order.payments.create(
amount=order.total,
amount=order.pending_sum,
provider=payment_provider,
info=payment_info,
state=OrderPayment.PAYMENT_STATE_CREATED

View File

@@ -1626,6 +1626,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:

View File

@@ -32,11 +32,13 @@ from django.core.files.base import ContentFile
from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scopes_disabled
from pretix.base.models import OrderPayment
from tests.const import SAMPLE_PNG
from pretix.base.models import (
InvoiceAddress, Item, Order, OrderPosition, Organizer, Question,
SeatingPlan,
SeatingPlan, GiftCard
)
from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer
@@ -3371,3 +3373,176 @@ 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
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 # TODO check why we get 3 mails, one order receivend and two payments
),
],
)
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()
del res['payment_provider']
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()
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'])
assert o.status == Order.STATUS_PAID
assert o.payments.count() == 4
assert gc_one_eur.transactions.count() == 2
assert o.payments.all()[0].state == OrderPayment.PAYMENT_STATE_CONFIRMED
assert gc_empty.transactions.count() == 0
assert o.payments.all()[1].state == OrderPayment.PAYMENT_STATE_FAILED
assert gc_wrong_currency.transactions.count() == 1
assert o.payments.all()[2].state == OrderPayment.PAYMENT_STATE_FAILED
assert gc_enough_eur.transactions.count() == 2
assert o.payments.all()[3].state == OrderPayment.PAYMENT_STATE_CONFIRMED
@pytest.mark.django_db
def test_order_create_use_gift_card_and_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]
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_PENDING
open_payment = o.payments.last()
assert open_payment.state == OrderPayment.PAYMENT_STATE_CREATED
assert open_payment.amount == o.total-gc_value
assert open_payment.payment_provider.identifier == res['payment_provider']