Bank transfer: Allow to send the invoice direclty to the accounting department (#2975)

This commit is contained in:
Raphael Michel
2022-12-14 14:08:50 +01:00
committed by GitHub
parent ad1dab3b7f
commit cea6c340be
9 changed files with 305 additions and 27 deletions
+8
View File
@@ -381,6 +381,14 @@ def base_placeholders(sender, **kwargs):
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency '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( SimpleFunctionalMailTextPlaceholder(
'refund_amount', ['event_or_subevent', 'refund_amount'], 'refund_amount', ['event_or_subevent', 'refund_amount'],
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency), lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
+10 -10
View File
@@ -325,16 +325,6 @@ class BasePaymentProvider:
help_text=_('Users will not be able to choose this payment provider after the given date.'), help_text=_('Users will not be able to choose this payment provider after the given date.'),
required=False, required=False,
)), )),
('_invoice_text',
I18nFormField(
label=_('Text on invoices'),
help_text=_('Will be printed just below the payment figures and above the closing text on invoices. '
'This will only be used if the invoice is generated before the order is paid. If the '
'invoice is generated later, it will show a text stating that it has already been paid.'),
required=False,
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_total_min', ('_total_min',
forms.DecimalField( forms.DecimalField(
label=_('Minimum order total'), label=_('Minimum order total'),
@@ -382,6 +372,16 @@ class BasePaymentProvider:
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'), 'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
required=False required=False
)), )),
('_invoice_text',
I18nFormField(
label=_('Text on invoices'),
help_text=_('Will be printed just below the payment figures and above the closing text on invoices. '
'This will only be used if the invoice is generated before the order is paid. If the '
'invoice is generated later, it will show a text stating that it has already been paid.'),
required=False,
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_restricted_countries', ('_restricted_countries',
forms.MultipleChoiceField( forms.MultipleChoiceField(
label=_('Restrict to countries'), label=_('Restrict to countries'),
+17 -6
View File
@@ -86,7 +86,6 @@ INVALID_ADDRESS = 'invalid-pretix-mail-address'
class TolerantDict(dict): class TolerantDict(dict):
# kept for backwards compatibility with plugins
def __missing__(self, key): def __missing__(self, key):
return key return key
@@ -100,7 +99,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, 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, 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, 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_only=False, no_order_links=False):
""" """
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -150,12 +150,21 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param attach_other_files: A list of file paths on our storage to attach. :param attach_other_files: A list of file paths on our storage to attach.
:param plain_text_only: If set to ``True``, rendering a HTML version will be skipped.
:param no_order_links: If set to ``True``, no link to the order confirmation page will be auto-appended. Currently
only allowed to use together with ``plain_text_only`` since HTML renderers add their own
links.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean :raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend. that the email has been sent, just that it has been queued by the email backend.
""" """
if email == INVALID_ADDRESS: if email == INVALID_ADDRESS:
return return
if no_order_links and not plain_text_only:
raise ValueError('If you set no_order_links, you also need to set plain_text_only.')
headers = headers or {} headers = headers or {}
if auto_email: if auto_email:
headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN' headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN'
@@ -179,7 +188,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
}) })
renderer = ClassicMailRenderer(None, organizer) renderer = ClassicMailRenderer(None, organizer)
content_plain = body_plain = render_mail(template, context) content_plain = body_plain = render_mail(template, context)
subject = format_map(str(subject), context) subject = str(subject).format_map(TolerantDict(context))
sender = ( sender = (
sender or sender or
(event.settings.get('mail_from') if event else None) or (event.settings.get('mail_from') if event else None) or
@@ -244,7 +253,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
if order and order.testmode: if order and order.testmode:
subject = "[TESTMODE] " + subject subject = "[TESTMODE] " + subject
if order and position: if order and position and not no_order_links:
body_plain += _( body_plain += _(
"You are receiving this email because someone placed an order for {event} for you." "You are receiving this email because someone placed an order for {event} for you."
).format(event=event.name) ).format(event=event.name)
@@ -260,7 +269,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
} }
) )
) )
elif order: elif order and not no_order_links:
body_plain += _( body_plain += _(
"You are receiving this email because you placed an order for {event}." "You are receiving this email because you placed an order for {event}."
).format(event=event.name) ).format(event=event.name)
@@ -280,7 +289,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
with override(timezone): with override(timezone):
try: try:
if 'position' in inspect.signature(renderer.render).parameters: if plain_text_only:
body_html = None
elif 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position) body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else: else:
# Backwards compatibility # Backwards compatibility
+185 -7
View File
@@ -41,6 +41,7 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.http import HttpRequest from django.http import HttpRequest
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from i18nfield.fields import I18nFormField, I18nTextarea from i18nfield.fields import I18nFormField, I18nTextarea
from i18nfield.forms import I18nTextInput from i18nfield.forms import I18nTextInput
@@ -49,8 +50,13 @@ from localflavor.generic.forms import BICFormField, IBANFormField
from localflavor.generic.validators import IBANValidator from localflavor.generic.validators import IBANValidator
from text_unidecode import unidecode from text_unidecode import unidecode
from pretix.base.email import get_available_placeholders, get_email_context
from pretix.base.forms import PlaceholderValidator
from pretix.base.i18n import language
from pretix.base.models import Order, OrderPayment, OrderRefund from pretix.base.models import Order, OrderPayment, OrderRefund
from pretix.base.payment import BasePaymentProvider from pretix.base.payment import BasePaymentProvider
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.helpers.format import format_map
from pretix.plugins.banktransfer.templatetags.ibanformat import ibanformat from pretix.plugins.banktransfer.templatetags.ibanformat import ibanformat
@@ -81,7 +87,8 @@ class BankTransfer(BasePaymentProvider):
)), )),
('bank_details_sepa_name', forms.CharField( ('bank_details_sepa_name', forms.CharField(
label=_('Name of account holder'), label=_('Name of account holder'),
help_text=_('Please note: special characters other than letters, numbers, and some punctuation can cause problems with some banks.'), help_text=_(
'Please note: special characters other than letters, numbers, and some punctuation can cause problems with some banks.'),
widget=forms.TextInput( widget=forms.TextInput(
attrs={ attrs={
'data-display-dependency': '#id_payment_banktransfer_bank_details_type_0', 'data-display-dependency': '#id_payment_banktransfer_bank_details_type_0',
@@ -166,12 +173,15 @@ class BankTransfer(BasePaymentProvider):
help_text=_('This text will be shown on the order confirmation page for pending orders in addition to ' help_text=_('This text will be shown on the order confirmation page for pending orders in addition to '
'the standard text.'), 'the standard text.'),
widget=I18nTextarea, widget=I18nTextarea,
widget_kwargs={'attrs': {
'rows': '2',
}},
required=False, required=False,
)), )),
('refund_iban_blocklist', forms.CharField( ('refund_iban_blocklist', forms.CharField(
label=_('IBAN blocklist for refunds'), label=_('IBAN blocklist for refunds'),
required=False, required=False,
widget=forms.Textarea, widget=forms.Textarea(attrs={'rows': 4}),
help_text=_('Put one IBAN or IBAN prefix per line. The system will not attempt to send refunds to any ' help_text=_('Put one IBAN or IBAN prefix per line. The system will not attempt to send refunds to any '
'of these IBANs. Useful e.g. if you receive a lot of "forwarded payments" by a third-party payment ' 'of these IBANs. Useful e.g. if you receive a lot of "forwarded payments" by a third-party payment '
'provider. You can also list country codes such as "GB" if you never want to send refunds to ' 'provider. You can also list country codes such as "GB" if you never want to send refunds to '
@@ -194,7 +204,54 @@ class BankTransfer(BasePaymentProvider):
@property @property
def settings_form_fields(self): def settings_form_fields(self):
d = OrderedDict(list(super().settings_form_fields.items()) + list(BankTransfer.form_fields().items())) phs = [
'{%s}' % p
for p in sorted(get_available_placeholders(self.event, ['event', 'order', 'invoice']).keys())
]
phs_ht = _('Available placeholders: {list}').format(
list=', '.join(phs)
)
more_fields = OrderedDict([
('invoice_email',
forms.BooleanField(
label=_('Allow users to enter an additional email address that the invoice will be sent to.'),
help_text=_(
'This requires that the invoice creation settings allow the invoice to be created right after '
'the payment method was chosen. Only the invoice will be sent to this email address, subsequent '
'invoice corrections will not be sent automatically. Only the invoice will be sent, no additional '
'information.'
),
required=False,
)),
('invoice_email_subject',
I18nFormField(
label=_('Invoice email subject'),
widget=I18nTextInput,
widget_kwargs={'attrs': {
'data-display-dependency': '#id_payment_banktransfer_invoice_email',
'data-required-if': '#id_payment_banktransfer_invoice_email',
}},
validators=[PlaceholderValidator(phs)],
help_text=phs_ht,
required=False
)),
('invoice_email_text',
I18nFormField(
label=_('Invoice email text'),
widget=I18nTextarea,
widget_kwargs={'attrs': {
'rows': '8',
'data-display-dependency': '#id_payment_banktransfer_invoice_email',
'data-required-if': '#id_payment_banktransfer_invoice_email',
}},
validators=[PlaceholderValidator(phs)],
help_text=phs_ht,
required=False
)),
])
d = OrderedDict(list(super().settings_form_fields.items()) + list(BankTransfer.form_fields().items()) + list(more_fields.items()))
d.move_to_end('invoice_immediately', last=False)
d.move_to_end('bank_details', last=False) d.move_to_end('bank_details', last=False)
d.move_to_end('bank_details_sepa_bank', last=False) d.move_to_end('bank_details_sepa_bank', last=False)
d.move_to_end('bank_details_sepa_bic', last=False) d.move_to_end('bank_details_sepa_bic', last=False)
@@ -207,7 +264,10 @@ class BankTransfer(BasePaymentProvider):
def settings_form_clean(self, cleaned_data): def settings_form_clean(self, cleaned_data):
if cleaned_data.get('payment_banktransfer_bank_details_type') == 'sepa': if cleaned_data.get('payment_banktransfer_bank_details_type') == 'sepa':
for f in ('bank_details_sepa_name', 'bank_details_sepa_bank', 'bank_details_sepa_bic', 'bank_details_sepa_iban'): for f in (
'bank_details_sepa_name', 'bank_details_sepa_bank', 'bank_details_sepa_bic',
'bank_details_sepa_iban'
):
if not cleaned_data.get('payment_banktransfer_%s' % f): if not cleaned_data.get('payment_banktransfer_%s' % f):
raise ValidationError( raise ValidationError(
{'payment_banktransfer_%s' % f: _('Please fill out your bank account details.')}) {'payment_banktransfer_%s' % f: _('Please fill out your bank account details.')})
@@ -217,10 +277,45 @@ class BankTransfer(BasePaymentProvider):
{'payment_banktransfer_bank_details': _('Please enter your bank account details.')}) {'payment_banktransfer_bank_details': _('Please enter your bank account details.')})
return cleaned_data return cleaned_data
@cached_property
def _invoice_email_asked(self):
return (
self.settings.get('invoice_email', as_type=bool) and
(self.event.settings.invoice_generate == 'True' or (
self.event.settings.invoice_generate == 'paid' and
self.settings.get('invoice_immediately', as_type=bool)
))
)
@property
def payment_form_fields(self) -> dict:
if self._invoice_email_asked:
return {
'send_invoice': forms.BooleanField(
label=_('Please send my invoice directly to our accounting department'),
required=False,
),
'send_invoice_to': forms.EmailField(
label=_('Invoice recipient e-mail'),
required=False,
help_text=_('The invoice recipient will receive an email which includes the invoice and your email '
'address so they know who placed this order.'),
widget=forms.EmailInput(
attrs={
'data-display-dependency': '#id_payment_banktransfer-send_invoice',
}
)
),
}
else:
return {}
def payment_form_render(self, request, total=None, order=None) -> str: def payment_form_render(self, request, total=None, order=None) -> str:
template = get_template('pretixplugins/banktransfer/checkout_payment_form.html') template = get_template('pretixplugins/banktransfer/checkout_payment_form.html')
form = self.payment_form(request)
ctx = { ctx = {
'request': request, 'request': request,
'form': form,
'event': self.event, 'event': self.event,
'settings': self.settings, 'settings': self.settings,
'code': self._code(order) if order else None, 'code': self._code(order) if order else None,
@@ -229,16 +324,97 @@ class BankTransfer(BasePaymentProvider):
return template.render(ctx) return template.render(ctx)
def checkout_prepare(self, request, total): def checkout_prepare(self, request, total):
return True form = self.payment_form(request)
if form.is_valid():
for k, v in form.cleaned_data.items():
request.session['payment_%s_%s' % (self.identifier, k)] = v
return True
else:
return False
def send_invoice_to_alternate_email(self, order, invoice, email):
"""
Sends an email to the alternate invoice address.
"""
with language(order.locale, self.event.settings.region):
context = get_email_context(event=self.event,
order=order,
invoice=invoice,
event_or_subevent=self.event,
invoice_address=order.invoice_address)
template = self.settings.get('invoice_email_text', as_type=LazyI18nString)
subject = self.settings.get('invoice_email_subject', as_type=LazyI18nString)
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
email,
subject,
template,
context=context,
event=self.event,
locale=order.locale,
order=order,
invoices=[invoice],
attach_tickets=False,
auto_email=True,
attach_ical=False,
plain_text_only=True,
no_order_links=True,
)
except SendMailException:
raise
else:
order.log_action(
'pretix.plugins.banktransfer.order.email.invoice',
data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': email,
'invoices': invoice.pk,
'attach_tickets': False,
'attach_ical': False,
}
)
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
send_invoice = (
self._invoice_email_asked and
request and
request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice')) and
request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice_to'))
)
if send_invoice:
recipient = request.session.get('payment_%s_%s' % (self.identifier, 'send_invoice_to'))
payment.info_data = {
'send_invoice_to': recipient,
}
payment.save(update_fields=['info'])
i = payment.order.invoices.filter(is_cancellation=False).last()
if i:
self.send_invoice_to_alternate_email(payment.order, i, recipient)
if request:
request.session.pop('payment_%s_%s' % (self.identifier, 'send_invoice'), None)
request.session.pop('payment_%s_%s' % (self.identifier, 'send_invoice_to'), None)
def payment_prepare(self, request: HttpRequest, payment: OrderPayment): def payment_prepare(self, request: HttpRequest, payment: OrderPayment):
return True return self.checkout_prepare(request, payment.amount)
def payment_is_valid_session(self, request): def payment_is_valid_session(self, request):
return True return True
def checkout_confirm_render(self, request, order=None): def checkout_confirm_render(self, request, order=None):
return self.payment_form_render(request, order=order) template = get_template('pretixplugins/banktransfer/checkout_confirm.html')
ctx = {
'request': request,
'event': self.event,
'settings': self.settings,
'code': self._code(order) if order else None,
'details': self.settings.get('bank_details', as_type=LazyI18nString),
}
return template.render(ctx)
def order_pending_mail_render(self, order, payment) -> str: def order_pending_mail_render(self, order, payment) -> str:
template = get_template('pretixplugins/banktransfer/email/order_pending.txt') template = get_template('pretixplugins/banktransfer/email/order_pending.txt')
@@ -355,6 +531,8 @@ class BankTransfer(BasePaymentProvider):
d = obj.info_data d = obj.info_data
d['reference'] = '' d['reference'] = ''
d['payer'] = '' d['payer'] = ''
if 'send_invoice_to' in d:
d['send_invoice_to'] = ''
d['_shredded'] = True d['_shredded'] = True
obj.info = json.dumps(d) obj.info = json.dumps(d)
obj.save(update_fields=['info']) obj.save(update_fields=['info'])
+31 -2
View File
@@ -22,11 +22,13 @@
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import get_template from django.template.loader import get_template
from django.urls import resolve, reverse from django.urls import resolve, reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _, gettext_noop
from i18nfield.strings import LazyI18nString
from pretix.base.signals import register_payment_providers from pretix.base.signals import logentry_display, register_payment_providers
from pretix.control.signals import html_head, nav_event, nav_organizer from pretix.control.signals import html_head, nav_event, nav_organizer
from ...base.settings import settings_hierarkey
from .payment import BankTransfer from .payment import BankTransfer
@@ -113,3 +115,30 @@ def html_head_presale(sender, request=None, **kwargs):
return template.render({}) return template.render({})
else: else:
return "" return ""
@receiver(signal=logentry_display)
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
plains = {
'pretix.plugins.banktransfer.order.email.invoice': _('The invoice was sent to the designated email address.'),
}
if logentry.action_type in plains:
return plains[logentry.action_type]
settings_hierarkey.add_default(
'payment_banktransfer_invoice_email_subject',
default_type=LazyI18nString,
value=LazyI18nString.from_gettext(gettext_noop("Invoice {invoice_number}"))
)
settings_hierarkey.add_default(
'payment_banktransfer_invoice_email_text',
default_type=LazyI18nString,
value=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"""))
)
+1
View File
@@ -206,6 +206,7 @@ def _handle_transaction(trans: BankTransaction, matches: tuple, event: Event = N
).last() ).last()
p.info_data = { p.info_data = {
**p.info_data,
'reference': trans.reference, 'reference': trans.reference,
'date': trans.date_parsed.isoformat() if trans.date_parsed else trans.date, 'date': trans.date_parsed.isoformat() if trans.date_parsed else trans.date,
'payer': trans.payer, 'payer': trans.payer,
@@ -0,0 +1,39 @@
{% load i18n %}
{% load ibanformat %}
{% load bootstrap3 %}
<p>{% blocktrans trimmed %}
After completing your purchase, we will ask you to transfer the money to the following
bank account, using a personal reference code:
{% endblocktrans %}</p>
{% if settings.bank_details_type == "sepa" %}
<dl class="dl-horizontal">
<dt>{% trans "Account holder" %}: </dt><dd>{{ settings.bank_details_sepa_name }}</dd>
<dt>{% trans "IBAN" %}: </dt><dd>{{ settings.bank_details_sepa_iban|ibanformat }}</dd>
<dt>{% trans "BIC" %}: </dt><dd>{{ settings.bank_details_sepa_bic }}</dd>
<dt>{% trans "Bank" %}: </dt><dd>{{ settings.bank_details_sepa_bank }}</dd>
</dl>
{% endif %}
{% if details %}
{{ details|linebreaks }}
{% endif %}
{% if code %}
<dl class="dl-horizontal">
<dt>{% trans "Reference code (important):" %} </dt><dd><strong>{{ code }}</strong></dd>
</dl>
{% else %}
<p>
<strong>
{% trans "We will assign you a personal reference code to use after you completed the order." %}
</strong>
</p>
{% endif %}
{% if request.session.payment_banktransfer_send_invoice and request.session.payment_banktransfer_send_invoice_to %}
<p>
{% blocktrans trimmed with recipient=request.session.payment_banktransfer_send_invoice_to %}
We will send a copy of your invoice directly to {{ recipient }}.
{% endblocktrans %}
</p>
{% endif %}
@@ -1,5 +1,6 @@
{% load i18n %} {% load i18n %}
{% load ibanformat %} {% load ibanformat %}
{% load bootstrap3 %}
<p>{% blocktrans trimmed %} <p>{% blocktrans trimmed %}
After completing your purchase, we will ask you to transfer the money to the following After completing your purchase, we will ask you to transfer the money to the following
@@ -29,3 +30,8 @@
</strong> </strong>
</p> </p>
{% endif %} {% endif %}
{% if form.fields %}
<div class="col-md-12">
{% bootstrap_form form layout='inline' %}
</div>
{% endif %}
@@ -2,8 +2,14 @@
{% if payment_info %} {% if payment_info %}
<dl class="dl-horizontal"> <dl class="dl-horizontal">
<dt>{% trans "Payer" %}</dt> {% if payment_info.send_invoice_to %}
<dd>{{ payment_info.payer }}</dd> <dt>{% trans "Send invoice to" %}</dt>
<dd>{{ payment_info.send_invoice_to }}</dd>
{% endif %}
{% if payment_info.payer %}
<dt>{% trans "Payer" %}</dt>
<dd>{{ payment_info.payer }}</dd>
{% endif %}
{% if payment_info.iban %} {% if payment_info.iban %}
<dt>{% trans "Account" %} <dt>{% trans "Account" %}
<dd> <dd>