diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 8f6c03997..7b805e00a 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -61,6 +61,7 @@ invoice_address object Invoice address AU, BR, CA, CN, MY, MX, and US. ├ internal_reference string Customer's internal reference to be printed on the invoice ├ custom_field string Custom invoice address field +├ invoice_email string Email address that should receive all invoices ├ vat_id string Customer VAT ID └ vat_id_validated string ``true``, if the VAT ID has been validated against the EU VAT service and validation was successful. This only @@ -342,6 +343,7 @@ List of all orders "country": "DE", "state": "", "internal_reference": "", + "invoice_email": null, "vat_id": "EU123456789", "vat_id_validated": false }, @@ -518,6 +520,7 @@ Fetching individual orders "country": "DE", "state": "", "internal_reference": "", + "invoice_email": null, "vat_id": "EU123456789", "vat_id_validated": false }, @@ -895,6 +898,7 @@ Creating orders * ``country`` * ``state`` * ``internal_reference`` + * ``invoice_email`` * ``vat_id`` * ``vat_id_validated`` (optional) – If you need support for reverse charge (rarely the case), you need to check yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will @@ -992,6 +996,7 @@ Creating orders "country": "UK", "state": "", "internal_reference": "", + "invoice_email": null, "vat_id": "" }, "positions": [ diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 6280d9a1c..1fdaf2a02 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -745,6 +745,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_include_expire_date', 'invoice_address_explanation_text', 'invoice_email_attachment', + 'invoice_email_asked', 'invoice_email_organizer', 'invoice_address_from_name', 'invoice_address_from', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 116987ba5..e62bc532e 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -92,7 +92,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer): class Meta: model = InvoiceAddress fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country', - 'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference') + 'state', 'vat_id', 'vat_id_validated', 'custom_field', 'internal_reference', 'invoice_email') read_only_fields = ('last_modified',) def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 7da193470..0d58af85e 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -376,6 +376,14 @@ def base_placeholders(sender, **kwargs): SimpleFunctionalMailTextPlaceholder( 'currency', ['event'], lambda event: event.currency, lambda event: event.currency ), + SimpleFunctionalMailTextPlaceholder( + 'order_email', ['order'], lambda order: order.email, 'john@example.org' + ), + SimpleFunctionalMailTextPlaceholder( + 'invoice_number', ['invoice'], + lambda invoice: invoice.full_invoice_no, + f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000' + ), SimpleFunctionalMailTextPlaceholder( 'refund_amount', ['event_or_subevent', 'refund_amount'], lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency), diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index e8bafaef7..644c36124 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -919,7 +919,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): class Meta: model = InvoiceAddress fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state', - 'vat_id', 'internal_reference', 'beneficiary', 'custom_field') + 'vat_id', 'internal_reference', 'beneficiary', 'custom_field', 'invoice_email') widgets = { 'is_business': BusinessBooleanRadio, 'street': forms.Textarea(attrs={ diff --git a/src/pretix/base/migrations/0224_invoiceaddress_invoice_email.py b/src/pretix/base/migrations/0224_invoiceaddress_invoice_email.py new file mode 100644 index 000000000..cb9c542e3 --- /dev/null +++ b/src/pretix/base/migrations/0224_invoiceaddress_invoice_email.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-11-15 10:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0223_voucher_min_usages'), + ] + + operations = [ + migrations.AddField( + model_name='invoiceaddress', + name='invoice_email', + field=models.EmailField(max_length=254, null=True), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 40a87c19d..64fa1859d 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1038,6 +1038,59 @@ class Order(LockModel, LoggedModel): } ) + def send_invoice_to_alternate_email(self, invoice): + """ + Sends an email to the alternate invoice address. + """ + from pretix.base.services.mail import ( + SendMailException, TolerantDict, mail, render_mail, + ) + + try: + recipient = self.invoice_address.invoice_email + except InvoiceAddress.DoesNotExist: + return + if not recipient: + return + + with language(self.locale, self.event.settings.region): + context = get_email_context(event=self.event, order=self, invoice=invoice, event_or_subevent=self.event, + invoice_address=self.invoice_address) + template = self.event.settings.email_text_invoice_only + subject = self.event.settings.email_subject_invoice_only + + try: + email_content = render_mail(template, context) + subject = subject.format_map(TolerantDict(context)) + mail( + recipient, + subject, + template, + context=context, + event=self.event, + locale=self.locale, + order=self, + invoices=[invoice], + attach_tickets=False, + auto_email=True, + attach_ical=False, + ) + except SendMailException: + raise + else: + self.log_action( + 'pretix.event.order.email.order_invoice_only', + data={ + 'subject': subject, + 'message': email_content, + 'position': None, + 'recipient': recipient, + 'invoices': invoice.pk, + 'attach_tickets': False, + 'attach_ical': False, + } + ) + def resend_link(self, user=None, auth=None): with language(self.locale, self.event.settings.region): email_template = self.event.settings.mail_text_resend_link @@ -2781,6 +2834,14 @@ class InvoiceAddress(models.Model): verbose_name=_('Beneficiary'), blank=True ) + invoice_email = models.EmailField( + verbose_name=_('Send invoice to'), + help_text=_('If your accounting department requires that we send them the invoice directly, you can enter ' + 'their email address here. They will receive a separate email. You will still receive the invoice ' + 'and booking confirmation as well.'), + blank=True, + null=True, + ) objects = ScopedManager(organizer='order__event__organizer') profiles = ScopedManager(organizer='customer__organizer') @@ -2865,6 +2926,7 @@ class InvoiceAddress(models.Model): 'vat_id': self.vat_id, 'custom_field': self.custom_field, 'internal_reference': self.internal_reference, + 'invoice_email': self.invoice_email, 'beneficiary': self.beneficiary, }) return d diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index c004f7079..ba3a29b32 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -379,13 +379,18 @@ def invoice_pdf_task(invoice: int): with scope(organizer=i.order.event.organizer): if i.shredded: return None - if i.file: - i.file.delete() - with language(i.locale, i.event.settings.region): + + to_delete = i.file.name + with transaction.atomic(), language(i.locale, i.event.settings.region): fname, ftype, fcontent = i.event.invoice_renderer.generate(i) i.file.save(fname, ContentFile(fcontent), save=False) i.save(update_fields=['file']) - return i.file.name + + try: + i.file.storage.delete(to_delete) + except: + logger.exception('Could not delete previous PDF file for invoice') + return i.file.name def invoice_qualified(order: Order): @@ -484,6 +489,14 @@ def fetch_ecb_rates(sender, **kwargs): logger.exception('Could not retrieve rates from ECB') +@app.task(base=TransactionAwareTask) +def send_invoice_to_customer_alternative_email(invoice_id): + with scopes_disabled(): + i = Invoice.objects.get(pk=invoice_id) + with scope(organizer=i.order.event.organizer): + i.order.send_invoice_to_alternate_email(i) + + @receiver(signal=periodic_task) @scopes_disabled() def send_invoices_to_organizer(sender, **kwargs): diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index b509ee188..b167275ac 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -98,7 +98,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None, position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None, customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None, - attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None): + attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None, + plain_text_without_links=False): """ Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. @@ -242,7 +243,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La if order and order.testmode: subject = "[TESTMODE] " + subject - if order and position: + if order and position and not plain_text_without_links: body_plain += _( "You are receiving this email because someone placed an order for {event} for you." ).format(event=event.name) @@ -258,7 +259,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La } ) ) - elif order: + elif order and not plain_text_without_links: body_plain += _( "You are receiving this email because you placed an order for {event}." ).format(event=event.name) @@ -278,7 +279,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La with override(timezone): try: - if 'position' in inspect.signature(renderer.render).parameters: + if plain_text_without_links: + body_html = None + elif 'position' in inspect.signature(renderer.render).parameters: body_html = renderer.render(content_plain, signature, raw_subject, order, position) else: # Backwards compatibility @@ -596,8 +599,9 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st raise SendMailException('Failed to send an email to {}.'.format(to)) else: for i in invoices_sent: - i.sent_to_customer = now() - i.save(update_fields=['sent_to_customer']) + if not i.order.invoice_address.invoice_email or i.order.invoice_address.invoice_email in to: + i.sent_to_customer = now() + i.save(update_fields=['sent_to_customer']) def mail_send(*args, **kwargs): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2fec21d91..6a2919fa2 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -80,6 +80,7 @@ from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, + send_invoice_to_customer_alternative_email, ) from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.mail import SendMailException @@ -1091,6 +1092,13 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: _order_placed_email_attendee(event, order, p, email_attendees_template, subject_attendees_template, log_entry, is_free=free_order_flow) + try: + if invoice and order.invoice_address.invoice_email: + # We fire this task with a little delay since both mail() calls will call invoice_pdf_task and this + # will reduce the chance of the PDF renderer running twice except under exceptional load patterns. + send_invoice_to_customer_alternative_email.apply_async(args=(invoice.pk,), countdown=60) + except InvoiceAddress.DoesNotExist: + pass return order.id diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index a68a67e4e..a393bdee1 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -972,6 +972,18 @@ DEFAULTS = { "to emails."), ) }, + 'invoice_email_asked': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Allow customers to enter an alternate email address"), + help_text=_("If an alternate email address is entered, the invoice will be sent there in addition to being " + "sent to the user."), + widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_email_attachment'}), + ) + }, 'invoice_email_organizer': { 'default': '', 'type': str, @@ -2098,6 +2110,20 @@ You can view the details of your order here: {url} Best regards, +Your {event} team""")) + }, + 'mail_subject_invoice_only': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(gettext_noop("Invoice {invoice_number}")), + }, + 'mail_text_invoice_only': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(gettext_noop("""Hello, + +you receive this message because an order for {event} was placed by {order_email} and we have been asked to forward the invoice to you. + +Best regards, + Your {event} team""")) }, 'mail_text_order_custom_mail': { diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 4519078fc..cdd21c077 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -802,6 +802,7 @@ class InvoiceSettingsForm(SettingsForm): 'invoice_numbers_counter_length', 'invoice_address_explanation_text', 'invoice_email_attachment', + 'invoice_email_asked', 'invoice_email_organizer', 'invoice_address_from_name', 'invoice_address_from', @@ -1170,6 +1171,16 @@ class MailSettingsForm(SettingsForm): required=False, widget=I18nTextarea, ) + mail_subject_invoice_only = I18nFormField( + label=_("Subject"), + required=False, + widget=I18nTextInput, + ) + mail_text_invoice_only = I18nFormField( + label=_("Text"), + required=False, + widget=I18nTextarea, + ) base_context = { 'mail_text_order_placed': ['event', 'order', 'payment'], 'mail_subject_order_placed': ['event', 'order', 'payment'], @@ -1210,6 +1221,8 @@ class MailSettingsForm(SettingsForm): 'mail_subject_waiting_list': ['event', 'waiting_list_entry'], 'mail_text_resend_all_links': ['event', 'orders'], 'mail_subject_resend_all_links': ['event', 'orders'], + 'mail_subject_invoice_only': ['event', 'order', 'invoice'], + 'mail_text_invoice_only': ['event', 'order', 'invoice'], 'mail_attach_ical_description': ['event', 'event_or_subevent'], } diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 118415ead..4c560a56b 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -392,6 +392,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.order.email.order_approved': _('An email has been sent to notify the user that the order has ' 'been approved.'), 'pretix.event.order.email.order_placed': _('An email has been sent to notify the user that the order has been received and requires payment.'), + 'pretix.event.order.email.order_invoice_only': _('An invoice has been sent to an alternate email address.'), 'pretix.event.order.email.order_placed_require_approval': _('An email has been sent to notify the user that ' 'the order has been received and requires ' 'approval.'), diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index f573889f6..efc49300a 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -12,6 +12,7 @@ {% bootstrap_field form.invoice_generate layout="control" %} {% bootstrap_field form.invoice_generate_sales_channels layout="control" %} {% bootstrap_field form.invoice_email_attachment layout="control" %} + {% bootstrap_field form.invoice_email_asked layout="control" %} {% bootstrap_field form.invoice_email_organizer layout="control" %} {% bootstrap_field form.invoice_language layout="control" %} {% bootstrap_field form.invoice_include_free layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/event/mail.html b/src/pretix/control/templates/pretixcontrol/event/mail.html index b3802f2f7..f1ad14043 100644 --- a/src/pretix/control/templates/pretixcontrol/event/mail.html +++ b/src/pretix/control/templates/pretixcontrol/event/mail.html @@ -119,6 +119,10 @@ {% blocktrans asvar title_require_approval %}Order approval process{% endblocktrans %} {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_require_approval items="mail_subject_order_placed_require_approval,mail_text_order_placed_require_approval,mail_subject_order_approved,mail_text_order_approved,mail_subject_order_approved_free,mail_text_order_approved_free,mail_subject_order_denied,mail_text_order_denied" %} + + {% blocktrans asvar title_invoice_only %}Invoice to alternate address{% endblocktrans %} + {% blocktrans asvar description_invoice_only %}This email text is used if you activated "Allow customers to enter an alternate email address" in your invoice settings and a user asks that the invoice is sent to an alternate email address. This email, unlike all other emails, will be sent without any styling to prevent issues with automated processing in accounting systems.{% endblocktrans %} + {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="ticket_reminder" title=title_invoice_only description=description_invoice_only items="mail_subject_invoice_only,mail_text_invoice_only" %}
{{ description }}
+ {% endif %} {% with exclude|split as exclusion %} {% with items|split as item_list %} {% for item in item_list %} diff --git a/src/pretix/control/templates/pretixcontrol/item/create.html b/src/pretix/control/templates/pretixcontrol/item/create.html index 154a58528..8fb4ec27c 100644 --- a/src/pretix/control/templates/pretixcontrol/item/create.html +++ b/src/pretix/control/templates/pretixcontrol/item/create.html @@ -10,7 +10,7 @@