Compare commits

...

6 Commits

Author SHA1 Message Date
Raphael Michel
1c3a3297ca Add documentation 2025-09-09 12:55:34 +02:00
Raphael Michel
bb16c88a8d Allow plugins to add data to invoices 2025-09-09 12:49:52 +02:00
Raphael Michel
1662c65b9b More changes 2025-09-08 19:51:01 +02:00
Raphael Michel
dabdac6ff2 Fix typo 2025-09-08 18:41:09 +02:00
Raphael Michel
50d724f9e3 Fix tests 2025-09-08 18:15:40 +02:00
Raphael Michel
ae5c0a5537 Invoicing: Configurable service date 2025-09-08 10:57:57 +02:00
16 changed files with 563 additions and 57 deletions

View File

@@ -80,17 +80,12 @@ lines list of objects The actual invo
for all invoice lines
created before this field was introduced as well as for
all lines not created by a fee (e.g. a product).
event_date_from datetime Start date of the (sub)event this line was created for as it
was set during invoice creation. Can be ``null`` for all invoice
lines created before this was introduced as well as for lines in
an event series not created by a product (e.g. shipping or
cancellation fees).
├ event_date_to datetime End date of the (sub)event this line was created for as it
was set during invoice creation. Can be ``null`` for all invoice
lines created before this was introduced as well as for lines in
an event series not created by a product (e.g. shipping or
cancellation fees) as well as whenever the respective (sub)event
has no end date set.
period_start datetime Start date of the service or delivery period of the invoice line.
Can be ``null`` if not known.
├ period_end datetime End date of the service or delivery period of the invoice line.
Can be ``null`` if not known.
├ event_date_from datetime Deprecated alias of ``period_start``.
├ event_date_to datetime Deprecated alias of ``period_end``.
├ event_location string Location of the (sub)event this line was created for as it
was set during invoice creation. Can be ``null`` for all invoice
lines created before this was introduced as well as for lines in
@@ -274,6 +269,8 @@ List of all invoices
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",
"event_date_to": null,
"period_start": "2017-12-27T10:00:00Z",
"period_end": "2017-12-27T10:00:00Z",
"event_location": "Heidelberg",
"attendee_name": null,
"gross_value": "23.00",
@@ -420,6 +417,8 @@ Fetching individual invoices
"fee_internal_type": null,
"event_date_from": "2017-12-27T10:00:00Z",
"event_date_to": null,
"period_start": "2017-12-27T10:00:00Z",
"period_end": "2017-12-27T10:00:00Z",
"event_location": "Heidelberg",
"attendee_name": null,
"gross_value": "23.00",

View File

@@ -23,7 +23,7 @@ There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals
:no-index:
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_expiry_changed, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text
:members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_expiry_changed, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, build_invoice_data, invoice_line_text
Check-ins
"""""""""

View File

@@ -805,6 +805,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_reissue_after_modify',
'invoice_include_free',
'invoice_generate',
'invoice_period',
'invoice_numbers_consecutive',
'invoice_numbers_prefix',
'invoice_numbers_prefix_cancellations',

View File

@@ -1757,12 +1757,14 @@ class LinePositionField(serializers.IntegerField):
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
position = LinePositionField(read_only=True)
event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
class Meta:
model = InvoiceLine
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', 'tax_name', 'fee_type',
'fee_internal_type', 'event_location')
'event_date_to', 'period_start', 'period_end', 'gross_value', 'tax_value', 'tax_rate', 'tax_code',
'tax_name', 'fee_type', 'fee_internal_type', 'event_location')
class InvoiceSerializer(I18nAwareModelSerializer):

View File

@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import datetime
import logging
import re
import unicodedata
@@ -522,6 +523,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event'))))
canvas.drawText(textobject)
def _date_range_in_header(self):
if self.invoice.event.has_subevents or not self.invoice.event.settings.show_dates_on_frontpage:
return None, None
tz = self.invoice.event.timezone
show_end_date = (
self.invoice.event.settings.show_date_to and
self.invoice.event.date_to and
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
)
if show_end_date:
return self.invoice.event.date_from.astimezone(tz).date(), self.invoice.event.date_to.astimezone(tz).date()
else:
return self.invoice.event.date_from.astimezone(tz).date(), None
def _draw_event(self, canvas):
def shorten(txt):
txt = str(txt)
@@ -535,25 +550,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
p_size = p.wrap(self.event_width, self.event_height)
return txt
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
tz = self.invoice.event.timezone
show_end_date = (
self.invoice.event.settings.show_date_to and
self.invoice.event.date_to and
self.invoice.event.date_to.astimezone(tz).date() != self.invoice.event.date_from.astimezone(tz).date()
d_from, d_to = self._date_range_in_header()
if d_from and d_to:
p_str = (
shorten(self.invoice.event.name) + '\n' +
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=date_format(d_from, "DATE_FORMAT"),
to_date=date_format(d_to, "DATE_FORMAT"),
)
)
if show_end_date:
p_str = (
shorten(self.invoice.event.name) + '\n' +
pgettext('invoice', '{from_date}\nuntil {to_date}').format(
from_date=self.invoice.event.get_date_from_display(show_times=False),
to_date=self.invoice.event.get_date_to_display(show_times=False)
)
)
else:
p_str = (
shorten(self.invoice.event.name) + '\n' + self.invoice.event.get_date_from_display(show_times=False)
)
elif d_from:
p_str = shorten(self.invoice.event.name) + '\n' + date_format(d_from, "DATE_FORMAT")
else:
p_str = shorten(self.invoice.event.name)
@@ -657,6 +664,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _get_story(self, doc):
has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge
header_dates = self._date_range_in_header()
tz = self.invoice.event.timezone
has_multiple_service_dates = len(set(
(il.period_start, il.period_end) for il in self.invoice.lines.all()
)) > 1
request_show_service_date = False
story = [
NextPageTemplate('FirstPage'),
@@ -700,15 +713,73 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)]
def _group_key(line):
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent_id,
line.event_date_from, line.event_date_to)
return (line.description, line.tax_rate, line.tax_name, line.net_value, line.gross_value, line.subevent,
line.period_start, line.period_end)
def day(dt: datetime.datetime) -> datetime.date:
if dt is None:
return None
return dt.astimezone(tz).date()
total = Decimal('0.00')
for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in addon_aware_groupby(
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
self.invoice.lines.all(),
key=_group_key,
is_addon=lambda l: l.description.startswith(" +"),
):
# Try to be clever and figure out when organizers would want to show the period. This heuristic is
# not perfect and the only "fully correct" way would be to include the period on every line always,
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
# (b) due to not respecting the show_date_to setting of events in cases where we could have respected it.
# Still, we want to show the date explicitly if its different to the event or invoice date.
if period_start and period_end and day(period_end) != day(period_start):
# It's a multi-day period, such as the validity of the ticket or an event date period
if day(period_start) == header_dates[0] and day(period_end) == header_dates[1]:
# This is the exact event period we already printed in the header, no need to repeat it.
period_line = ""
elif (self.event.has_subevents and subevent and day(subevent.date_from) == day(period_start) and
day(subevent.date_to) == day(period_end)):
# For subevents, build_invoice already includes the date in the description in the event-default format.
period_line = ""
else:
period_line = f"{date_format(day(period_start), 'SHORT_DATE_FORMAT')} {date_format(day(period_end), 'SHORT_DATE_FORMAT')}"
elif period_start or period_end:
# It's a single-day period
delivery_day = day(period_end or period_start)
if delivery_day in (header_dates[0], header_dates[1]):
# This is the event date we already printed in the header, no need to repeat it.
period_line = ""
elif self.event.has_subevents and subevent and delivery_day in (day(subevent.date_from), day(subevent.date_to)):
# For subevents, build_invoice already includes the date in the description in the event-default format.
period_line = ""
elif (delivery_day == self.invoice.date) and header_dates[0] is None:
# This is a shop that doesn't show the date of the event in the header, and the period is the invoice
# date. We assume that this is an 'everything is executed immediately' situation and do not want to
# confuse with showing additional dates on the invoice. This is the case that is not guaranteed to be
# correct in all cases and might need to change in the future. If customers have legal concerns, a
# quick fix is including a sentence like "Delivery date is the invoice date unless otherwise indicated:"
# in a custom text on the invoice.
period_line = ""
else:
period_line = date_format(delivery_day, 'SHORT_DATE_FORMAT')
else:
# No period known
period_line = ""
if not has_multiple_service_dates and period_line:
# Group together at the end of the invoice
request_show_service_date = period_line
else:
description += "\n" + period_line
lines = list(lines)
if has_taxes:
if len(lines) > 1:
@@ -717,6 +788,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
gross_price=money_filter(gross_value, self.invoice.event.currency),
)
description = description + "\n" + single_price_line
tdata.append((
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
@@ -821,6 +893,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm))
if request_show_service_date:
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
self.stylesheet['Normal']
))
if self.invoice.payment_provider_text:
story.append(FontFallbackParagraph(
self._normalize(self.invoice.payment_provider_text),

View File

@@ -0,0 +1,80 @@
# Generated by Django 4.2.17 on 2025-09-08 08:14
from django.core.cache import cache
from django.db import migrations
def set_invoice_period(apps, schema_editor):
EventSettingsStore = apps.get_model("pretixbase", "Event_SettingsStore")
ev_seen = set()
insert_queue = []
flush_queue = []
def store():
EventSettingsStore.objects.bulk_create(
insert_queue,
update_conflicts=True,
update_fields=["value"],
unique_fields=["object", "key"],
)
for f in flush_queue:
cache.delete(f)
flush_queue.clear()
insert_queue.clear()
# Existing events that use pretix-zugferd and have explicitly disabled delivery dates
for setting in EventSettingsStore.objects.filter(key="zugferd_include_delivery_date", value="False"):
flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
insert_queue.append(
EventSettingsStore(
object_id=setting.object_id,
key="invoice_period",
value="invoice_date",
)
)
ev_seen.add(setting.object_id)
if len(insert_queue) > 1000:
store()
# Existing events that previously hid their date on invoices
for setting in EventSettingsStore.objects.filter(key="show_dates_on_frontpage", value="False"):
if setting.object_id in ev_seen:
continue
flush_queue.append("hierarkey_{}_{}".format("event", setting.object_id))
insert_queue.append(
EventSettingsStore(
object_id=setting.object_id,
key="invoice_period",
value="auto_no_event",
)
)
ev_seen.add(setting.object_id)
if len(insert_queue) > 1000:
store()
store()
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0288_invoice_transmission"),
]
operations = [
migrations.RenameField(
model_name="invoiceline",
old_name="event_date_to",
new_name="period_end",
),
migrations.RenameField(
model_name="invoiceline",
old_name="event_date_from",
new_name="period_start",
),
migrations.RunPython(
set_invoice_period,
migrations.RunPython.noop,
)
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.17 on 2025-09-09 09:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0289_invoiceline_period"),
]
operations = [
migrations.AddField(
model_name="invoice",
name="plugin_data",
field=models.JSONField(default=dict),
),
]

View File

@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import string
import warnings
from decimal import Decimal
import pycountry
@@ -200,6 +201,7 @@ class Invoice(models.Model):
transmission_info = models.JSONField(null=True, blank=True)
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
plugin_data = models.JSONField(default=dict)
objects = ScopedManager(organizer='event__organizer')
@@ -387,10 +389,10 @@ class InvoiceLine(models.Model):
:type tax_name: str
:param subevent: The subevent this line refers to
:type subevent: SubEvent
:param event_date_from: Event date of the (sub)event at the time the invoice was created
:type event_date_from: datetime
:param event_date_to: Event end date of the (sub)event at the time the invoice was created
:type event_date_to: datetime
:param period_start: Start if service period invoiced
:type period_start: datetime
:param period_end: End of service period invoiced
:type period_end: datetime
:param event_location: Event location of the (sub)event at the time the invoice was created
:type event_location: str
:param item: The item this line refers to
@@ -409,8 +411,8 @@ class InvoiceLine(models.Model):
tax_name = models.CharField(max_length=190)
tax_code = models.CharField(max_length=190, null=True, blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
event_date_from = models.DateTimeField(null=True)
event_date_to = models.DateTimeField(null=True)
period_start = models.DateTimeField(null=True)
period_end = models.DateTimeField(null=True)
event_location = models.TextField(null=True, blank=True)
item = models.ForeignKey('Item', null=True, blank=True, on_delete=models.PROTECT)
variation = models.ForeignKey('ItemVariation', null=True, blank=True, on_delete=models.PROTECT)
@@ -427,3 +429,35 @@ class InvoiceLine(models.Model):
def __str__(self):
return 'Line {} of invoice {}'.format(self.position, self.invoice)
@property
def event_date_from(self):
warnings.warn(
'InvoiceLine.event_date_from is deprecated, use period_start instead,',
category=DeprecationWarning,
)
return self.period_start
@event_date_from.setter
def event_date_from(self, value):
warnings.warn(
'InvoiceLine.event_date_from is deprecated, use period_start instead,',
category=DeprecationWarning,
)
self.period_start = value
@property
def event_date_to(self):
warnings.warn(
'InvoiceLine.event_date_to is deprecated, use period_end instead,',
category=DeprecationWarning,
)
return self.period_end
@event_date_to.setter
def event_date_to(self, value):
warnings.warn(
'InvoiceLine.event_date_to is deprecated, use period_end instead,',
category=DeprecationWarning,
)
self.period_to = value

View File

@@ -61,7 +61,9 @@ from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask,
)
from pretix.base.signals import invoice_line_text, periodic_task
from pretix.base.signals import (
build_invoice_data, invoice_line_text, periodic_task,
)
from pretix.celery_app import app
from pretix.helpers.database import OF_SELF, rolledback_transaction
from pretix.helpers.models import modelcopy
@@ -82,6 +84,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
lp = invoice.order.payments.last()
min_period_start = None
max_period_end = None
now_dt = now()
with (language(invoice.locale, invoice.event.settings.region)):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
@@ -208,7 +214,9 @@ def build_invoice(invoice: Invoice) -> Invoice:
positions = list(
invoice.order.positions.select_related('addon_to', 'item', 'tax_rule', 'subevent', 'variation').annotate(
addon_c=Count('addons')
).prefetch_related('answers', 'answers__options', 'answers__question').order_by('positionid', 'id')
).prefetch_related(
'answers', 'answers__options', 'answers__question', 'granted_memberships',
).order_by('positionid', 'id')
)
reverse_charge = False
@@ -267,6 +275,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
location=_location_oneliner(location)
)
period_start, period_end = _service_period_for_position(invoice, p, now_dt)
min_period_start = min(min_period_start or period_start, period_start)
max_period_end = min(max_period_end or period_end, period_end)
InvoiceLine.objects.create(
position=i,
invoice=invoice,
@@ -277,8 +289,8 @@ def build_invoice(invoice: Invoice) -> Invoice:
item=p.item,
variation=p.variation,
attendee_name=p.attendee_name if invoice.event.settings.invoice_attendee_name else None,
event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to,
period_start=period_start,
period_end=period_end,
event_location=location if invoice.event.settings.invoice_event_location else None,
tax_rate=p.tax_rate,
tax_code=p.tax_code,
@@ -301,13 +313,29 @@ def build_invoice(invoice: Invoice) -> Invoice:
fee_title = _(fee.get_fee_type_display())
if fee.description:
fee_title += " - " + fee.description
if min_period_start and max_period_end:
# Consider fees to have the same service period as the products sold
period_start = min_period_start
period_end = max_period_end
else:
# Usually can only happen if everything except a cancellation fee is removed
if invoice.event.settings.invoice_period in ("auto", "auto_no_event", "event_date") and not invoice.event.has_subevents:
# Non-series event, let's be backwards-compatible and tag everything with the event period
period_start = invoice.event.date_from
period_end = invoice.event.date_to
else:
# We could try to work from the canceled positions, but it doesn't really make sense. A cancellation
# fee is not "delivered" at the event date, it is rather effective right now.
period_start = period_end = now()
InvoiceLine.objects.create(
position=i + offset,
invoice=invoice,
description=fee_title,
gross_value=fee.value,
event_date_from=None if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=None if invoice.event.has_subevents else invoice.event.date_to,
period_start=period_start,
period_end=period_end,
event_location=(
None if invoice.event.has_subevents
else (str(invoice.event.location)
@@ -336,6 +364,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.reverse_charge = reverse_charge
invoice.save()
build_invoice_data.send(sender=invoice.event, invoice=invoice)
return invoice
@@ -351,6 +380,49 @@ def build_cancellation(invoice: Invoice):
return invoice
def _service_period_for_position(invoice, position, invoice_dt):
if invoice.event.settings.invoice_period in ("auto", "auto_no_event"):
if position.valid_from or position.valid_until:
period_start = position.valid_from or now()
period_end = position.valid_until
elif memberships := list(position.granted_memberships.all()):
period_start = min(m.date_start for m in memberships)
period_end = max(m.date_end for m in memberships)
elif invoice.event.has_subevents:
if position.subevent:
period_start = position.subevent.date_from
period_end = position.subevent.date_to
else:
# Currently impossible case, but might not be in the future and never makes
# sense to use the event date here
period_start = invoice_dt
period_end = invoice_dt
elif invoice.event.settings.invoice_period == "auto_no_event":
period_start = invoice_dt
period_end = invoice_dt
else:
period_start = invoice.event.date_from
period_end = invoice.event.date_to
elif invoice.event.settings.invoice_period == "order_date":
period_start = invoice.order.datetime
period_end = invoice.order.datetime
elif invoice.event.settings.invoice_period == "event_date":
if position.subevent:
period_start = position.subevent.date_from
period_end = position.subevent.date_to
else:
period_start = invoice.event.date_from
period_end = invoice.event.date_to
elif invoice.event.settings.invoice_period == "invoice_date":
period_start = period_end = invoice_dt
else:
raise ValueError(f"Invalid invoice period setting '{invoice.event.settings.invoice_period}'")
if not period_end:
period_end = period_start
return period_start, period_end
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
if invoice.canceled:
raise ValueError("Invoice should not be canceled twice.")
@@ -456,6 +528,12 @@ def build_preview_invoice_pdf(event):
if not locale or locale == '__user__':
locale = event.settings.locale
if event.settings.invoice_period in ("auto", "auto_no_event", "event_date"):
period_start = event.date_from
period_end = event.date_to or event.date_from
else:
period_start = period_end = timezone.now()
with rolledback_transaction(), language(locale, event.settings.region):
order = event.orders.create(
status=Order.STATUS_PENDING, datetime=timezone.now(),
@@ -506,8 +584,8 @@ def build_preview_invoice_pdf(event):
invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax,
tax_rate=tax.rate, tax_name=tax.name, tax_code=tax.code,
event_date_from=event.date_from,
event_date_to=event.date_to,
period_start=period_start,
period_end=period_end,
event_location=event.settings.invoice_event_location,
)
else:
@@ -515,8 +593,8 @@ def build_preview_invoice_pdf(event):
InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product A"),
gross_value=100, tax_value=0, tax_rate=0, tax_code=None,
event_date_from=event.date_from,
event_date_to=event.date_to,
period_start=period_start,
period_end=period_end,
event_location=event.settings.invoice_event_location,
)

View File

@@ -1098,6 +1098,35 @@ DEFAULTS = {
help_text=_("Invoices will never be automatically generated for free orders.")
)
},
'invoice_period': {
'default': 'auto',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': dict(
choices=(
('auto', _('Automatic based on ticket-specific validity, membership validity, event series date, or event date)')),
('auto_no_event', _('Automatic, but prefer invoice date over event date')),
('event_date', _('Event date')),
('order_date', _('Order date')),
('invoice_date', _('Invoice date')),
),
),
'form_kwargs': dict(
label=_("Date of service"),
widget=forms.RadioSelect,
choices=(
('auto', _('Automatic based on ticket-specific validity, membership validity, event series date, or event date)')),
('auto_no_event', _('Automatic, but prefer invoice date over event date')),
('event_date', _('Event date')),
('order_date', _('Order date')),
('invoice_date', _('Invoice date')),
),
help_text=_("This controls what dates are shown on the invoice, but is especially important for "
"electronic invoicing."),
required=True,
)
},
'invoice_reissue_after_modify': {
'default': 'False',
'type': bool,

View File

@@ -596,6 +596,18 @@ multiple events. Receivers should return a subclass of pretix.base.exporter.Base
The ``sender`` keyword argument will contain an organizer.
"""
build_invoice_data = EventPluginSignal()
"""
Arguments: ``invoice``
This signal is sent out every time an invoice is built, after the invoice model was created
and filled and before the PDF generation task is started. You can use this to make changes
to the invoice, but we recommend to mostly use it to add content to ``Invoice.plugin_data``.
You are responsible for saving any changes to the database.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
validate_order = EventPluginSignal()
"""
Arguments: ``payments``, ``positions``, ``email``, ``locale``, ``invoice_address``,

View File

@@ -857,6 +857,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_show_payments',
'invoice_reissue_after_modify',
'invoice_generate',
'invoice_period',
'invoice_attendee_name',
'invoice_event_location',
'invoice_include_expire_date',

View File

@@ -15,6 +15,19 @@
{% bootstrap_field form.invoice_email_attachment layout="control" %}
{% bootstrap_field form.invoice_email_organizer layout="control" %}
{% bootstrap_field form.invoice_language layout="control" %}
{% bootstrap_field form.invoice_period layout="control" %}
{% if not request.event.settings.show_dates_on_frontpage %}
<div data-display-dependency="input[name=invoice_period][value=auto],input[name=invoice_period][value=event_date]">
<div class="alert alert-warning dynamic">
{% blocktrans trimmed %}
You configured that your shop is not an event and the event date should not be shown.
Therefore, we recommend that you set the date of service to a different option.
{% endblocktrans %}
</div>
</div>
{% endif %}
{% bootstrap_field form.invoice_include_free layout="control" %}
{% bootstrap_field form.invoice_show_payments layout="control" %}
{% bootstrap_field form.invoice_reissue_after_modify layout="control" %}

View File

@@ -231,7 +231,9 @@ TEST_INVOICE_RES = {
"description": "Budget Ticket<br />Attendee: Peter",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'attendee_name': 'Peter',
'item': None,
@@ -249,7 +251,9 @@ TEST_INVOICE_RES = {
"description": "Payment fee",
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'attendee_name': None,
'fee_type': "payment",

View File

@@ -608,7 +608,9 @@ def test_order_create_invoice(token_client, organizer, event, order):
'description': 'Budget Ticket<br />Attendee: Peter',
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'fee_type': None,
'fee_internal_type': None,
@@ -626,7 +628,9 @@ def test_order_create_invoice(token_client, organizer, event, order):
'description': 'Payment fee',
'subevent': None,
'event_date_from': '2017-12-27T10:00:00Z',
'event_date_to': None,
'event_date_to': '2017-12-27T10:00:00Z',
'period_start': '2017-12-27T10:00:00Z',
'period_end': '2017-12-27T10:00:00Z',
'event_location': None,
'fee_type': "payment",
'fee_internal_type': None,

View File

@@ -33,7 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import json
from datetime import date, timedelta
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
import pytest
@@ -42,6 +42,7 @@ from django.utils.itercompat import is_iterable
from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.invoice import addon_aware_groupby
from pretix.base.models import (
@@ -62,7 +63,8 @@ def env():
with scope(organizer=o):
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer'
date_from=datetime(2024, 12, 1, 9, 0, 0, tzinfo=timezone.utc),
plugins='pretix.plugins.banktransfer'
)
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
@@ -660,3 +662,154 @@ def test_addon_aware_groupby():
[True, 102, 3.00],
]],
]
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "event_date"])
def test_period_from_event_start(env, period):
event, order = env
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == event.date_from
assert l1.period_end == event.date_from
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "event_date"])
def test_period_from_event_range(env, period):
event, order = env
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == event.date_from
assert l1.period_end == event.date_to
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
def test_period_from_ticket_validity(env, period):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == p1.valid_from
assert l1.period_end == p1.valid_until
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
def test_period_from_subevent(env, period):
event, order = env
event.has_subevents = True
event.save()
se1 = event.subevents.create(
name=event.name,
active=True,
date_from=datetime((now().year + 1), 7, 31, 9, 0, 0, tzinfo=timezone.utc),
date_to=datetime((now().year + 1), 7, 31, 17, 0, 0, tzinfo=timezone.utc),
)
p1 = order.positions.first()
p1.subevent = se1
p1.save()
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == se1.date_from
assert l1.period_end == se1.date_to
@pytest.mark.django_db
@pytest.mark.parametrize("period", ["auto", "auto_no_event"])
def test_period_from_memberships(env, period):
event, order = env
event.date_to = event.date_from + timedelta(days=1)
event.settings.invoice_period = period
p1 = order.positions.first()
membershiptype = event.organizer.membership_types.create(
name=LazyI18nString({"en": "Week pass"}),
transferable=True,
allow_parallel_usage=False,
max_usages=15,
)
customer = event.organizer.customers.create(
identifier="8WSAJCJ",
email="foo@example.org",
name_parts={"_legacy": "Foo"},
name_cached="Foo",
is_verified=False,
)
m = customer.memberships.create(
membership_type=membershiptype,
granted_in=p1,
date_start=datetime(2021, 4, 1, 0, 0, 0, 0, tzinfo=timezone.utc),
date_end=datetime(2021, 4, 8, 23, 59, 59, 999999, tzinfo=timezone.utc),
attendee_name_parts={
"_scheme": "given_family",
'given_name': 'John',
'family_name': 'Doe',
}
)
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == m.date_start
assert l1.period_end == m.date_end
@pytest.mark.django_db
def test_period_auto_no_event_from_invoice(env):
event, order = env
event.settings.invoice_period = "auto_no_event"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert abs(l1.period_start - now()) < timedelta(seconds=10)
assert abs(l1.period_end - now()) < timedelta(seconds=10)
@pytest.mark.django_db
def test_period_always_invoice_date(env):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.settings.invoice_period = "invoice_date"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert abs(l1.period_start - now()) < timedelta(seconds=10)
assert abs(l1.period_end - now()) < timedelta(seconds=10)
@pytest.mark.django_db
def test_period_always_event_date(env):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.settings.invoice_period = "event_date"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == event.date_from
assert l1.period_end == event.date_from
@pytest.mark.django_db
def test_period_always_order_date(env):
event, order = env
p1 = order.positions.first()
p1.valid_from = datetime(2025, 1, 1, 0, 0, 0, tzinfo=event.timezone)
p1.valid_until = datetime(2025, 12, 31, 23, 59, 59, tzinfo=event.timezone)
p1.save()
event.settings.invoice_period = "order_date"
inv = generate_invoice(order)
l1 = inv.lines.first()
assert l1.period_start == order.datetime
assert l1.period_end == order.datetime