diff --git a/src/pretix/base/migrations/0292_giftcard_customer.py b/src/pretix/base/migrations/0292_giftcard_customer.py
new file mode 100644
index 0000000000..5ee6b7dd4e
--- /dev/null
+++ b/src/pretix/base/migrations/0292_giftcard_customer.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.19 on 2025-05-19 11:06
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('pretixbase', '0291_alter_logentry_object_id'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='giftcard',
+ name='customer',
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='customer_gift_cards', to='pretixbase.customer'),
+ ),
+ ]
diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py
index 5d94657a42..942ae5918d 100644
--- a/src/pretix/base/models/customers.py
+++ b/src/pretix/base/models/customers.py
@@ -19,6 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# .
#
+from decimal import Decimal
+
import pycountry
from django.conf import settings
from django.contrib.auth.hashers import (
@@ -27,7 +29,11 @@ from django.contrib.auth.hashers import (
from django.core.validators import RegexValidator, URLValidator
from django.db import models
from django.db.models import F, Q
+from django.db.models.aggregates import Sum
+from django.db.models.expressions import OuterRef, Subquery
+from django.db.models.functions.comparison import Coalesce
from django.utils.crypto import get_random_string, salted_hmac
+from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField
@@ -36,6 +42,7 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
+from pretix.base.models.giftcards import GiftCardTransaction
from pretix.base.models.organizer import Organizer
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.helpers.countries import FastCountryField
@@ -288,6 +295,19 @@ class Customer(LoggedModel):
organizer=self.organizer,
)
+ def usable_gift_cards(self, used_cards=[]):
+ s = GiftCardTransaction.objects.filter(
+ card=OuterRef('pk')
+ ).order_by().values('card').annotate(s=Sum('value')).values('s')
+ qs = self.customer_gift_cards.annotate(
+ cached_value=Coalesce(Subquery(s), Decimal('0.00')),
+ )
+ ne_qs = qs.filter(
+ Q(expires__isnull=True) | Q(expires__gte=now()),
+ )
+ ex_qs = ne_qs.exclude(id__in=used_cards)
+ return ex_qs.filter(cached_value__gt=0)
+
class AttendeeProfile(models.Model):
customer = models.ForeignKey(
diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py
index d93eba36b3..409bf4a1c1 100644
--- a/src/pretix/base/models/giftcards.py
+++ b/src/pretix/base/models/giftcards.py
@@ -80,6 +80,13 @@ class GiftCard(LoggedModel):
null=True, blank=True,
verbose_name=_('Owned by ticket holder')
)
+ customer = models.ForeignKey(
+ 'Customer',
+ related_name='customer_gift_cards',
+ on_delete=models.PROTECT,
+ null=True, blank=True,
+ verbose_name=_('Owned by customer account')
+ )
issuance = models.DateTimeField(
auto_now_add=True,
)
diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py
index 5487391823..72f127770d 100644
--- a/src/pretix/base/payment.py
+++ b/src/pretix/base/payment.py
@@ -38,6 +38,7 @@ import json
import logging
from collections import OrderedDict
from decimal import ROUND_HALF_UP, Decimal
+from functools import cached_property
from typing import Any, Dict, Union
from zoneinfo import ZoneInfo
@@ -57,8 +58,8 @@ from i18nfield.strings import LazyI18nString
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
from pretix.base.models import (
- CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
- OrderRefund, Quota, TaxRule,
+ CartPosition, Customer, Event, GiftCard, InvoiceAddress, Order,
+ OrderPayment, OrderRefund, Quota, TaxRule,
)
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.settings import SettingsSandbox
@@ -99,6 +100,7 @@ class PaymentProviderForm(Form):
class GiftCardPaymentForm(PaymentProviderForm):
def __init__(self, *args, **kwargs):
+ self.customer_gift_cards = kwargs.pop('customer_gift_cards') if 'customer_gift_cards' in kwargs else None
self.event = kwargs.pop('event')
self.testmode = kwargs.pop('testmode')
self.positions = kwargs.pop('positions')
@@ -1373,6 +1375,31 @@ class GiftCardPayment(BasePaymentProvider):
payment_form_class = GiftCardPaymentForm
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
+ @cached_property
+ def customer_gift_cards(self):
+ if not self.request:
+ return None
+ if not self.used_cards:
+ self.used_cards = []
+ cs = None
+ if 'checkout' in self.request.resolver_match.url_name:
+ cs = cart_session(self.request)
+ customer = getattr(self.request, "customer", None)
+ if customer:
+ return customer.usable_gift_cards(self.used_cards)
+ elif cs and cs.get('customer_mode', 'guest') == 'login':
+ try:
+ customer = self.request.organizer.customers.get(pk=cs["customer"])
+ return customer.usable_gift_cards(self.used_cards)
+ except Customer.DoesNotExist:
+ return None
+
+ def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order = None) -> str:
+ form = self.payment_form(request)
+ template = get_template(self.payment_form_template_name)
+ ctx = {'request': request, 'form': form, 'customer_gift_cards': form.customer_gift_cards, }
+ return template.render(ctx)
+
def payment_form(self, request: HttpRequest) -> Form:
# Unfortunately, in payment_form we do not know if we're in checkout
# or in an existing order. But we need to do the validation logic in the
@@ -1392,8 +1419,12 @@ class GiftCardPayment(BasePaymentProvider):
positions = order.positions.all()
testmode = order.testmode
+ self.request = request
+ self.used_cards = used_cards
+
form = self.payment_form_class(
event=self.event,
+ customer_gift_cards=self.customer_gift_cards,
used_cards=used_cards,
positions=positions,
testmode=testmode,
diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py
index 01be0e2540..bfe817d0fc 100644
--- a/src/pretix/base/services/orders.py
+++ b/src/pretix/base/services/orders.py
@@ -3087,6 +3087,7 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
expires=order.event.organizer.default_gift_card_expiry if giftcard_expires is _unset else giftcard_expires,
conditions=giftcard_conditions,
currency=order.event.currency,
+ customer=order.customer,
testmode=order.testmode
)
giftcard.log_action('pretix.giftcards.created', data={})
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index 8e614a12e7..c8f63914f6 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -731,6 +731,21 @@ class GiftCardCreateForm(forms.ModelForm):
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
+ if self.organizer.settings.customer_accounts:
+ self.fields['customer'].queryset = self.organizer.customers.all()
+ self.fields['customer'].widget = Select2(
+ attrs={
+ 'data-model-select2': 'generic',
+ 'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
+ 'organizer': self.organizer.slug,
+ }),
+ }
+ )
+ self.fields['customer'].widget.choices = self.fields['customer'].choices
+ self.fields['customer'].required = False
+ else:
+ del self.fields['customer']
+
def clean_secret(self):
s = self.cleaned_data['secret']
if GiftCard.objects.filter(
@@ -749,9 +764,10 @@ class GiftCardCreateForm(forms.ModelForm):
class Meta:
model = GiftCard
- fields = ['secret', 'currency', 'testmode', 'expires', 'conditions']
+ fields = ['secret', 'currency', 'testmode', 'expires', 'conditions', 'customer']
field_classes = {
- 'expires': SplitDateTimeField
+ 'expires': SplitDateTimeField,
+ 'customer': SafeModelChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
@@ -762,10 +778,11 @@ class GiftCardCreateForm(forms.ModelForm):
class GiftCardUpdateForm(forms.ModelForm):
class Meta:
model = GiftCard
- fields = ['expires', 'conditions', 'owner_ticket']
+ fields = ['expires', 'conditions', 'owner_ticket', 'customer']
field_classes = {
'expires': SplitDateTimeField,
'owner_ticket': SafeOrderPositionChoiceField,
+ 'customer': SafeModelChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
@@ -788,6 +805,21 @@ class GiftCardUpdateForm(forms.ModelForm):
self.fields['owner_ticket'].widget.choices = self.fields['owner_ticket'].choices
self.fields['owner_ticket'].required = False
+ if organizer.settings.customer_accounts:
+ self.fields['customer'].queryset = organizer.customers.all()
+ self.fields['customer'].widget = Select2(
+ attrs={
+ 'data-model-select2': 'generic',
+ 'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
+ 'organizer': organizer.slug,
+ }),
+ }
+ )
+ self.fields['customer'].widget.choices = self.fields['customer'].choices
+ self.fields['customer'].required = False
+ else:
+ del self.fields['customer']
+
class ReusableMediumUpdateForm(forms.ModelForm):
error_messages = {
diff --git a/src/pretix/control/templates/pretixcontrol/giftcards/checkout.html b/src/pretix/control/templates/pretixcontrol/giftcards/checkout.html
index ca579a36b4..23403ac1e6 100644
--- a/src/pretix/control/templates/pretixcontrol/giftcards/checkout.html
+++ b/src/pretix/control/templates/pretixcontrol/giftcards/checkout.html
@@ -1,7 +1,35 @@
{% load i18n %}
{% load bootstrap3 %}
+{% load money %}
{% load rich_text %}
{{ request.event.settings.payment_giftcard_public_description|rich_text }}
-
+{% if customer_gift_cards %}
+
+ {% trans "Information" %}
+ {% trans "The following gift cards are available in your customer account:" %}
+
+
+{% endif %}
{% bootstrap_form form layout='checkout' %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer.html b/src/pretix/control/templates/pretixcontrol/organizers/customer.html
index b5954d0689..8a86c8f680 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/customer.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/customer.html
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
+{% load icon %}
{% load money %}
{% load static %}
{% block title %}
@@ -278,6 +279,53 @@
{% include "pretixcontrol/pagination.html" %}
+
+
+
+ {% trans "Gift cards" %}
+
+
+
+
+
+ | {% trans "Gift card code" %}
+ | {% trans "Creation date" %}
+ | {% trans "Expiry date" %}
+ | {% trans "Last transaction" %}
+ | {% trans "Current value" %}
+ | |
+
+
+
+ {% for gc in gift_cards %}
+
+ |
+
+ {{ gc.secret }}
+ {% if gc.testmode %}
+ {% trans "TEST MODE" %}
+ {% endif %}
+ {% if gc.expired %}
+ {% trans "Expired" %}
+ {% endif %}
+ |
+ {{ gc.issuance|date:"SHORT_DATETIME_FORMAT" }} |
+ {% if gc.expires %}{{ gc.expires|date:"SHORT_DATETIME_FORMAT" }}{% endif %} |
+ {% if gc.last_tx %}{{ gc.last_tx|date:"SHORT_DATETIME_FORMAT" }}{% endif %} |
+
+ {{ gc.value|money:gc.currency }}
+ |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html
index 271780abe8..8a035e490c 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html
@@ -54,6 +54,14 @@
{{ card.owner_ticket.order.code }}-{{ card.owner_ticket.positionid }}
{% endif %}
+ {% if card.customer %}
+
{% trans "Customer account" %}
+
+
+ {{ card.customer.identifier }} – {{ card.customer.email }}
+
+
+ {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html
index 86bac3b052..5c21b8fc74 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html
@@ -12,6 +12,9 @@
{% bootstrap_field form.testmode layout="control" %}
{% bootstrap_field form.expires layout="control" %}
{% bootstrap_field form.conditions layout="control" %}
+ {% if form.customer %}
+ {% bootstrap_field form.customer layout="control" %}
+ {% endif %}