Skip to content

Commit 1827f82

Browse files
Implement voucher per event and for all events of an organizer: apply voucher in billing setting (#488)
* Apply voucher * Add migration file, apply voucher for invoice * Add pydantic model, minor refactor * Fix spelling * Add None case for validation * Fix space * Add pydantic req, edit validation error * Add status paid for 0 ticket_fee invoice * Update migration file, invoice billing model * Change pdf format * Add missing colon * Resolve conversations
1 parent d75c72c commit 1827f82

File tree

11 files changed

+639
-311
lines changed

11 files changed

+639
-311
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 5.1.4 on 2024-12-26 08:14
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('pretixbase', '0006_create_invoice_voucher'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='billinginvoice',
16+
name='final_ticket_fee',
17+
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
18+
),
19+
migrations.AddField(
20+
model_name='billinginvoice',
21+
name='voucher_discount',
22+
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
23+
),
24+
migrations.AddField(
25+
model_name='organizerbillingmodel',
26+
name='invoice_voucher',
27+
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='billing', to='pretixbase.invoicevoucher'),
28+
),
29+
migrations.AddField(
30+
model_name='billinginvoice',
31+
name='voucher_price_mode',
32+
field=models.CharField(max_length=20, null=True),
33+
),
34+
migrations.AddField(
35+
model_name='billinginvoice',
36+
name='voucher_value',
37+
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
38+
)
39+
]

src/pretix/base/models/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
cachedticket_name, generate_secret,
2525
)
2626
from .organizer import (
27-
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
27+
Organizer, Organizer_SettingsStore, OrganizerBillingModel, Team,
28+
TeamAPIToken, TeamInvite,
2829
)
2930
from .seating import Seat, SeatCategoryMapping, SeatingPlan
3031
from .tax import TaxRule
31-
from .vouchers import Voucher
32+
from .vouchers import InvoiceVoucher, Voucher
3233
from .waitinglist import WaitingListEntry

src/pretix/base/models/billing.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from pretix.base.models import LoggedModel
99

10+
from .choices import PriceModeChoices
11+
1012

1113
class BillingInvoice(LoggedModel):
1214
STATUS_PENDING = "n"
@@ -30,6 +32,10 @@ class BillingInvoice(LoggedModel):
3032
currency = models.CharField(max_length=3)
3133

3234
ticket_fee = models.DecimalField(max_digits=10, decimal_places=2)
35+
final_ticket_fee = models.DecimalField(max_digits=10, decimal_places=2, default=0)
36+
voucher_discount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
37+
voucher_price_mode = models.CharField(max_length=20, null=True, blank=True, choices=PriceModeChoices.choices)
38+
voucher_value = models.DecimalField(max_digits=10, decimal_places=2, default=0)
3339
payment_method = models.CharField(max_length=20, null=True, blank=True)
3440
paid_datetime = models.DateTimeField(null=True, blank=True)
3541
note = models.TextField(null=True, blank=True)

src/pretix/base/models/choices.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.db import models
2+
from django.utils.translation import gettext_lazy as _
3+
4+
5+
class PriceModeChoices(models.TextChoices):
6+
NONE = 'none', _('No effect')
7+
SET = 'set', _('Set product price to')
8+
SUBTRACT = 'subtract', _('Subtract from product price')
9+
PERCENT = 'percent', _('Reduce product price by (%)')

src/pretix/base/models/organizer.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,13 @@ class OrganizerBillingModel(models.Model):
464464
verbose_name=_("Tax ID"),
465465
)
466466

467+
invoice_voucher = models.ForeignKey(
468+
"pretixbase.InvoiceVoucher",
469+
on_delete=models.CASCADE,
470+
related_name="billing",
471+
null=True
472+
)
473+
467474
stripe_customer_id = models.CharField(
468475
max_length=255,
469476
verbose_name=_("Stripe Customer ID"),

src/pretix/base/models/vouchers.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from ..decimal import round_decimal
1818
from .base import LoggedModel
19+
from .choices import PriceModeChoices
1920
from .event import Event, SubEvent
2021
from .items import Item, ItemVariation, Quota
2122
from .orders import Order, OrderPosition
@@ -81,12 +82,6 @@ class Voucher(LoggedModel):
8182
* You need to either select a quota or an item
8283
* If you select an item that has variations but do not select a variation, you cannot set block_quota
8384
"""
84-
PRICE_MODES = (
85-
('none', _('No effect')),
86-
('set', _('Set product price to')),
87-
('subtract', _('Subtract from product price')),
88-
('percent', _('Reduce product price by (%)')),
89-
)
9085

9186
event = models.ForeignKey(
9287
Event,
@@ -144,8 +139,8 @@ class Voucher(LoggedModel):
144139
price_mode = models.CharField(
145140
verbose_name=_("Price mode"),
146141
max_length=100,
147-
choices=PRICE_MODES,
148-
default='none'
142+
choices=PriceModeChoices.choices,
143+
default=PriceModeChoices.NONE
149144
)
150145
value = models.DecimalField(
151146
verbose_name=_("Voucher value"),
@@ -506,12 +501,6 @@ def budget_used(self):
506501

507502

508503
class InvoiceVoucher(LoggedModel):
509-
PRICE_MODES = (
510-
('none', _('No effect')),
511-
('set', _('Set product price to')),
512-
('subtract', _('Subtract from product price')),
513-
('percent', _('Reduce product price by (%)')),
514-
)
515504
code = models.CharField(
516505
verbose_name=_("Voucher code"),
517506
max_length=255, default=generate_code,
@@ -542,8 +531,8 @@ class InvoiceVoucher(LoggedModel):
542531
price_mode = models.CharField(
543532
verbose_name=_("Price mode"),
544533
max_length=100,
545-
choices=PRICE_MODES,
546-
default='none'
534+
choices=PriceModeChoices.choices,
535+
default=PriceModeChoices.NONE
547536
)
548537
value = models.DecimalField(
549538
verbose_name=_("Voucher value"),
@@ -583,3 +572,27 @@ def is_active(self):
583572
if self.valid_until and self.valid_until < now():
584573
return False
585574
return True
575+
576+
def calculate_price(self, original_price: Decimal, max_discount: Decimal=None, event: Event=None) -> Decimal:
577+
"""
578+
Returns how the price given in original_price would be modified if this
579+
voucher is applied, i.e. replaced by a different price or reduced by a
580+
certain percentage. If the voucher does not modify the price, the
581+
original price will be returned.
582+
"""
583+
if self.value is not None:
584+
if self.price_mode == 'set':
585+
p = self.value
586+
elif self.price_mode == 'subtract':
587+
p = max(original_price - self.value, Decimal('0.00'))
588+
elif self.price_mode == 'percent':
589+
p = round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
590+
else:
591+
p = original_price
592+
places = settings.CURRENCY_PLACES.get(event.currency, 2)
593+
if places < 2:
594+
p = p.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
595+
if max_discount is not None:
596+
p = max(p, original_price - max_discount)
597+
return p
598+
return original_price

src/pretix/control/forms/organizer_forms/organizer_form.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pretix.base.forms import I18nModelForm
77
from pretix.base.models.organizer import Organizer, OrganizerBillingModel
8+
from pretix.base.models.vouchers import InvoiceVoucher
89
from pretix.helpers.countries import CachedCountries, get_country_name
910
from pretix.helpers.stripe_utils import (
1011
create_stripe_customer, update_customer_info,
@@ -44,6 +45,7 @@ class Meta:
4445
"country",
4546
"preferred_language",
4647
"tax_id",
48+
"invoice_voucher"
4749
]
4850

4951
primary_contact_name = forms.CharField(
@@ -132,6 +134,14 @@ class Meta:
132134
required=False,
133135
)
134136

137+
invoice_voucher = forms.CharField(
138+
label=_("Invoice Voucher"),
139+
help_text=_("If you have a voucher code, enter it here."),
140+
max_length=255,
141+
widget=forms.TextInput(attrs={"placeholder": ""}),
142+
required=False,
143+
)
144+
135145
def __init__(self, *args, **kwargs):
136146
self.organizer = kwargs.pop("organizer", None)
137147
self.warning_message = None
@@ -162,6 +172,25 @@ def validate_vat_number(self, country_code, vat_number):
162172
result = pyvat.is_vat_number_format_valid(vat_number, country_code)
163173
return result
164174

175+
def clean_invoice_voucher(self):
176+
voucher_code = self.cleaned_data['invoice_voucher']
177+
if not voucher_code:
178+
return None
179+
180+
voucher_instance = InvoiceVoucher.objects.filter(code=voucher_code).first()
181+
if not voucher_instance:
182+
raise forms.ValidationError("Voucher code not found!")
183+
184+
if not voucher_instance.is_active():
185+
raise forms.ValidationError("The voucher code has either expired or reached its usage limit.")
186+
187+
if voucher_instance.limit_organizer.exists():
188+
limit_organizer = voucher_instance.limit_organizer.values_list("id", flat=True)
189+
if self.organizer.id not in limit_organizer:
190+
raise forms.ValidationError("Voucher code is not valid for this organizer!")
191+
192+
return voucher_instance
193+
165194
def clean(self):
166195
cleaned_data = super().clean()
167196
country_code = cleaned_data.get("country")
@@ -174,14 +203,16 @@ def clean(self):
174203
self.add_error("tax_id", _("Invalid VAT number for {}".format(country_name)))
175204

176205
def save(self, commit=True):
206+
def set_attribute(instance):
207+
for field in self.Meta.fields:
208+
setattr(instance, field, self.cleaned_data[field])
209+
177210
instance = OrganizerBillingModel.objects.filter(
178211
organizer_id=self.organizer.id
179212
).first()
180213

181214
if instance:
182-
for field in self.Meta.fields:
183-
setattr(instance, field, self.cleaned_data[field])
184-
215+
set_attribute(instance)
185216
if commit:
186217
update_customer_info(
187218
instance.stripe_customer_id,
@@ -191,9 +222,7 @@ def save(self, commit=True):
191222
instance.save()
192223
else:
193224
instance = OrganizerBillingModel(organizer_id=self.organizer.id)
194-
for field in self.Meta.fields:
195-
setattr(instance, field, self.cleaned_data[field])
196-
225+
set_attribute(instance)
197226
if commit:
198227
stripe_customer = create_stripe_customer(
199228
email=self.cleaned_data.get("primary_contact_email"),

src/pretix/control/templates/pretixcontrol/organizers/billing.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ <h1>{% trans "Billing settings" %}</h1>
2828
{% bootstrap_field form.city layout="control" %}
2929
{% bootstrap_field form.country layout="control" %}
3030
{% bootstrap_field form.tax_id layout="control" %}
31+
{% bootstrap_field form.invoice_voucher layout="control" %}
3132
{% bootstrap_field form.preferred_language layout="control" %}
3233
<div class="form-group submit-group">
3334
<button type="submit" class="btn btn-primary btn-save">

0 commit comments

Comments
 (0)