diff --git a/doc/api/resources/giftcards.rst b/doc/api/resources/giftcards.rst
new file mode 100644
index 0000000000..0d441a3af8
--- /dev/null
+++ b/doc/api/resources/giftcards.rst
@@ -0,0 +1,229 @@
+.. _`rest-giftcards`:
+
+Gift cards
+==========
+
+Resource description
+--------------------
+
+The gift card resource contains the following public fields:
+
+.. rst-class:: rest-resource-table
+
+===================================== ========================== =======================================================
+Field Type Description
+===================================== ========================== =======================================================
+id integer Internal ID of the gift card
+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
+---------
+
+.. http:get:: /api/v1/organizers/(organizer)/giftcards/
+
+ Returns a list of all gift cards issued by a given organizer.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "id": 1,
+ "secret": "HLBYVELFRC77NCQY",
+ "currency": "EUR",
+ "testmode": false,
+ "value": "13.37"
+ }
+ ]
+ }
+
+ :query integer page: The page number in case of a multi-page result set, default is 1
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
+
+.. http:get:: /api/v1/organizers/(organizer)/giftcards/(id)/
+
+ Returns information on one gift card, identified by its ID.
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ GET /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 1,
+ "secret": "HLBYVELFRC77NCQY",
+ "currency": "EUR",
+ "testmode": false,
+ "value": "13.37"
+ }
+
+ :param organizer: The ``slug`` field of the organizer to fetch
+ :param id: The ``id`` field of the gift card to fetch
+ :statuscode 200: no error
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
+
+.. http:post:: /api/v1/organizers/(organizer)/giftcards/
+
+ Creates a new gift card
+
+ **Example request**:
+
+ .. sourcecode:: http
+
+ POST /api/v1/organizers/bigevents/giftcards/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+ Content-Type: application/json
+
+ {
+ "secret": "HLBYVELFRC77NCQY",
+ "currency": "EUR",
+ "value": "13.37"
+ }
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 201 Created
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 1,
+ "secret": "HLBYVELFRC77NCQY",
+ "testmode": false,
+ "currency": "EUR",
+ "value": "13.37"
+ }
+
+ :param organizer: The ``slug`` field of the organizer to create a gift card for
+ :statuscode 201: no error
+ :statuscode 400: The gift card could not be created due to invalid submitted data.
+ :statuscode 401: Authentication failure
+ :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
+
+.. http:patch:: /api/v1/organizers/(organizer)/giftcards/(id)/
+
+ Update a gift card. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
+ 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``, ``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**:
+
+ .. sourcecode:: http
+
+ PATCH /api/v1/organizers/bigevents/giftcards/1/ HTTP/1.1
+ Host: pretix.eu
+ Accept: application/json, text/javascript
+ Content-Type: application/json
+ Content-Length: 94
+
+ {
+ "value": "14.00"
+ }
+
+ **Example response**:
+
+ .. sourcecode:: http
+
+ HTTP/1.1 200 OK
+ Vary: Accept
+ Content-Type: application/json
+
+ {
+ "id": 1,
+ "secret": "HLBYVELFRC77NCQY",
+ "testmode": false,
+ "currency": "EUR",
+ "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",
+ "testmode": false,
+ "value": "15.37"
+ }
+
+ :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.
diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst
index 0173f0cdfe..0ac952416a 100644
--- a/doc/api/resources/index.rst
+++ b/doc/api/resources/index.rst
@@ -21,6 +21,7 @@ Resources and endpoints
vouchers
checkinlists
waitinglist
+ giftcards
carts
webhooks
seatingplans
diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst
index ab53542bb7..617adc8824 100644
--- a/doc/api/resources/items.rst
+++ b/doc/api/resources/items.rst
@@ -77,6 +77,7 @@ generate_tickets boolean If ``false``, t
rules apply.
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out.
+issue_giftcard boolean If ``true``, buying this product will yield a gift card.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations.
@@ -206,6 +207,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
+ "issue_giftcard": false,
"position": 0,
"picture": null,
"available_from": null,
@@ -300,6 +302,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
+ "issue_giftcard": false,
"position": 0,
"picture": null,
"available_from": null,
@@ -375,6 +378,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
+ "issue_giftcard": false,
"position": 0,
"picture": null,
"available_from": null,
@@ -437,6 +441,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
+ "issue_giftcard": false,
"position": 0,
"picture": null,
"available_from": null,
@@ -531,6 +536,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
+ "issue_giftcard": false,
"position": 0,
"picture": null,
"available_from": null,
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/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
diff --git a/doc/user/events/giftcards.rst b/doc/user/events/giftcards.rst
new file mode 100644
index 0000000000..6e1f833414
--- /dev/null
+++ b/doc/user/events/giftcards.rst
@@ -0,0 +1,71 @@
+.. spelling::
+
+ Warengutschein
+ Wertgutschein
+
+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. Vouchers in pretix are bound to a specific event.
+
+* 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/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/api/serializers/item.py b/src/pretix/api/serializers/item.py
index 51e676ed1d..b28207ae22 100644
--- a/src/pretix/api/serializers/item.py
+++ b/src/pretix/api/serializers/item.py
@@ -119,7 +119,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
- 'show_quota_left', 'hidden_if_available', 'allow_waitinglist')
+ 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard')
read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self):
@@ -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/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py
index be61a0bfd8..622179d601 100644
--- a/src/pretix/api/serializers/organizer.py
+++ b/src/pretix/api/serializers/organizer.py
@@ -1,6 +1,11 @@
+from django.db.models import Q
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import serializers
+from rest_framework.exceptions import ValidationError
+
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
-from pretix.base.models import Organizer, SeatingPlan
+from pretix.base.models import GiftCard, Organizer, SeatingPlan
from pretix.base.models.seating import SeatingPlanLayoutValidator
@@ -18,3 +23,27 @@ class SeatingPlanSerializer(I18nAwareModelSerializer):
class Meta:
model = SeatingPlan
fields = ('id', 'name', 'layout')
+
+
+class GiftCardSerializer(I18nAwareModelSerializer):
+ value = serializers.DecimalField(max_digits=10, decimal_places=2)
+
+ def validate(self, data):
+ data = super().validate(data)
+ s = data['secret']
+ qs = GiftCard.objects.filter(
+ secret=s
+ ).filter(
+ Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"])
+ )
+ if self.instance:
+ qs = qs.exclude(pk=self.instance.pk)
+ if qs.exists():
+ raise ValidationError(
+ {'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')}
+ )
+ return data
+
+ class Meta:
+ model = GiftCard
+ fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode')
diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py
index 4397688d4a..917b2fe973 100644
--- a/src/pretix/api/urls.py
+++ b/src/pretix/api/urls.py
@@ -19,6 +19,7 @@ orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
+orga_router.register(r'giftcards', organizer.GiftCardViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet)
diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py
index db6b5bdfb9..4f3f676e44 100644
--- a/src/pretix/api/views/organizer.py
+++ b/src/pretix/api/views/organizer.py
@@ -1,11 +1,14 @@
-from rest_framework import filters, viewsets
-from rest_framework.exceptions import PermissionDenied
+from django.db import transaction
+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 (
- OrganizerSerializer, SeatingPlanSerializer,
+ GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer,
)
-from pretix.base.models import Organizer, SeatingPlan
+from pretix.base.models import GiftCard, Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts
@@ -81,3 +84,66 @@ class SeatingPlanViewSet(viewsets.ModelViewSet):
data={'id': instance.pk}
)
instance.delete()
+
+
+class GiftCardViewSet(viewsets.ModelViewSet):
+ serializer_class = GiftCardSerializer
+ queryset = GiftCard.objects.none()
+ permission = 'can_manage_gift_cards'
+ write_permission = 'can_manage_gift_cards'
+
+ def get_queryset(self):
+ return self.request.organizer.issued_gift_cards.all()
+
+ def get_serializer_context(self):
+ ctx = super().get_serializer_context()
+ ctx['organizer'] = self.request.organizer
+ return ctx
+
+ @transaction.atomic()
+ def perform_create(self, serializer):
+ value = serializer.validated_data.pop('value')
+ inst = serializer.save(issuer=self.request.organizer)
+ inst.transactions.create(value=value)
+ inst.log_action(
+ 'pretix.giftcards.transaction.manual',
+ user=self.request.user,
+ auth=self.request.auth,
+ data=merge_dicts(self.request.data, {'id': inst.pk})
+ )
+
+ @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,
+ testmode=serializer.instance.testmode)
+ diff = value - old_value
+ inst.transactions.create(value=diff)
+ inst.log_action(
+ 'pretix.giftcards.transaction.manual',
+ user=self.request.user,
+ auth=self.request.auth,
+ data={'value': diff}
+ )
+ 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/migrations/0138_auto_20191017_1151.py b/src/pretix/base/migrations/0138_auto_20191017_1151.py
new file mode 100644
index 0000000000..09c365faf5
--- /dev/null
+++ b/src/pretix/base/migrations/0138_auto_20191017_1151.py
@@ -0,0 +1,107 @@
+# 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')),
+ ('testmode', django.db.models.BooleanField(default=False)),
+ ],
+ 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
+ ),
+ ]
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/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/base/models/event.py b/src/pretix/base/models/event.py
index b47cd32b54..55ccff0ecd 100644
--- a/src/pretix/base/models/event.py
+++ b/src/pretix/base/models/event.py
@@ -730,7 +730,7 @@ class Event(EventMixin, LoggedModel):
def has_payment_provider(self):
result = False
for provider in self.get_payment_providers().values():
- if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'):
+ if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting', 'giftcard'):
result = True
break
return result
diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py
new file mode 100644
index 0000000000..424167a720
--- /dev/null
+++ b/src/pretix/base/models/giftcards.py
@@ -0,0 +1,112 @@
+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
+from django.utils.translation import ugettext_lazy as _
+
+from pretix.base.banlist import banned
+from pretix.base.models import LoggedModel
+
+
+def gen_giftcard_secret():
+ charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
+ while True:
+ code = get_random_string(length=settings.ENTROPY['giftcard_secret'], allowed_chars=charset)
+ if not banned(code) and 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(LoggedModel):
+ 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,
+ null=True, blank=True
+ )
+ issuance = models.DateTimeField(
+ auto_now_add=True,
+ )
+ secret = models.CharField(
+ max_length=190,
+ default=gen_giftcard_secret,
+ 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)
+
+ def __str__(self):
+ return self.secret
+
+ @property
+ 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'),)
+
+
+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
+ )
+
+ class Meta:
+ ordering = ("datetime",)
diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py
index e68572eb2b..31c43522b3 100644
--- a/src/pretix/base/models/items.py
+++ b/src/pretix/base/models/items.py
@@ -242,6 +242,8 @@ class Item(LoggedModel):
:type require_approval: bool
:param sales_channels: Sales channels this item is available on.
:type sales_channels: bool
+ :param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price
+ :type issue_giftcard: bool
"""
objects = ItemQuerySetManager()
@@ -413,6 +415,12 @@ class Item(LoggedModel):
verbose_name=_('Sales channels'),
default=['web']
)
+ issue_giftcard = models.BooleanField(
+ verbose_name=_('This product is a gift card'),
+ help_text=_('When a customer buys this product, they will get a gift card with a value corresponding to the '
+ 'product price.'),
+ default=False
+ )
# !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/forms/item.py if applicable.
diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py
index 7fa5c11e22..6799ad34dd 100644
--- a/src/pretix/base/models/orders.py
+++ b/src/pretix/base/models/orders.py
@@ -202,7 +202,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.")
@@ -218,6 +218,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()
@@ -460,11 +464,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/models/organizer.py b/src/pretix/base/models/organizer.py
index 5ca7ca8be3..e351ea43d4 100644
--- a/src/pretix/base/models/organizer.py
+++ b/src/pretix/base/models/organizer.py
@@ -2,6 +2,7 @@ import string
from django.core.validators import RegexValidator
from django.db import models
+from django.db.models import Exists, OuterRef, Q
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
@@ -82,6 +83,24 @@ class Organizer(LoggedModel):
return ObjectRelatedCache(self)
+ @property
+ def has_gift_cards(self):
+ return self.cache.get_or_set(
+ key='has_gift_cards',
+ timeout=15,
+ default=lambda: self.issued_gift_cards.exists() or self.gift_card_issuer_acceptance.exists()
+ )
+
+ @property
+ def accepted_gift_cards(self):
+ from .giftcards import GiftCard, GiftCardAcceptance
+
+ return GiftCard.objects.annotate(
+ accepted=Exists(GiftCardAcceptance.objects.filter(issuer=OuterRef('issuer'), collector=self))
+ ).filter(
+ Q(issuer=self) | Q(accepted=True)
+ )
+
def allow_delete(self):
from . import Order, Invoice
return (
@@ -156,6 +175,10 @@ class Team(LoggedModel):
help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy '
'reports, so be careful who you add to this team!')
)
+ can_manage_gift_cards = models.BooleanField(
+ default=False,
+ verbose_name=_("Can manage gift cards")
+ )
can_change_event_settings = models.BooleanField(
default=False,
diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py
index fd7ba9dba9..6895195187 100644
--- a/src/pretix/base/payment.py
+++ b/src/pretix/base/payment.py
@@ -7,7 +7,9 @@ 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
from django.forms import Form
from django.http import HttpRequest
@@ -20,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
@@ -29,7 +31,8 @@ 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.presale.views import get_cart_total
+from pretix.multidomain.urlreverse import eventreverse
+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__)
@@ -888,6 +891,206 @@ class OffsettingProvider(BasePaymentProvider):
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
+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
+
+ @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
+
+ def order_change_allowed(self, order: Order) -> bool:
+ 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
+
+ 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)
+
+ def api_payment_details(self, payment: OrderPayment):
+ from .models import GiftCard
+ gc = GiftCard.objects.get(pk=payment.info_data.get('gift_card'))
+ return {
+ 'gift_card': {
+ 'id': gc.pk,
+ 'secret': gc.secret,
+ 'organizer': gc.issuer.slug
+ }
+ }
+
+ def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
+ return True
+
+ def payment_refund_supported(self, payment: OrderPayment) -> bool:
+ 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(
+ 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.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
+ 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:
+ 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 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")
+ )
+ if gc.currency != self.event.currency:
+ messages.error(request, _("This gift card does not support this currency."))
+ return
+ 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 payment.order.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
+ 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:
+ # 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."))
+
+ 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: # noqa - just a safeguard
+ raise PaymentException(_("This gift card does not support this currency."))
+ 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 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,
+ 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
+ gc = GiftCard.objects.get(pk=refund.payment.info_data.get('gift_card'))
+ trans = gc.transactions.create(
+ value=refund.amount,
+ order=refund.order,
+ refund=refund
+ )
+ refund.info_data = {
+ 'gift_card': gc.pk,
+ 'transaction_id': trans.pk,
+ }
+ refund.done()
+
+
@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..a8146e5706 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."),
}
@@ -958,14 +959,41 @@ 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'):
+ 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:
+ 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()
+ ))
+ cs['gift_cards'] = gcs
+
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..76471e1464 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
@@ -43,8 +44,8 @@ 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_placed,
- order_split, periodic_task, validate_order,
+ 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
@@ -291,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():
@@ -545,7 +559,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 +567,16 @@ 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)
+
+ gift_card_values = {}
+ for gc in gift_cards:
+ fval = Decimal(gc.value) # TODO: don't require an extra query
+ fval = min(fval, total)
+ if fval > 0:
+ total -= fval
+ gift_card_values[gc] = fval
+
if payment_provider:
payment_fee = payment_provider.calculate_fee(total)
else:
@@ -565,17 +587,34 @@ 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,
+ shown_total=None):
p = None
-
with transaction.atomic():
+ checked_gift_cards = []
+ if gift_cards:
+ gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards)
+ 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)
+ 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])
+
order = Order(
status=Order.STATUS_PENDING,
event=event,
@@ -606,11 +645,41 @@ 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
+
+ # 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,
provider=payment_provider.identifier,
- amount=total,
+ amount=pending_sum,
fee=pf
)
@@ -658,7 +727,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, shown_total=None):
if payment_provider:
pprov = event.get_payment_providers().get(payment_provider)
if not pprov:
@@ -707,9 +777,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, shown_total=shown_total)
- 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)
@@ -894,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'))
@@ -961,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'):
@@ -1188,6 +1263,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,
@@ -1466,12 +1552,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, shown_total=None):
with language(locale):
try:
try:
return _perform_order(event, payment_provider, positions, email, locale, address, meta_info,
- sales_channel)
+ sales_channel, gift_cards, shown_total)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):
@@ -1615,3 +1701,25 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
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):
+ 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
+ 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/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/forms/filter.py b/src/pretix/control/forms/filter.py
index a1c87926a5..3f103f23cc 100644
--- a/src/pretix/control/forms/filter.py
+++ b/src/pretix/control/forms/filter.py
@@ -502,6 +502,29 @@ class OrganizerFilterForm(FilterForm):
return qs
+class GiftCardFilterForm(FilterForm):
+ query = forms.CharField(
+ label=_('Search query'),
+ widget=forms.TextInput(attrs={
+ 'placeholder': _('Search query'),
+ 'autofocus': 'autofocus'
+ }),
+ required=False
+ )
+
+ def __init__(self, *args, **kwargs):
+ kwargs.pop('request')
+ super().__init__(*args, **kwargs)
+
+ def filter_qs(self, qs):
+ fdata = self.cleaned_data
+
+ if fdata.get('query'):
+ query = fdata.get('query')
+ qs = qs.filter(secret__icontains=query)
+ return qs
+
+
class EventFilterForm(FilterForm):
orders = {
'slug': 'slug',
diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py
index 77ec131607..21e30a61dd 100644
--- a/src/pretix/control/forms/item.py
+++ b/src/pretix/control/forms/item.py
@@ -421,6 +421,23 @@ class ItemUpdateForm(I18nModelForm):
self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices
self.fields['hidden_if_available'].required = False
+ def clean(self):
+ d = super().clean()
+ if d['issue_giftcard']:
+ if d['tax_rule'] and d['tax_rule'].rate > 0:
+ self.add_error(
+ '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:
model = Item
localized_fields = '__all__'
@@ -451,6 +468,7 @@ class ItemUpdateForm(I18nModelForm):
'require_bundling',
'show_quota_left',
'hidden_if_available',
+ 'issue_giftcard',
]
field_classes = {
'available_from': SplitDateTimeField,
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index 8f7c965160..4424582699 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -4,6 +4,7 @@ from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
+from django.db.models import Q
from django.utils.safestring import mark_safe
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField
@@ -12,7 +13,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm
-from pretix.base.models import Device, Organizer, Team
+from pretix.base.models import Device, GiftCard, Organizer, Team
from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget,
)
@@ -145,6 +146,7 @@ class TeamForm(forms.ModelForm):
model = Team
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
+ 'can_manage_gift_cards',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders',
'can_view_vouchers', 'can_change_vouchers']
@@ -328,3 +330,29 @@ class WebHookForm(forms.ModelForm):
field_classes = {
'limit_events': SafeModelMultipleChoiceField
}
+
+
+class GiftCardCreateForm(forms.ModelForm):
+ value = forms.DecimalField(
+ label=_('Gift card value')
+ )
+
+ def __init__(self, *args, **kwargs):
+ self.organizer = kwargs.pop('organizer')
+ super().__init__(*args, **kwargs)
+
+ def clean_secret(self):
+ s = self.cleaned_data['secret']
+ if GiftCard.objects.filter(
+ secret__iexact=s
+ ).filter(
+ Q(issuer=self.organizer) | Q(issuer__gift_card_collector_acceptance__collector=self.organizer)
+ ).exists():
+ raise ValidationError(
+ _('A gift card with the same secret already exists in your or an affiliated organizer account.')
+ )
+ return s
+
+ class Meta:
+ model = GiftCard
+ fields = ['secret', 'currency', 'testmode']
diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py
index 8428741693..a7e4d13df4 100644
--- a/src/pretix/control/navigation.py
+++ b/src/pretix/control/navigation.py
@@ -437,6 +437,16 @@ def get_organizer_navigation(request):
'active': 'organizer.device' in url.url_name,
'icon': 'tablet',
})
+ if 'can_manage_gift_cards' in request.orgapermset:
+ nav.append({
+ 'label': _('Gift cards'),
+ 'url': reverse('control:organizer.giftcards', kwargs={
+ 'organizer': request.organizer.slug
+ }),
+ 'active': 'organizer.giftcard' in url.url_name,
+ 'icon': 'credit-card',
+ })
+ if 'can_change_organizer_settings' in request.orgapermset:
nav.append({
'label': _('Webhooks'),
'url': reverse('control:organizer.webhooks', kwargs={
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 %}
+
+ {% 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 %}
+