# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-2021 rami.io GmbH and contributors # # This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General # Public License as published by the Free Software Foundation in version 3 of the License. # # ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are # applicable granting you additional permissions and placing additional restrictions on your usage of this software. # Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive # this file, see . # # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # import logging import re import unicodedata from collections import defaultdict from decimal import Decimal from io import BytesIO from itertools import groupby from typing import Tuple import bleach import vat_moss.exchange_rates from bidi.algorithm import get_display from django.contrib.staticfiles import finders from django.db.models import Sum from django.dispatch import receiver from django.utils.formats import date_format, localize from django.utils.translation import ( get_language, gettext, gettext_lazy, pgettext, ) from reportlab.lib import colors, pagesizes from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT from reportlab.lib.styles import ParagraphStyle, StyleSheet1 from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import ( BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate, PageTemplate, Paragraph, Spacer, Table, TableStyle, ) from pretix.base.decimal import round_decimal from pretix.base.models import Event, Invoice, Order, OrderPayment from pretix.base.services.currencies import SOURCE_NAMES from pretix.base.signals import register_invoice_renderers from pretix.base.templatetags.money import money_filter from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper from pretix.presale.style import get_fonts logger = logging.getLogger(__name__) class NumberedCanvas(Canvas): def __init__(self, *args, **kwargs): self.font_regular = kwargs.pop('font_regular') super().__init__(*args, **kwargs) self._saved_page_states = [] def showPage(self): self._saved_page_states.append(dict(self.__dict__)) self._startPage() def save(self): num_pages = len(self._saved_page_states) for state in self._saved_page_states: self.__dict__.update(state) self.draw_page_number(num_pages) Canvas.showPage(self) Canvas.save(self) def draw_page_number(self, page_count): self.saveState() self.setFont(self.font_regular, 8) text = pgettext("invoice", "Page %d of %d") % (self._pageNumber, page_count,) try: text = get_display(reshaper.reshape(text)) except: logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) self.drawRightString(self._pagesize[0] - 20 * mm, 10 * mm, text) self.restoreState() class BaseInvoiceRenderer: """ This is the base class for all invoice renderers. """ def __init__(self, event: Event): self.event = event def __str__(self): return self.identifier def generate(self, invoice: Invoice) -> Tuple[str, str, str]: """ This method should generate the invoice file and return a tuple consisting of a filename, a file type and file content. The extension will be taken from the filename which is otherwise ignored. """ raise NotImplementedError() @property def verbose_name(self) -> str: """ A human-readable name for this renderer. This should be short but self-explanatory. Good examples include 'German DIN 5008' or 'Italian invoice'. """ raise NotImplementedError() # NOQA @property def identifier(self) -> str: """ A short and unique identifier for this renderer. This should only contain lowercase letters and in most cases will be the same as your package name. """ raise NotImplementedError() # NOQA class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): """ This is a convenience class to avoid duplicate code when implementing invoice renderers that are based on reportlab. """ pagesize = pagesizes.A4 left_margin = 25 * mm right_margin = 20 * mm top_margin = 20 * mm bottom_margin = 15 * mm doc_template_class = BaseDocTemplate canvas_class = Canvas font_regular = 'OpenSans' font_bold = 'OpenSansBd' def _init(self): """ Initialize the renderer. By default, this registers fonts and sets ``self.stylesheet``. """ self._register_fonts() self.stylesheet = self._get_stylesheet() def _get_stylesheet(self): """ Get a stylesheet. By default, this contains the "Normal" and "Heading1" styles. """ stylesheet = StyleSheet1() stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12)) stylesheet.add(ParagraphStyle(name='Bold', fontName=self.font_bold, fontSize=10, leading=12)) stylesheet.add(ParagraphStyle(name='BoldRight', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT)) stylesheet.add(ParagraphStyle(name='BoldRightNoSplit', fontName=self.font_bold, fontSize=10, leading=12, alignment=TA_RIGHT, splitLongWords=False)) stylesheet.add(ParagraphStyle(name='NormalRight', fontName=self.font_regular, fontSize=10, leading=12, alignment=TA_RIGHT)) stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12, textColor=colors.white, alignment=TA_CENTER)) stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal'])) stylesheet.add(ParagraphStyle(name='Heading1', fontName=self.font_bold, fontSize=15, leading=15 * 1.2)) stylesheet.add(ParagraphStyle(name='FineprintHeading', fontName=self.font_bold, fontSize=8, leading=12)) stylesheet.add(ParagraphStyle(name='Fineprint', fontName=self.font_regular, fontSize=8, leading=10)) stylesheet.add(ParagraphStyle(name='FineprintRight', fontName=self.font_regular, fontSize=8, leading=10, alignment=TA_RIGHT)) return stylesheet def _register_fonts(self): """ Register fonts with reportlab. By default, this registers the OpenSans font family """ pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))) pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))) pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))) pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))) pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', italic='OpenSansIt', boldItalic='OpenSansBI') for family, styles in get_fonts(event=self.event, pdf_support_required=True).items(): if family == self.event.settings.invoice_renderer_font: pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype']))) self.font_regular = family if 'italic' in styles: pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype']))) if 'bold' in styles: pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype']))) self.font_bold = family + ' B' if 'bolditalic' in styles: pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype']))) def _normalize(self, text): # reportlab does not support unicode combination characters # It's important we do this before we use ArabicReshaper text = unicodedata.normalize("NFKC", text) # reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper # to resolve all ligatures and python-bidi to switch RTL texts. try: text = "
".join(get_display(reshaper.reshape(l)) for l in re.split("
", text)) except: logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text))) return text def _upper(self, val): # We uppercase labels, but not in every language if get_language().startswith('el'): return val return val.upper() def _on_other_page(self, canvas: Canvas, doc): """ Called when a new page is rendered that is *not* the first page. """ pass def _on_first_page(self, canvas: Canvas, doc): """ Called when a new page is rendered that is the first page. """ pass def _get_story(self, doc): """ Called to create the story to be inserted into the main frames. """ raise NotImplementedError() def _get_first_page_frames(self, doc): """ Called to create a list of frames for the first page. """ return [ Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, id='normal') ] def _get_other_page_frames(self, doc): """ Called to create a list of frames for the other pages. """ return [ Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, id='normal') ] def _build_doc(self, fhandle): """ Build a PDF document in a given file handle """ self._init() doc = self.doc_template_class(fhandle, pagesize=self.pagesize, leftMargin=self.left_margin, rightMargin=self.right_margin, topMargin=self.top_margin, bottomMargin=self.bottom_margin) doc.addPageTemplates([ PageTemplate( id='FirstPage', frames=self._get_first_page_frames(doc), onPage=self._on_first_page, pagesize=self.pagesize ), PageTemplate( id='OtherPages', frames=self._get_other_page_frames(doc), onPage=self._on_other_page, pagesize=self.pagesize ) ]) story = self._get_story(doc) doc.build(story, canvasmaker=self.canvas_class) return doc def generate(self, invoice: Invoice): self.invoice = invoice buffer = BytesIO() self._build_doc(buffer) buffer.seek(0) return 'invoice.pdf', 'application/pdf', buffer.read() def _clean_text(self, text, tags=None): return self._normalize(bleach.clean( text, tags=tags or [] ).strip().replace('
', '
').replace('\n', '
\n')) class PaidMarker(Flowable): def __init__(self, text='paid', color=None, font='OpenSansBd', size=20): super().__init__() self.text = text self.color = color self.font = font self.size = size self._showBoundary = True def wrap(self, availwidth, availheight): # Fake a size, we don't care if we exceed the table return 10, self.size / 2 def draw(self): self.canv.translate(0, - self.size / 2) self.canv.rotate(2) self.canv.setFont(self.font, self.size) self.canv.setFillColor(self.color) width = self.canv.stringWidth(self.text, self.font, self.size) self.canv.drawRightString(0, 0, self.text) self.canv.setStrokeColor(self.color) self.canv.roundRect(-width - self.size / 2, -self.size / 4, width + self.size, self.size + self.size / 4, 3) class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): identifier = 'classic' verbose_name = pgettext('invoice', 'Classic renderer (pretix 1.0)') def canvas_class(self, *args, **kwargs): kwargs['font_regular'] = self.font_regular return NumberedCanvas(*args, **kwargs) def _on_other_page(self, canvas: Canvas, doc): canvas.saveState() canvas.setFont(self.font_regular, 8) for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) canvas.restoreState() invoice_to_width = 85 * mm invoice_to_height = 50 * mm invoice_to_left = 25 * mm invoice_to_top = 52 * mm def _draw_invoice_to(self, canvas): p = Paragraph(self._clean_text(self.invoice.address_invoice_to), style=self.stylesheet['Normal']) p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height) p_size = p.wrap(self.invoice_to_width, self.invoice_to_height) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top) invoice_from_width = 70 * mm invoice_from_height = 50 * mm invoice_from_left = 25 * mm invoice_from_top = 17 * mm def _draw_invoice_from(self, canvas): p = Paragraph( self._clean_text(self.invoice.full_invoice_from), style=self.stylesheet['InvoiceFrom'] ) p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height) p_size = p.wrap(self.invoice_from_width, self.invoice_from_height) p.drawOn(canvas, self.invoice_from_left, self.pagesize[1] - p_size[1] - self.invoice_from_top) def _draw_invoice_from_label(self, canvas): textobject = canvas.beginText(25 * mm, (297 - 15) * mm) textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice from')))) canvas.drawText(textobject) def _draw_invoice_to_label(self, canvas): textobject = canvas.beginText(25 * mm, (297 - 50) * mm) textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice to')))) canvas.drawText(textobject) logo_width = 25 * mm logo_height = 25 * mm logo_left = 95 * mm logo_top = 13 * mm logo_anchor = 'n' def _draw_logo(self, canvas): if self.invoice.event.settings.invoice_logo_image: logo_file = self.invoice.event.settings.get('invoice_logo_image', binary_file=True) ir = ThumbnailingImageReader(logo_file) try: ir.resize(self.logo_width, self.logo_height, 300) except: logger.exception("Can not resize image") pass canvas.drawImage(ir, self.logo_left, self.pagesize[1] - self.logo_height - self.logo_top, width=self.logo_width, height=self.logo_height, preserveAspectRatio=True, anchor=self.logo_anchor, mask='auto') def _draw_metadata(self, canvas): textobject = canvas.beginText(125 * mm, (297 - 38) * mm) textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Order code')))) textobject.moveCursor(0, 5) textobject.setFont(self.font_regular, 10) textobject.textLine(self._normalize(self.invoice.order.full_code)) canvas.drawText(textobject) textobject = canvas.beginText(125 * mm, (297 - 50) * mm) textobject.setFont(self.font_bold, 8) if self.invoice.is_cancellation: textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation number')))) textobject.moveCursor(0, 5) textobject.setFont(self.font_regular, 10) textobject.textLine(self._normalize(self.invoice.number)) textobject.moveCursor(0, 5) textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice')))) textobject.moveCursor(0, 5) textobject.setFont(self.font_regular, 10) textobject.textLine(self._normalize(self.invoice.refers.number)) else: textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice number')))) textobject.moveCursor(0, 5) textobject.setFont(self.font_regular, 10) textobject.textLine(self._normalize(self.invoice.number)) textobject.moveCursor(0, 5) if self.invoice.is_cancellation: textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Cancellation date')))) textobject.moveCursor(0, 5) textobject.setFont(self.font_regular, 10) textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) textobject.moveCursor(0, 5) textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Original invoice date')))) textobject.moveCursor(0, 5) textobject.setFont(self.font_regular, 10) textobject.textLine(self._normalize(date_format(self.invoice.refers.date, "DATE_FORMAT"))) textobject.moveCursor(0, 5) else: textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Invoice date')))) textobject.moveCursor(0, 5) textobject.setFont(self.font_regular, 10) textobject.textLine(self._normalize(date_format(self.invoice.date, "DATE_FORMAT"))) textobject.moveCursor(0, 5) canvas.drawText(textobject) event_left = 125 * mm event_top = 17 * mm event_width = 65 * mm event_height = 50 * mm def _draw_event_label(self, canvas): textobject = canvas.beginText(125 * mm, (297 - 15) * mm) textobject.setFont(self.font_bold, 8) textobject.textLine(self._normalize(self._upper(pgettext('invoice', 'Event')))) canvas.drawText(textobject) def _draw_event(self, canvas): def shorten(txt): txt = str(txt) txt = bleach.clean(txt, tags=[]).strip() p = Paragraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) p_size = p.wrap(self.event_width, self.event_height) while p_size[1] > 2 * self.stylesheet['Normal'].leading: txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…' p = Paragraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) 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() ) 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) ) else: p_str = shorten(self.invoice.event.name) p = Paragraph(self._normalize(p_str.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) p.wrapOn(canvas, self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height) p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1]) self._draw_event_label(canvas) def _draw_footer(self, canvas): canvas.setFont(self.font_regular, 8) for i, line in enumerate(self.invoice.footer_text.split('\n')[::-1]): canvas.drawCentredString(self.pagesize[0] / 2, 25 + (3.5 * i) * mm, self._normalize(line.strip())) def _draw_testmode(self, canvas): if self.invoice.order.testmode: canvas.saveState() canvas.setFont(self.font_bold, 30) canvas.setFillColorRGB(32, 0, 0) canvas.drawRightString(self.pagesize[0] - 20 * mm, (297 - 100) * mm, self._normalize(gettext('TEST MODE'))) canvas.restoreState() def _on_first_page(self, canvas: Canvas, doc): canvas.setCreator('pretix.eu') canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number)) canvas.saveState() self._draw_footer(canvas) self._draw_testmode(canvas) self._draw_invoice_from_label(canvas) self._draw_invoice_from(canvas) self._draw_invoice_to_label(canvas) self._draw_invoice_to(canvas) self._draw_metadata(canvas) self._draw_logo(canvas) self._draw_event(canvas) canvas.restoreState() def _get_first_page_frames(self, doc): footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm return [ Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, id='normal') ] def _get_other_page_frames(self, doc): footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm return [ Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, id='normal') ] def _get_intro(self): story = [] if self.invoice.custom_field: story.append(Paragraph( '{}: {}'.format( self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)), self._clean_text(self.invoice.custom_field), ), self.stylesheet['Normal'] )) if self.invoice.internal_reference: story.append(Paragraph( self._normalize(pgettext('invoice', 'Customer reference: {reference}').format( reference=self._clean_text(self.invoice.internal_reference), )), self.stylesheet['Normal'] )) if self.invoice.invoice_to_vat_id: story.append(Paragraph( self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' + self._clean_text(self.invoice.invoice_to_vat_id), self.stylesheet['Normal'] )) if self.invoice.invoice_to_beneficiary: story.append(Paragraph( self._normalize(pgettext('invoice', 'Beneficiary')) + ':
' + self._clean_text(self.invoice.invoice_to_beneficiary), self.stylesheet['Normal'] )) if self.invoice.introductory_text: story.append(Paragraph( self._clean_text(self.invoice.introductory_text, tags=['br']), self.stylesheet['Normal'] )) story.append(Spacer(1, 10 * mm)) return story def _get_story(self, doc): has_taxes = any(il.tax_value for il in self.invoice.lines.all()) or self.invoice.reverse_charge story = [ NextPageTemplate('FirstPage'), Paragraph( self._normalize( pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU' else pgettext('invoice', 'Invoice') ) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')), self.stylesheet['Heading1'] ), Spacer(1, 5 * mm), NextPageTemplate('OtherPages'), ] story += self._get_intro() taxvalue_map = defaultdict(Decimal) grossvalue_map = defaultdict(Decimal) tstyledata = [ ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('FONTNAME', (0, 0), (-1, -1), self.font_regular), ('FONTNAME', (0, 0), (-1, 0), self.font_bold), ('FONTNAME', (0, -1), (-1, -1), self.font_bold), ('LEFTPADDING', (0, 0), (0, -1), 0), ('RIGHTPADDING', (-1, 0), (-1, -1), 0), ] if has_taxes: tdata = [( Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']), Paragraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']), Paragraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']), )] else: tdata = [( Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']), Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']), Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']), )] 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) total = Decimal('0.00') for (description, tax_rate, tax_name, net_value, gross_value, *ignored), lines in groupby(self.invoice.lines.all(), key=_group_key): lines = list(lines) if has_taxes: if len(lines) > 1: single_price_line = pgettext('invoice', 'Single price: {net_price} net / {gross_price} gross').format( net_price=money_filter(net_value, self.invoice.event.currency), gross_price=money_filter(gross_value, self.invoice.event.currency), ) description = description + "\n" + single_price_line tdata.append(( Paragraph( self._clean_text(description, tags=['br']), self.stylesheet['Normal'] ), str(len(lines)), localize(tax_rate) + " %", Paragraph(money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']), Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']), )) else: if len(lines) > 1: single_price_line = pgettext('invoice', 'Single price: {price}').format( price=money_filter(gross_value, self.invoice.event.currency), ) description = description + "\n" + single_price_line tdata.append(( Paragraph( self._clean_text(description, tags=['br']), self.stylesheet['Normal'] ), str(len(lines)), Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']), )) taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines) grossvalue_map[tax_rate, tax_name] += gross_value * len(lines) total += gross_value * len(lines) if has_taxes: tdata.append([ Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '', money_filter(total, self.invoice.event.currency) ]) colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)] else: tdata.append([ Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', money_filter(total, self.invoice.event.currency) ]) colwidths = [a * doc.width for a in (.65, .20, .15)] if not self.invoice.is_cancellation: if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING: pending_sum = self.invoice.order.pending_sum if pending_sum != total: tdata.append( [Paragraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] + (['', '', ''] if has_taxes else ['']) + [money_filter(pending_sum - total, self.invoice.event.currency)] ) tdata.append( [Paragraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] + (['', '', ''] if has_taxes else ['']) + [money_filter(pending_sum, self.invoice.event.currency)] ) tstyledata += [ ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), ] elif self.invoice.event.settings.invoice_show_payments and self.invoice.order.payments.filter( state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), provider='giftcard' ).exists(): giftcard_sum = self.invoice.order.payments.filter( state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), provider='giftcard' ).aggregate( s=Sum('amount') )['s'] or Decimal('0.00') tdata.append( [Paragraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] + (['', '', ''] if has_taxes else ['']) + [money_filter(giftcard_sum, self.invoice.event.currency)] ) tdata.append( [Paragraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] + (['', '', ''] if has_taxes else ['']) + [money_filter(total - giftcard_sum, self.invoice.event.currency)] ) tstyledata += [ ('FONTNAME', (0, len(tdata) - 3), (-1, len(tdata) - 3), self.font_bold), ] elif self.invoice.payment_provider_stamp: pm = PaidMarker( text=self._normalize(self.invoice.payment_provider_stamp), color=colors.HexColor(self.event.settings.theme_color_success), font=self.font_bold, size=16 ) tdata[-1][-2] = pm table = Table(tdata, colWidths=colwidths, repeatRows=1) table.setStyle(TableStyle(tstyledata)) story.append(table) story.append(Spacer(1, 10 * mm)) if self.invoice.payment_provider_text: story.append(Paragraph( self._normalize(self.invoice.payment_provider_text), self.stylesheet['Normal'] )) if self.invoice.payment_provider_text and self.invoice.additional_text: story.append(Spacer(1, 3 * mm)) if self.invoice.additional_text: story.append(Paragraph( self._clean_text(self.invoice.additional_text, tags=['br']), self.stylesheet['Normal'] )) story.append(Spacer(1, 5 * mm)) tstyledata = [ ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), ('LEFTPADDING', (0, 0), (0, -1), 0), ('RIGHTPADDING', (-1, 0), (-1, -1), 0), ('TOPPADDING', (0, 0), (-1, -1), 1), ('BOTTOMPADDING', (0, 0), (-1, -1), 1), ('FONTSIZE', (0, 0), (-1, -1), 8), ('FONTNAME', (0, 0), (-1, -1), self.font_regular), ] thead = [ Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']), Paragraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']), Paragraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']), Paragraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']), '' ] tdata = [thead] for idx, gross in grossvalue_map.items(): rate, name = idx if rate == 0: continue tax = taxvalue_map[idx] tdata.append([ Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), money_filter(gross - tax, self.invoice.event.currency), money_filter(gross, self.invoice.event.currency), money_filter(tax, self.invoice.event.currency), '' ]) def fmt(val): try: return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display) except ValueError: return localize(val) + ' ' + self.invoice.foreign_currency_display if len(tdata) > 1 and has_taxes: colwidths = [a * doc.width for a in (.25, .15, .15, .15, .3)] table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT) table.setStyle(TableStyle(tstyledata)) story.append(Spacer(5 * mm, 5 * mm)) story.append(KeepTogether([ Paragraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']), table ])) if self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: tdata = [thead] for idx, gross in grossvalue_map.items(): rate, name = idx if rate == 0: continue tax = taxvalue_map[idx] gross = round_decimal(gross * self.invoice.foreign_currency_rate) tax = round_decimal(tax * self.invoice.foreign_currency_rate) net = gross - tax tdata.append([ Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']), fmt(net), fmt(gross), fmt(tax), '' ]) table = Table(tdata, colWidths=colwidths, repeatRows=2, hAlign=TA_LEFT) table.setStyle(TableStyle(tstyledata)) story.append(KeepTogether([ Spacer(1, height=2 * mm), Paragraph( self._normalize(pgettext( 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' '{date}, this corresponds to:' ).format(rate=localize(self.invoice.foreign_currency_rate), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))), self.stylesheet['Fineprint'] ), Spacer(1, height=3 * mm), table ])) elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate: foreign_total = round_decimal(total * self.invoice.foreign_currency_rate) story.append(Spacer(1, 5 * mm)) story.append(Paragraph(self._normalize( pgettext( 'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on ' '{date}, the invoice total corresponds to {total}.' ).format(rate=localize(self.invoice.foreign_currency_rate), date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"), authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"), total=fmt(foreign_total))), self.stylesheet['Fineprint'] )) return story class Modern1Renderer(ClassicInvoiceRenderer): identifier = 'modern1' verbose_name = gettext_lazy('Default invoice renderer (European-style letter)') bottom_margin = 16.9 * mm top_margin = 16.9 * mm right_margin = 20 * mm invoice_to_height = 27.3 * mm invoice_to_width = 80 * mm invoice_to_left = 25 * mm invoice_to_top = (40 + 17.7) * mm invoice_from_left = 125 * mm invoice_from_top = 50 * mm invoice_from_width = pagesizes.A4[0] - invoice_from_left - right_margin invoice_from_height = 50 * mm logo_width = 75 * mm logo_height = 25 * mm logo_left = pagesizes.A4[0] - logo_width - right_margin logo_top = top_margin logo_anchor = 'e' event_left = 25 * mm event_top = top_margin event_width = 80 * mm event_height = 25 * mm def _get_stylesheet(self): stylesheet = super()._get_stylesheet() stylesheet.add(ParagraphStyle(name='Sender', fontName=self.font_regular, fontSize=8, leading=10)) stylesheet['InvoiceFrom'].alignment = TA_RIGHT return stylesheet def _draw_invoice_from(self, canvas): if not self.invoice.invoice_from: return c = [ self._clean_text(l) for l in self.invoice.address_invoice_from.strip().split('\n') ] p = Paragraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender']) p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm) p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm) super()._draw_invoice_from(canvas) def _draw_invoice_to_label(self, canvas): pass def _draw_invoice_from_label(self, canvas): pass def _draw_event_label(self, canvas): pass def _get_first_page_frames(self, doc): footer_length = 3.5 * len(self.invoice.footer_text.split('\n')) * mm if self.event.settings.invoice_renderer_highlight_order_code: margin_top = 100 * mm else: margin_top = 95 * mm return [ Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - margin_top, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=footer_length, id='normal') ] def _draw_metadata(self, canvas): # Draws the "invoice number -- date" line. This has gotten a little more complicated since we # encountered some events with very long invoice numbers. In this case, we automatically reduce # the font size until it fits. begin_top = 100 * mm def _draw(label, value, value_size, x, width, bold=False, sublabel=None): if canvas.stringWidth(value, self.font_regular, value_size) > width and value_size > 6: return False textobject = canvas.beginText(x, self.pagesize[1] - begin_top) textobject.setFont(self.font_regular, 8) textobject.textLine(self._normalize(label)) textobject.moveCursor(0, 5) textobject.setFont(self.font_bold if bold else self.font_regular, value_size) textobject.textLine(self._normalize(value)) if sublabel: textobject.moveCursor(0, 1) textobject.setFont(self.font_regular, 8) textobject.textLine(self._normalize(sublabel)) return textobject value_size = 10 while value_size >= 5: if self.event.settings.invoice_renderer_highlight_order_code: kwargs = dict(bold=True, sublabel=pgettext('invoice', '(Please quote at all times.)')) else: kwargs = {} objects = [ _draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs) ] p = Paragraph( self._normalize(date_format(self.invoice.date, "DATE_FORMAT")), style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2) ) w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize) p.wrapOn(canvas, w, 15 * mm) date_x = self.pagesize[0] - w - self.right_margin if self.invoice.is_cancellation: objects += [ _draw(pgettext('invoice', 'Cancellation number'), self.invoice.number, value_size, self.left_margin + 50 * mm, 45 * mm), _draw(pgettext('invoice', 'Original invoice'), self.invoice.refers.number, value_size, self.left_margin + 100 * mm, date_x - self.left_margin - 100 * mm - 5 * mm), ] else: objects += [ _draw(pgettext('invoice', 'Invoice number'), self.invoice.number, value_size, self.left_margin + 70 * mm, date_x - self.left_margin - 70 * mm - 5 * mm), ] if all(objects): for o in objects: canvas.drawText(o) break value_size -= 1 p.drawOn(canvas, date_x, self.pagesize[1] - begin_top - 10 - 6) textobject = canvas.beginText(date_x, self.pagesize[1] - begin_top) textobject.setFont(self.font_regular, 8) if self.invoice.is_cancellation: textobject.textLine(self._normalize(pgettext('invoice', 'Cancellation date'))) else: textobject.textLine(self._normalize(pgettext('invoice', 'Invoice date'))) canvas.drawText(textobject) class Modern1SimplifiedRenderer(Modern1Renderer): identifier = 'modern1simplified' verbose_name = gettext_lazy('Simplified invoice renderer') logo_left = Modern1Renderer.left_margin logo_width = pagesizes.A4[0] - Modern1Renderer.right_margin - logo_left logo_height = 25 * mm logo_top = 13 * mm logo_anchor = 'nw' def _draw_invoice_from(self, canvas): super(Modern1Renderer, self)._draw_invoice_from(canvas) def _draw_event(self, canvas): pass def _get_intro(self): i = [] if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage: i.append(Paragraph( pgettext('invoice', 'Event date: {date_range}').format( date_range=self.invoice.event.get_date_range_display(), ), self.stylesheet['Normal'], )) i.append(Spacer(2 * mm, 2 * mm)) return i + super()._get_intro() @receiver(register_invoice_renderers, dispatch_uid="invoice_renderer_classic") def recv_classic(sender, **kwargs): return [ClassicInvoiceRenderer, Modern1Renderer, Modern1SimplifiedRenderer]