forked from CGM_Public/pretix_original
Refs #131 -- Basic implementation of invoicing
This commit is contained in:
0
src/db.sqlite3.bak
Normal file
0
src/db.sqlite3.bak
Normal file
50
src/pretix/base/migrations/0012_auto_20160312_1040.py
Normal file
50
src/pretix/base/migrations/0012_auto_20160312_1040.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-03-12 10:40
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.invoices
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0011_auto_20160311_2052'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('invoice_no', models.PositiveIntegerField(db_index=True)),
|
||||
('is_cancelled', models.BooleanField(default=False)),
|
||||
('invoice_from', models.TextField()),
|
||||
('invoice_to', models.TextField()),
|
||||
('date', models.DateField(default=datetime.date.today)),
|
||||
('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.invoices.invoice_filename)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Event')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pretixbase.Order')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceLine',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.TextField()),
|
||||
('gross_value', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('tax_value', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('tax_rate', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=7)),
|
||||
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='pretixbase.Invoice')),
|
||||
],
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='invoice',
|
||||
unique_together=set([('event', 'invoice_no')]),
|
||||
),
|
||||
]
|
||||
20
src/pretix/base/migrations/0013_invoice_locale.py
Normal file
20
src/pretix/base/migrations/0013_invoice_locale.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-03-12 10:51
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0012_auto_20160312_1040'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='locale',
|
||||
field=models.CharField(default='en', max_length=50),
|
||||
),
|
||||
]
|
||||
20
src/pretix/base/migrations/0014_invoice_additional_text.py
Normal file
20
src/pretix/base/migrations/0014_invoice_additional_text.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-03-12 11:07
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0013_invoice_locale'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='additional_text',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
30
src/pretix/base/migrations/0015_auto_20160312_1924.py
Normal file
30
src/pretix/base/migrations/0015_auto_20160312_1924.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.2 on 2016-03-12 19:24
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0014_invoice_additional_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='invoice',
|
||||
name='is_cancelled',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='is_cancellation',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='refers',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refered', to='pretixbase.Invoice'),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from .auth import User
|
||||
from .base import CachedFile, cachedfile_name
|
||||
from .event import Event, EventLock, EventPermission, EventSetting
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemCategory, ItemVariation, Question, Quota, itempicture_upload_to,
|
||||
)
|
||||
@@ -17,5 +18,6 @@ __all__ = [
|
||||
'ItemCategory', 'Item', 'Property', 'PropertyValue', 'ItemVariation', 'VariationsField', 'Question',
|
||||
'BaseRestriction', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'AbstractPosition', 'OrderPosition',
|
||||
'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to',
|
||||
'generate_secret', 'Voucher', 'LogEntry', 'InvoiceAddress', 'generate_position_secret'
|
||||
'generate_secret', 'Voucher', 'LogEntry', 'InvoiceAddress', 'generate_position_secret', 'InvoiceLine',
|
||||
'Invoice', 'invoice_filename'
|
||||
]
|
||||
|
||||
62
src/pretix/base/models/invoices.py
Normal file
62
src/pretix/base/models/invoices.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import random
|
||||
import string
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import DatabaseError, models
|
||||
from django.db.models import Max
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
secret = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(14))
|
||||
return 'invoices/{org}/{ev}/{ev}-{no}-{code}-{secret}.pdf'.format(
|
||||
org=instance.event.organizer.slug, ev=instance.event.slug,
|
||||
no=instance.invoice_no, code=instance.order.code,
|
||||
secret=secret
|
||||
)
|
||||
|
||||
|
||||
class Invoice(models.Model):
|
||||
order = models.ForeignKey('Order', related_name='invoices', db_index=True)
|
||||
event = models.ForeignKey('Event', related_name='invoices', db_index=True)
|
||||
invoice_no = models.PositiveIntegerField(db_index=True)
|
||||
is_cancellation = models.BooleanField(default=False)
|
||||
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
|
||||
invoice_from = models.TextField()
|
||||
invoice_to = models.TextField()
|
||||
date = models.DateField(default=date.today)
|
||||
locale = models.CharField(max_length=50, default='en')
|
||||
additional_text = models.TextField(blank=True)
|
||||
file = models.FileField(null=True, blank=True, upload_to=invoice_filename)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.order:
|
||||
raise ValueError('Any invoice needs to be connected to an order')
|
||||
if not self.event:
|
||||
self.event = self.order.event
|
||||
if not self.invoice_no:
|
||||
for i in range(10):
|
||||
self.invoice_no = (Invoice.objects.filter(
|
||||
event=self.event).aggregate(m=Max('invoice_no'))['m'] or 0) + 1
|
||||
try:
|
||||
return super().save(*args, **kwargs)
|
||||
except DatabaseError:
|
||||
# Suppress duplicate key errors and try again
|
||||
if i == 9:
|
||||
raise
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def number(self):
|
||||
return '%s-%05d' % (self.event.slug.upper(), self.invoice_no)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('event', 'invoice_no')
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
invoice = models.ForeignKey('Invoice', related_name='lines')
|
||||
description = models.TextField()
|
||||
gross_value = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
||||
338
src/pretix/base/services/invoices.py
Normal file
338
src/pretix/base/services/invoices.py
Normal file
@@ -0,0 +1,338 @@
|
||||
import copy
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from locale import format as lformat
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import pgettext, ugettext as _
|
||||
from reportlab.lib import pagesizes
|
||||
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import (
|
||||
BaseDocTemplate, Frame, NextPageTemplate, PageTemplate, Paragraph, Spacer,
|
||||
Table, TableStyle,
|
||||
)
|
||||
|
||||
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
|
||||
from pretix.base.signals import register_payment_providers
|
||||
|
||||
|
||||
def generate_cancellation(invoice: Invoice):
|
||||
cancellation = copy.copy(invoice)
|
||||
cancellation.pk = None
|
||||
cancellation.is_cancellation = True
|
||||
cancellation.date = date.today()
|
||||
cancellation.refers = invoice
|
||||
cancellation.invoice_no = None
|
||||
cancellation.save()
|
||||
for line in invoice.lines.all():
|
||||
line.pk = None
|
||||
line.invoice = cancellation
|
||||
line.gross_value *= -1
|
||||
line.tax_value *= -1
|
||||
line.save()
|
||||
|
||||
invoice_pdf(cancellation.pk)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def generate_invoice(order: Order):
|
||||
locale = order.event.settings.get('invoice_language')
|
||||
_lng = translation.get_language()
|
||||
if locale:
|
||||
if locale == '__user__':
|
||||
locale = order.locale
|
||||
translation.activate(locale or settings.LANGUAGE_CODE)
|
||||
|
||||
i = Invoice(order=order, event=order.event)
|
||||
i.invoice_from = order.event.settings.get('invoice_address_from')
|
||||
i.additional_text = order.event.settings.get('invoice_additional_text')
|
||||
|
||||
try:
|
||||
addr_template = pgettext("invoice", """{i.company}
|
||||
{i.name}
|
||||
{i.street}
|
||||
{i.zipcode} {i.city}
|
||||
{i.country}""")
|
||||
i.invoice_to = addr_template.format(i=order.invoice_address).strip()
|
||||
if order.invoice_address.vat_id:
|
||||
i.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % {i.vat_id}
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
i.invoice_to = ""
|
||||
|
||||
i.date = date.today()
|
||||
i.locale = locale
|
||||
i.save()
|
||||
|
||||
responses = register_payment_providers.send(order.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(order.event)
|
||||
if provider.identifier == order.payment_provider:
|
||||
payment_provider = provider
|
||||
break
|
||||
|
||||
for p in order.positions.all():
|
||||
desc = str(p.item.name)
|
||||
if p.variation:
|
||||
desc += " - " + str(p.variation.value)
|
||||
InvoiceLine.objects.create(
|
||||
invoice=i, description=desc,
|
||||
gross_value=p.price, tax_value=p.tax_value,
|
||||
tax_rate=p.tax_rate
|
||||
)
|
||||
|
||||
if order.payment_fee:
|
||||
InvoiceLine.objects.create(
|
||||
invoice=i, description=_('Payment via {method}').format(method=str(payment_provider.verbose_name)),
|
||||
gross_value=order.payment_fee, tax_value=order.payment_fee_tax_value,
|
||||
tax_rate=order.payment_fee_tax_rate
|
||||
)
|
||||
|
||||
translation.activate(_lng)
|
||||
invoice_pdf(i.pk)
|
||||
|
||||
|
||||
def _invoice_get_stylesheet():
|
||||
stylesheet = StyleSheet1()
|
||||
stylesheet.add(ParagraphStyle(name='Normal', fontName='OpenSans', fontSize=10, leading=12))
|
||||
stylesheet.add(ParagraphStyle(name='Heading1', fontName='OpenSansBd', fontSize=15, leading=15 * 1.2))
|
||||
return stylesheet
|
||||
|
||||
|
||||
def _invoice_register_fonts():
|
||||
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')))
|
||||
|
||||
|
||||
def _invoice_generate_german(invoice, f):
|
||||
_invoice_register_fonts()
|
||||
styles = _invoice_get_stylesheet()
|
||||
pagesize = pagesizes.A4
|
||||
|
||||
def on_page(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(pagesize[0] - 20 * mm, 10 * mm, _("Page %d") % (doc.page,))
|
||||
canvas.restoreState()
|
||||
|
||||
def on_first_page(canvas, doc):
|
||||
canvas.setCreator('pretix.eu')
|
||||
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=invoice.number))
|
||||
|
||||
canvas.saveState()
|
||||
canvas.setFont('OpenSans', 8)
|
||||
canvas.drawRightString(pagesize[0] - 20 * mm, 10 * mm, _("Page %d") % (doc.page,))
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLines(invoice.invoice_from.strip())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLines(invoice.invoice_to.strip())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
if invoice.is_cancellation:
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.refers.number)
|
||||
else:
|
||||
textobject.textLine(pgettext('invoice', 'Invoice number').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.number)
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
if invoice.is_cancellation:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Cancellation date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Original invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.refers.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
else:
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(pgettext('invoice', 'Invoice date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.date, "DATE_FORMAT"))
|
||||
textobject.moveCursor(0, 5)
|
||||
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(165 * mm, (297 - 50) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order code').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(invoice.order.code)
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Order date').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(date_format(invoice.order.datetime, "DATE_FORMAT"))
|
||||
canvas.drawText(textobject)
|
||||
|
||||
textobject = canvas.beginText(125 * mm, (297 - 15) * mm)
|
||||
textobject.setFont('OpenSansBd', 8)
|
||||
textobject.textLine(_('Event').upper())
|
||||
textobject.moveCursor(0, 5)
|
||||
textobject.setFont('OpenSans', 10)
|
||||
textobject.textLine(str(invoice.event.name))
|
||||
if invoice.event.settings.show_date_to:
|
||||
textobject.textLines(
|
||||
_('%s\nuntil %s') % (invoice.event.get_date_from_display(),
|
||||
invoice.event.get_date_to_display()))
|
||||
else:
|
||||
textobject.textLine(invoice.event.get_date_from_display())
|
||||
canvas.drawText(textobject)
|
||||
|
||||
canvas.restoreState()
|
||||
|
||||
doc = BaseDocTemplate(f.name, pagesize=pagesizes.A4,
|
||||
leftMargin=25 * mm, rightMargin=20 * mm,
|
||||
topMargin=20 * mm, bottomMargin=15 * mm)
|
||||
frames_p1 = [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height - 75 * mm,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0,
|
||||
id='normal')
|
||||
]
|
||||
frames = [
|
||||
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height,
|
||||
leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0,
|
||||
id='normal')
|
||||
]
|
||||
doc.addPageTemplates([
|
||||
PageTemplate(id='FirstPage', frames=frames_p1, onPage=on_first_page, pagesize=pagesize),
|
||||
PageTemplate(id='OtherPages', frames=frames, onPage=on_page, pagesize=pagesize)
|
||||
])
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
Paragraph(pgettext('invoice', 'Invoice')
|
||||
if not invoice.is_cancellation
|
||||
else pgettext('invoice', 'Cancellation'),
|
||||
styles['Heading1']),
|
||||
Spacer(1, 5 * mm),
|
||||
NextPageTemplate('OtherPages'),
|
||||
]
|
||||
|
||||
taxvalue_map = defaultdict(Decimal)
|
||||
grossvalue_map = defaultdict(Decimal)
|
||||
|
||||
tstyledata = [
|
||||
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'),
|
||||
('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
tdata = [(pgettext('invoice', 'Description'), pgettext('invoice', 'Tax rate'), pgettext('invoice', 'Price'))]
|
||||
total = Decimal('0.00')
|
||||
for line in invoice.lines.all():
|
||||
tdata.append((
|
||||
line.description,
|
||||
lformat("%.2f", line.tax_rate) + " %",
|
||||
lformat("%.2f", line.gross_value) + " " + invoice.event.currency,
|
||||
))
|
||||
taxvalue_map[line.tax_rate] += line.tax_value
|
||||
grossvalue_map[line.tax_rate] += line.gross_value
|
||||
total += line.gross_value
|
||||
|
||||
tdata.append([pgettext('invoice', 'Invoice total'), '', lformat("%.2f", total) + " " + invoice.event.currency])
|
||||
colwidths = [a * doc.width for a in (.60, .20, .20)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=1)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
|
||||
story.append(Spacer(1, 15 * mm))
|
||||
story.append(Paragraph(invoice.additional_text, styles['Normal']))
|
||||
story.append(Spacer(1, 15 * mm))
|
||||
|
||||
tstyledata = [
|
||||
('SPAN', (1, 0), (-1, 0)),
|
||||
('ALIGN', (2, 1), (-1, -1), 'RIGHT'),
|
||||
('LEFTPADDING', (0, 0), (0, -1), 0),
|
||||
('RIGHTPADDING', (-1, 0), (-1, -1), 0),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
]
|
||||
tdata = [('', pgettext('invoice', 'Included taxes'), '', '', ''),
|
||||
('', pgettext('invoice', 'Tax rate'),
|
||||
pgettext('invoice', 'Net value'), pgettext('invoice', 'Gross value'), pgettext('invoice', 'Tax'))]
|
||||
|
||||
for rate, gross in grossvalue_map.items():
|
||||
if line.tax_rate == 0:
|
||||
continue
|
||||
tax = taxvalue_map[rate]
|
||||
tdata.append((
|
||||
'',
|
||||
lformat("%.2f", rate) + " %",
|
||||
lformat("%.2f", (gross - tax)) + " " + invoice.event.currency,
|
||||
lformat("%.2f", gross) + " " + invoice.event.currency,
|
||||
lformat("%.2f", tax) + " " + invoice.event.currency,
|
||||
))
|
||||
|
||||
if len(tdata) > 2:
|
||||
colwidths = [a * doc.width for a in (.45, .10, .15, .15, .15)]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=2)
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(table)
|
||||
|
||||
doc.build(story)
|
||||
return doc
|
||||
|
||||
|
||||
def invoice_pdf(invoice: int):
|
||||
i = Invoice.objects.get(pk=invoice)
|
||||
_lng = translation.get_language()
|
||||
translation.activate(i.locale)
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||
_invoice_generate_german(i, f)
|
||||
f.seek(0)
|
||||
i.file.save('invoice.pdf', ContentFile(f.read()))
|
||||
i.save()
|
||||
|
||||
translation.activate(_lng)
|
||||
return i.file.name
|
||||
|
||||
|
||||
if settings.HAS_CELERY:
|
||||
from pretix.celery import app
|
||||
|
||||
invoice_pdf_task = app.task(invoice_pdf)
|
||||
|
||||
def invoice_pdf(*args, **kwargs):
|
||||
invoice_pdf_task.apply_async(args=args, kwargs=kwargs)
|
||||
@@ -12,6 +12,9 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.models.orders import InvoiceAddress
|
||||
from pretix.base.payment import BasePaymentProvider
|
||||
from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_pdf,
|
||||
)
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.signals import (
|
||||
order_paid, order_placed, register_payment_providers,
|
||||
@@ -99,6 +102,11 @@ def mark_order_refunded(order: Order, user: User=None):
|
||||
order.status = Order.STATUS_REFUNDED
|
||||
order.save()
|
||||
order.log_action('pretix.event.order.refunded', user=user)
|
||||
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@@ -112,6 +120,11 @@ def cancel_order(order: Order, user: User=None):
|
||||
order.status = Order.STATUS_CANCELLED
|
||||
order.save()
|
||||
order.log_action('pretix.event.order.cancelled', user=user)
|
||||
|
||||
i = order.invoices.filter(is_cancellation=False).last()
|
||||
if i:
|
||||
generate_cancellation(i)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@@ -250,6 +263,9 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
pass
|
||||
|
||||
if event.settings.get('invoice_generate'):
|
||||
generate_invoice(order)
|
||||
|
||||
mail(
|
||||
order.email, _('Your order: %(code)s') % {'code': order.code},
|
||||
event.settings.mail_text_order_placed,
|
||||
|
||||
@@ -61,6 +61,22 @@ DEFAULTS = {
|
||||
'default': '0.00',
|
||||
'type': decimal.Decimal
|
||||
},
|
||||
'invoice_generate': {
|
||||
'default': 'False',
|
||||
'type': bool
|
||||
},
|
||||
'invoice_address_from': {
|
||||
'default': '',
|
||||
'type': str
|
||||
},
|
||||
'invoice_additional_text': {
|
||||
'default': '',
|
||||
'type': str
|
||||
},
|
||||
'invoice_language': {
|
||||
'default': '__user__',
|
||||
'type': str
|
||||
},
|
||||
'show_items_outside_presale_period': {
|
||||
'default': 'True',
|
||||
'type': bool
|
||||
|
||||
@@ -148,6 +148,26 @@ class EventSettingsForm(SettingsForm):
|
||||
help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."),
|
||||
required=False
|
||||
)
|
||||
invoice_generate = forms.BooleanField(
|
||||
label=_("Generate invoices"),
|
||||
required=False
|
||||
)
|
||||
invoice_address_from = forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': 5}), required=False,
|
||||
label=_("Your address"),
|
||||
help_text=_("Will be printed as the sender on invoices. Be sure to include relevant details required in "
|
||||
"your jurisdiction (e.g. your VAT ID).")
|
||||
)
|
||||
invoice_additional_text = forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': 5}), required=False,
|
||||
label=_("Additional text"),
|
||||
help_text=_("Will be printed on every invoice below the invoice total.")
|
||||
)
|
||||
invoice_language = forms.ChoiceField(
|
||||
widget=forms.Select, required=True,
|
||||
label=_("Invoice language"),
|
||||
choices=[('__user__', _('The user\'s language'))] + settings.LANGUAGES,
|
||||
)
|
||||
max_items_per_order = forms.IntegerField(
|
||||
min_value=1,
|
||||
label=_("Maximum number of items per order")
|
||||
|
||||
@@ -48,6 +48,10 @@
|
||||
{% bootstrap_field sform.invoice_address_required layout="horizontal" %}
|
||||
{% bootstrap_field sform.invoice_address_vatid layout="horizontal" %}
|
||||
{% bootstrap_field sform.tax_rate_default layout="horizontal" %}
|
||||
{% bootstrap_field sform.invoice_generate layout="horizontal" %}
|
||||
{% bootstrap_field sform.invoice_language layout="horizontal" %}
|
||||
{% bootstrap_field sform.invoice_address_from layout="horizontal" %}
|
||||
{% bootstrap_field sform.invoice_additional_text layout="horizontal" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -77,6 +77,19 @@
|
||||
</button>
|
||||
</form>
|
||||
</dd>
|
||||
{% if invoices %}
|
||||
<dt>{% trans "Invoices" %}</dt>
|
||||
<dd>
|
||||
{% for i in invoices %}
|
||||
<a href="{% url "control:event.invoice.download" invoice=i.pk event=request.event.slug organizer=request.event.organizer.slug %}">
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{{ i.number }}</a> ({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
{% if forloop.revcounter0 > 0 %}
|
||||
<br />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,6 +70,8 @@ urlpatterns = [
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
|
||||
url(r'^orders/(?P<code>[0-9A-Z]+)/download/(?P<output>[^/]+)$', orders.OrderDownload.as_view(),
|
||||
name='event.order.download'),
|
||||
url(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
|
||||
name='event.invoice.download'),
|
||||
url(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'),
|
||||
url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
|
||||
url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),
|
||||
|
||||
@@ -5,7 +5,7 @@ from django import forms
|
||||
from django.contrib import messages
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, HttpResponseNotAllowed
|
||||
from django.http import Http404, HttpResponseNotAllowed
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
@@ -13,10 +13,11 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, TemplateView, View
|
||||
|
||||
from pretix.base.models import (
|
||||
CachedFile, CachedTicket, EventLock, Item, Order, Quota,
|
||||
CachedFile, CachedTicket, EventLock, Invoice, Item, Order, Quota,
|
||||
)
|
||||
from pretix.base.services import tickets
|
||||
from pretix.base.services.export import export
|
||||
from pretix.base.services.invoices import invoice_pdf
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.orders import cancel_order, mark_order_paid
|
||||
from pretix.base.services.stats import order_overview
|
||||
@@ -118,6 +119,7 @@ class OrderDetail(OrderView):
|
||||
and self.order.status == Order.STATUS_PAID
|
||||
)
|
||||
ctx['payment'] = self.payment_provider.order_control_render(self.request, self.object)
|
||||
ctx['invoices'] = list(self.order.invoices.all())
|
||||
return ctx
|
||||
|
||||
def get_items(self):
|
||||
@@ -137,8 +139,8 @@ class OrderDetail(OrderView):
|
||||
def keyfunc(pos):
|
||||
if (pos.item.admission and self.request.event.settings.attendee_names_asked) \
|
||||
or pos.item.questions.all():
|
||||
return pos.id, 0, 0, 0, None
|
||||
return 0, pos.item_id, pos.variation_id, pos.price, pos.voucher
|
||||
return pos.id, 0, 0, 0, 0, None
|
||||
return 0, pos.item_id, pos.variation_id, pos.price, pos.tax_rate, pos.voucher
|
||||
|
||||
positions = []
|
||||
for k, g in groupby(sorted(list(cartpos), key=keyfunc), key=keyfunc):
|
||||
@@ -221,6 +223,38 @@ class OrderResendLink(OrderView):
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
|
||||
class InvoiceDownload(EventPermissionRequiredMixin, View):
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_order_url(self):
|
||||
return reverse('control:event.order', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.event.organizer.slug,
|
||||
'code': self.invoice.order.code
|
||||
})
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
self.invoice = Invoice.objects.get(
|
||||
event=self.request.event,
|
||||
id=self.kwargs['invoice']
|
||||
)
|
||||
except Invoice.DoesNotExist:
|
||||
raise Http404(_('This invoice has not been found'))
|
||||
|
||||
if not self.invoice.file:
|
||||
invoice_pdf(self.invoice.pk)
|
||||
self.invoice = Invoice.objects.get(pk=self.invoice.pk)
|
||||
|
||||
if not self.invoice.file:
|
||||
# This happens if we have celery installed and the file will be generated in the background
|
||||
messages.warning(request, _('The invoice file has not yet been generated, we will generate it for you '
|
||||
'now. Please try again in a few seconds.'))
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
return redirect(self.invoice.file.url)
|
||||
|
||||
|
||||
class OrderDownload(OrderView):
|
||||
|
||||
@cached_property
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -91,43 +91,70 @@
|
||||
{% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=False %}
|
||||
</div>
|
||||
</div>
|
||||
{% if request.event.settings.invoice_address_asked %}
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading">
|
||||
{% if order.can_modify_answers %}
|
||||
<div class="pull-right">
|
||||
<a href="{% eventurl event "presale:event.order.modify" secret=order.secret order=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change details" %}
|
||||
</a>
|
||||
<div class="row">
|
||||
{% if invoices %}
|
||||
<div class="col-xs-12 {% if request.event.settings.invoice_address_asked %}col-md-6{% endif %}">
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Invoices" %}
|
||||
</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3 class="panel-title">
|
||||
{% trans "Invoice information" %}
|
||||
</h3>
|
||||
<div class="panel-body">
|
||||
<ul>
|
||||
{% for i in invoices %}
|
||||
<li>
|
||||
<a href="{% eventurl event "presale:event.invoice.download" invoice=i.pk secret=order.secret order=order.code %}">
|
||||
{% if i.is_cancellation %}{% trans "Cancellation" %}{% else %}{% trans "Invoice" %}{% endif %}
|
||||
{{ i.number }}</a> ({{ i.date|date:"SHORT_DATE_FORMAT" }})
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Company" %}</dt>
|
||||
<dd>{{ order.invoice_address.company }}</dd>
|
||||
<dt>{% trans "Name" %}</dt>
|
||||
<dd>{{ order.invoice_address.name }}</dd>
|
||||
<dt>{% trans "Address" %}</dt>
|
||||
<dd>{{ order.invoice_address.street|linebreaksbr }}</dd>
|
||||
<dt>{% trans "ZIP code and city" %}</dt>
|
||||
<dd>{{ order.invoice_address.zipcode }} {{ addr.city }}</dd>
|
||||
<dt>{% trans "Country" %}</dt>
|
||||
<dd>{{ order.invoice_address.country }}</dd>
|
||||
<dt>{% trans "Phone" %}</dt>
|
||||
<dd>{{ order.invoice_address.phone }}</dd>
|
||||
{% if request.event.settings.invoice_address_vatid %}
|
||||
<dt>{% trans "VAT ID" %}</dt>
|
||||
<dd>{{ order.invoice_address.vat_id }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% if request.event.settings.invoice_address_asked %}
|
||||
<div class="col-xs-12 {% if invoices %}col-md-6{% endif %}">
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading">
|
||||
{% if order.can_modify_answers %}
|
||||
<div class="pull-right">
|
||||
<a href="{% eventurl event "presale:event.order.modify" secret=order.secret order=order.code %}">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Change details" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3 class="panel-title">
|
||||
{% trans "Invoice information" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<dl class="dl-horizontal">
|
||||
<dt>{% trans "Company" %}</dt>
|
||||
<dd>{{ order.invoice_address.company }}</dd>
|
||||
<dt>{% trans "Name" %}</dt>
|
||||
<dd>{{ order.invoice_address.name }}</dd>
|
||||
<dt>{% trans "Address" %}</dt>
|
||||
<dd>{{ order.invoice_address.street|linebreaksbr }}</dd>
|
||||
<dt>{% trans "ZIP code and city" %}</dt>
|
||||
<dd>{{ order.invoice_address.zipcode }} {{ addr.city }}</dd>
|
||||
<dt>{% trans "Country" %}</dt>
|
||||
<dd>{{ order.invoice_address.country }}</dd>
|
||||
<dt>{% trans "Phone" %}</dt>
|
||||
<dd>{{ order.invoice_address.phone }}</dd>
|
||||
{% if request.event.settings.invoice_address_vatid %}
|
||||
<dt>{% trans "VAT ID" %}</dt>
|
||||
<dd>{{ order.invoice_address.vat_id }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{% if order.status == "n" %}
|
||||
<div class="row">
|
||||
<div class="col-md-12 text-right">
|
||||
|
||||
@@ -35,6 +35,9 @@ event_patterns = [
|
||||
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<output>[^/]+)$',
|
||||
pretix.presale.views.order.OrderDownload.as_view(),
|
||||
name='event.order.download'),
|
||||
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/invoice/(?P<invoice>[^/]+)$',
|
||||
pretix.presale.views.order.InvoiceDownload.as_view(),
|
||||
name='event.invoice.download'),
|
||||
url(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),
|
||||
]
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.generic import TemplateView, View
|
||||
|
||||
from pretix.base.models import CachedFile, CachedTicket, Order, OrderPosition
|
||||
from pretix.base.models import (
|
||||
CachedFile, CachedTicket, Invoice, Order, OrderPosition,
|
||||
)
|
||||
from pretix.base.models.orders import InvoiceAddress
|
||||
from pretix.base.services.invoices import invoice_pdf
|
||||
from pretix.base.services.orders import cancel_order
|
||||
from pretix.base.services.tickets import generate
|
||||
from pretix.base.signals import (
|
||||
@@ -85,6 +88,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView):
|
||||
answers=True,
|
||||
queryset=OrderPosition.objects.filter(order=self.order)
|
||||
)
|
||||
ctx['invoices'] = list(self.order.invoices.all())
|
||||
if self.order.status == Order.STATUS_PENDING:
|
||||
ctx['payment'] = self.payment_provider.order_pending_render(self.request, self.order)
|
||||
ctx['can_retry'] = (
|
||||
@@ -319,3 +323,31 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
|
||||
ct.save()
|
||||
generate(self.order.id, self.output.identifier)
|
||||
return redirect(reverse('cachedfile.download', kwargs={'id': ct.cachedfile.id}))
|
||||
|
||||
|
||||
class InvoiceDownload(EventViewMixin, OrderDetailMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if not self.order:
|
||||
raise Http404(_('Unknown order code or not authorized to access this order.'))
|
||||
|
||||
try:
|
||||
invoice = Invoice.objects.get(
|
||||
event=self.request.event,
|
||||
order=self.order,
|
||||
id=self.kwargs['invoice']
|
||||
)
|
||||
except Invoice.DoesNotExist:
|
||||
raise Http404(_('This invoice has not been found'))
|
||||
|
||||
if not invoice.file:
|
||||
invoice_pdf(invoice.pk)
|
||||
invoice = Invoice.objects.get(pk=invoice.pk)
|
||||
|
||||
if not invoice.file:
|
||||
# This happens if we have celery installed and the file will be generated in the background
|
||||
messages.warning(request, _('The invoice file has not yet been generated, we will generate it for you '
|
||||
'now. Please try again in a few seconds.'))
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
return redirect(invoice.file.url)
|
||||
|
||||
@@ -204,11 +204,11 @@ LOCALE_PATHS = (
|
||||
os.path.join(os.path.dirname(__file__), 'locale'),
|
||||
)
|
||||
|
||||
LANGUAGES = (
|
||||
LANGUAGES = [
|
||||
('en', _('English')),
|
||||
('de', _('German')),
|
||||
('de-informal', _('German (informal)')),
|
||||
)
|
||||
]
|
||||
|
||||
EXTRA_LANG_INFO = {
|
||||
'de-informal': {
|
||||
|
||||
Reference in New Issue
Block a user