Compare commits

...

1 Commits

Author SHA1 Message Date
Raphael Michel
f243ad2a58 Draft: Alternative email address for invoices 2022-11-15 13:24:33 +01:00
30 changed files with 240 additions and 23 deletions

View File

@@ -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": [

View File

@@ -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',

View File

@@ -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):

View File

@@ -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),

View File

@@ -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={

View File

@@ -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),
),
]

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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': {

View File

@@ -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'],
}

View File

@@ -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.'),

View File

@@ -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" %}

View File

@@ -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" %}
</div>
<h4>{% trans "Attachments" %}</h4>
{% bootstrap_field form.mail_attachment_new_order layout="control" %}

View File

@@ -10,6 +10,9 @@
</summary>
<div id="{{ pid }}">
<div class="panel-body">
{% if description %}
<p class="help-block">{{ description }}</p>
{% endif %}
{% with exclude|split as exclusion %}
{% with items|split as item_list %}
{% for item in item_list %}

View File

@@ -10,7 +10,7 @@
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
<div class="internal-name-wrapper">
<div class="optional-field-wrapper" data-text="{% trans "Use a different name internally" %}">
{% bootstrap_field form.internal_name layout="control" %}
</div>
{% bootstrap_field form.copy_from layout="control" %}

View File

@@ -12,7 +12,7 @@
<legend>{% trans "General" %}</legend>
{% bootstrap_field form.active layout="control" %}
{% bootstrap_field form.name layout="control" %}
<div class="internal-name-wrapper">
<div class="optional-field-wrapper" data-text="{% trans "Use a different name internally" %}">
{% bootstrap_field form.internal_name layout="control" %}
</div>
{% bootstrap_field form.category layout="control" %}

View File

@@ -12,7 +12,7 @@
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
<div class="internal-name-wrapper">
<div class="optional-field-wrapper" data-text="{% trans "Use a different name internally" %}">
{% bootstrap_field form.internal_name layout="control" %}
</div>
{% bootstrap_field form.description layout="control" %}

View File

@@ -923,6 +923,10 @@
{% endif %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% if request.event.settings.invoice_email_asked %}
<dt>{% trans "Send invoice to" %}</dt>
<dd>{{ order.invoice_address.invoice_email }}</dd>
{% endif %}
</dl>
</div>
</div>

View File

@@ -733,7 +733,7 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
idx = matched.group('idx')
if idx in self.supported_locale:
with language(self.supported_locale[idx], self.request.event.settings.region):
if k.startswith('mail_subject_'):
if k.startswith('mail_subject_') or k.endswith('_invoice_only'):
msgs[self.supported_locale[idx]] = bleach.clean(v).format_map(self.placeholders(preview_item))
else:
msgs[self.supported_locale[idx]] = markdown_compile_email(

View File

@@ -1048,7 +1048,8 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
for k in (
"company", "street", "zipcode", "city", "country", "state",
"state_for_address", "vat_id", "custom_field", "internal_reference", "beneficiary"
"state_for_address", "vat_id", "custom_field", "internal_reference",
"beneficiary", "invoice_email",
):
v = getattr(a, k) or ""
# always add all values of an address even when empty,

View File

@@ -105,6 +105,10 @@
{% endif %}
<dt>{% trans "Internal reference" %}</dt>
<dd>{{ addr.internal_reference }}</dd>
{% if request.event.settings.invoice_email_asked and addr.invoice_email %}
<dt>{% trans "Send invoice to" %}</dt>
<dd>{{ addr.invoice_email }}</dd>
{% endif %}
</dl>
</div>
</div>

View File

@@ -68,7 +68,16 @@
{{ event.settings.invoice_address_explanation_text|rich_text }}
</div>
{% endif %}
{% bootstrap_form invoice_form layout="checkout" %}
{% bootstrap_form_errors invoice_form layout="checkout" %}
{% for f in invoice_form %}
{% if f.name == "invoice_email" %}
<div class="optional-field-wrapper" data-text="{% trans "Send invoice to a different email address" %}">
{% bootstrap_field f layout="checkout" %}
</div>
{% else %}
{% bootstrap_field f layout="checkout" %}
{% endif %}
{% endfor %}
</div>
</div>
</details>

View File

@@ -339,6 +339,10 @@
{% endif %}
<dt>{% trans "Internal Reference" %}</dt>
<dd>{{ order.invoice_address.internal_reference }}</dd>
{% if request.event.settings.invoice_email_asked and order.invoice_address.invoice_email %}
<dt>{% trans "Send invoice to" %}</dt>
<dd>{{ order.invoice_address.invoice_email }}</dd>
{% endif %}
{% endif %}
</dl>
</div>

View File

@@ -846,8 +846,8 @@ function setup_basics(el) {
update();
});
// Items and categories
el.find(".internal-name-wrapper").each(function () {
// Hide optional field
el.find(".optional-field-wrapper").each(function () {
if ($(this).find("input").val() === "") {
var $fg = $(this).find(".form-group");
$fg.hide();
@@ -855,7 +855,7 @@ function setup_basics(el) {
$("<div>").addClass("col-md-9 col-md-offset-3").append(
$("<div>").addClass("help-block").append(
$("<a>").attr("href", "#").text(
gettext("Use a different name internally")
$(this).attr("data-text")
).click(function () {
$fg.slideDown();
$fgl.slideUp();

View File

@@ -139,6 +139,28 @@ var form_handlers = function (el) {
});
});
// Hide optional field
el.find(".optional-field-wrapper").each(function () {
if ($(this).find("input").val() === "") {
var $fg = $(this).find(".form-group");
$fg.hide();
var $fgl = $("<div>").addClass("form-group").append(
$("<div>").addClass("col-md-9 col-md-offset-3").append(
$("<div>").addClass("help-block").append(
$("<a>").attr("href", "#").text(
$(this).attr("data-text")
).click(function () {
$fg.slideDown();
$fgl.slideUp();
return false;
})
)
)
);
$(this).append($fgl);
}
});
el.find("input[name*=question], select[name*=question]").change(questions_toggle_dependent);
questions_toggle_dependent();
questions_init_photos(el);

View File

@@ -412,7 +412,11 @@ function questions_init_profiles(el) {
var answer = selectedProfile[key].value;
var $field = selectedProfile[key].field;
if (!$field || !$field.length) return;
if ($field.closest('.optional-field-wrapper').length) {
$field.closest('.optional-field-wrapper').find('.form-group').first().show()
$field.closest('.optional-field-wrapper').find('.form-group').last().hide()
}
if ($field.attr("type") === "checkbox") {
if (answer === true || answer === false) {
// boolean

View File

@@ -323,7 +323,8 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
'vat_id': '',
'vat_id_validated': False,
'internal_reference': '',
'custom_field': None
'custom_field': None,
'invoice_email': None,
},
'positions': [
{

View File

@@ -265,6 +265,7 @@ TEST_ORDER_RES = {
"country": "NZ",
"state": "",
"internal_reference": "",
"invoice_email": None,
"custom_field": "Custom info",
"vat_id": "DE123",
"vat_id_validated": True