mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Invoicing: Allow to show exchange rates based on sources/rules (#3122)
This commit is contained in:
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from reportlab.platypus import (
|
||||
|
||||
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
|
||||
@@ -773,9 +774,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
Spacer(1, height=2 * mm),
|
||||
Paragraph(
|
||||
pgettext(
|
||||
'invoice', 'Using the conversion rate of 1:{rate} as published by the European Central Bank on '
|
||||
'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']
|
||||
),
|
||||
@@ -787,10 +789,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
story.append(Paragraph(
|
||||
pgettext(
|
||||
'invoice', 'Using the conversion rate of 1:{rate} as published by the European Central Bank on '
|
||||
'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']
|
||||
))
|
||||
|
||||
34
src/pretix/base/migrations/0232_exchangerate.py
Normal file
34
src/pretix/base/migrations/0232_exchangerate.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.2.17 on 2023-02-14 15:34
|
||||
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0231_auto_20230208_1546'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExchangeRate',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('source', models.CharField(max_length=100)),
|
||||
('source_date', models.DateField()),
|
||||
('updated', models.DateTimeField(auto_now=True)),
|
||||
('source_currency', models.CharField(max_length=3)),
|
||||
('other_currency', models.CharField(max_length=3)),
|
||||
('rate', models.DecimalField(decimal_places=6, max_digits=16)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('source', 'source_currency', 'other_currency')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='foreign_currency_source',
|
||||
field=models.CharField(max_length=100, null=True),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
|
||||
from .auth import U2FDevice, User, WebAuthnDevice
|
||||
from .base import CachedFile, LoggedModel, cachedfile_name
|
||||
from .checkin import Checkin, CheckinList
|
||||
from .currencies import ExchangeRate
|
||||
from .customers import Customer
|
||||
from .devices import Device, Gate
|
||||
from .discount import Discount
|
||||
|
||||
34
src/pretix/base/models/currencies.py
Normal file
34
src/pretix/base/models/currencies.py
Normal file
@@ -0,0 +1,34 @@
|
||||
#
|
||||
# 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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ExchangeRate(models.Model):
|
||||
source = models.CharField(max_length=100)
|
||||
source_date = models.DateField()
|
||||
updated = models.DateTimeField(auto_now=True)
|
||||
source_currency = models.CharField(max_length=3)
|
||||
other_currency = models.CharField(max_length=3)
|
||||
rate = models.DecimalField(decimal_places=6, max_digits=16)
|
||||
|
||||
class Meta:
|
||||
unique_together = (('source', 'source_currency', 'other_currency'))
|
||||
@@ -105,6 +105,8 @@ class Invoice(models.Model):
|
||||
:type foreign_currency_rate: Decimal
|
||||
:param foreign_currency_rate_date: The date of the foreign currency exchange rates.
|
||||
:type foreign_currency_rate_date: date
|
||||
:param foreign_currency_rate_source: The source of the foreign currency rate.
|
||||
:type foreign_currency_rate_source: str
|
||||
:param file: The filename of the rendered invoice
|
||||
:type file: File
|
||||
"""
|
||||
@@ -152,6 +154,7 @@ class Invoice(models.Model):
|
||||
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
|
||||
foreign_currency_rate = models.DecimalField(decimal_places=4, max_digits=10, null=True, blank=True)
|
||||
foreign_currency_rate_date = models.DateField(null=True, blank=True)
|
||||
foreign_currency_source = models.CharField(max_length=100, null=True, blank=True)
|
||||
|
||||
shredded = models.BooleanField(default=False)
|
||||
|
||||
|
||||
145
src/pretix/base/services/currencies.py
Normal file
145
src/pretix/base/services/currencies.py
Normal file
@@ -0,0 +1,145 @@
|
||||
#
|
||||
# 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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# 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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.db.models import Max
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from lxml import etree
|
||||
|
||||
from pretix.base.models import ExchangeRate
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
|
||||
SOURCE_NAMES = {
|
||||
None: _('European Central Bank'), # backwards-compatibility
|
||||
'eu:ecb:eurofxref-daily': _('European Central Bank'),
|
||||
'cz:cnb:rate-fixing-daily': _('Czech National Bank'),
|
||||
}
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def fetch_rates(sender, **kwargs):
|
||||
if not settings.FETCH_ECB_RATES:
|
||||
return
|
||||
|
||||
source_tasks = {
|
||||
'eu:ecb:eurofxref-daily': fetch_ecb_rates,
|
||||
'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates,
|
||||
}
|
||||
|
||||
for source_name, task in source_tasks.items():
|
||||
last_source_date = ExchangeRate.objects.filter(source=source_name).aggregate(m=Max('source_date'))['m']
|
||||
if last_source_date and last_source_date >= date.today():
|
||||
# We assume that the rates we fetch are only updated daily
|
||||
continue
|
||||
|
||||
last_fetch_date = ExchangeRate.objects.filter(source=source_name).aggregate(m=Max('updated'))['m']
|
||||
if last_fetch_date and last_fetch_date >= datetime.utcnow() - timedelta(hours=1):
|
||||
# Only try to fetch once per hour
|
||||
continue
|
||||
|
||||
# Today's rate not yet published, let's try to fetch it
|
||||
task.apply_async()
|
||||
|
||||
|
||||
@app.task()
|
||||
def fetch_ecb_rates():
|
||||
"""
|
||||
Fetches currency rates from the European Central Bank.
|
||||
"""
|
||||
d = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml'
|
||||
r = requests.get(d)
|
||||
r.raise_for_status()
|
||||
|
||||
# File looks like this:
|
||||
# <?xml version="1.0" encoding="UTF-8"?>
|
||||
# <gesmes:Envelope xmlns:gesmes="http://www.gesmes.org/xml/2002-08-01"
|
||||
# xmlns="http://www.ecb.int/vocabulary/2002-08-01/eurofxref">
|
||||
# <gesmes:subject>Reference rates</gesmes:subject>
|
||||
# <gesmes:Sender>
|
||||
# <gesmes:name>European Central Bank</gesmes:name>
|
||||
# </gesmes:Sender>
|
||||
# <Cube>
|
||||
# <Cube time="2023-02-14">
|
||||
# <Cube currency="USD" rate="1.0759"/>
|
||||
# ...
|
||||
# </Cube>
|
||||
# </Cube>
|
||||
# </gesmes:Envelope>
|
||||
|
||||
root = etree.fromstring(r.content)
|
||||
namespaces = {
|
||||
'gesmes': 'http://www.gesmes.org/xml/2002-08-01',
|
||||
'eurofxref': 'http://www.ecb.int/vocabulary/2002-08-01/eurofxref'
|
||||
}
|
||||
outercube = root.xpath('./eurofxref:Cube/eurofxref:Cube[@time]', namespaces=namespaces)[0]
|
||||
source_date = date.fromisoformat(outercube.get("time"))
|
||||
|
||||
for cube in outercube.xpath('./eurofxref:Cube[@currency][@rate]', namespaces=namespaces):
|
||||
currency = cube.get('currency')
|
||||
rate = Decimal(cube.get('rate'))
|
||||
ExchangeRate.objects.update_or_create(
|
||||
source='eu:ecb:eurofxref-daily',
|
||||
source_currency='EUR',
|
||||
other_currency=currency,
|
||||
defaults=dict(
|
||||
source_date=source_date,
|
||||
rate=rate,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.task()
|
||||
def fetch_cnb_cz_rates():
|
||||
"""
|
||||
Fetches currency rates from the Czech National Bank.
|
||||
"""
|
||||
d = f'https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/' \
|
||||
f'central-bank-exchange-rate-fixing/daily.txt?date={date.today().strftime("%d.%m.%Y")}'
|
||||
r = requests.get(d)
|
||||
r.raise_for_status()
|
||||
|
||||
lines = r.text.splitlines()
|
||||
|
||||
# File looks like this:
|
||||
# 14 Feb 2023 #32
|
||||
# Country|Currency|Amount|Code|Rate
|
||||
# Australia|dollar|1|AUD|15.412
|
||||
|
||||
source_date = datetime.strptime(lines[0].split("#")[0].strip(), "%d %b %Y").date()
|
||||
|
||||
for line in lines[2:]:
|
||||
country, currency, amount, code, rate = line.split("|")
|
||||
rate = Decimal(rate).quantize(Decimal('0.000001')) / int(amount)
|
||||
ExchangeRate.objects.update_or_create(
|
||||
source='cz:cnb:rate-fixing-daily',
|
||||
source_currency=code,
|
||||
other_currency='CZK',
|
||||
defaults=dict(
|
||||
source_date=source_date,
|
||||
rate=rate,
|
||||
)
|
||||
)
|
||||
@@ -33,16 +33,12 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
from datetime import date, timedelta
|
||||
from datetime import timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
import vat_moss.exchange_rates
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import connection, transaction
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver
|
||||
@@ -56,11 +52,10 @@ from i18nfield.strings import LazyI18nString
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
||||
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
||||
)
|
||||
from pretix.base.models.tax import EU_CURRENCIES
|
||||
from pretix.base.services.tasks import TransactionAwareTask
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
from pretix.base.signals import invoice_line_text, periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import OF_SELF, rolledback_transaction
|
||||
@@ -144,27 +139,54 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.invoice_to += "\n" + pgettext("invoice", "VAT-ID: %s") % ia.vat_id
|
||||
invoice.invoice_to_vat_id = ia.vat_id
|
||||
|
||||
cc = str(ia.country)
|
||||
|
||||
if cc in EU_CURRENCIES and EU_CURRENCIES[cc] != invoice.event.currency and invoice.event.settings.invoice_eu_currencies:
|
||||
invoice.foreign_currency_display = EU_CURRENCIES[cc]
|
||||
if invoice.event.settings.invoice_eu_currencies == 'True':
|
||||
cc = str(ia.country)
|
||||
if cc in EU_CURRENCIES and EU_CURRENCIES[cc] != invoice.event.currency:
|
||||
invoice.foreign_currency_display = EU_CURRENCIES[cc]
|
||||
|
||||
if settings.FETCH_ECB_RATES:
|
||||
rate = ExchangeRate.objects.filter(
|
||||
source='eu:ecb:eurofxref-daily',
|
||||
source_currency=invoice.event.currency,
|
||||
other_currency=invoice.foreign_currency_display,
|
||||
source_date__gt=now().date() - timedelta(days=7)
|
||||
).first()
|
||||
if rate:
|
||||
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rate.source_date
|
||||
invoice.foreign_currency_source = 'eu:ecb:eurofxref-daily'
|
||||
else:
|
||||
rate_eur_to_event = ExchangeRate.objects.filter(
|
||||
source='eu:ecb:eurofxref-daily',
|
||||
source_currency='EUR',
|
||||
other_currency=invoice.event.currency,
|
||||
source_date__gt=now().date() - timedelta(days=7)
|
||||
).first()
|
||||
rate_eur_to_wanted = ExchangeRate.objects.filter(
|
||||
source='eu:ecb:eurofxref-daily',
|
||||
source_currency='EUR',
|
||||
other_currency=invoice.foreign_currency_display,
|
||||
source_date__gt=now().date() - timedelta(days=7)
|
||||
).first()
|
||||
if rate_eur_to_wanted and rate_eur_to_event:
|
||||
invoice.foreign_currency_rate = (
|
||||
rate_eur_to_wanted.rate / rate_eur_to_event.rate
|
||||
).quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = min(rate_eur_to_wanted.source_date, rate_eur_to_event.source_date)
|
||||
invoice.foreign_currency_source = 'eu:ecb:eurofxref-daily'
|
||||
elif invoice.event.settings.invoice_eu_currencies == 'CZK' and invoice.event.currency != 'CZK':
|
||||
invoice.foreign_currency_display = 'CZK'
|
||||
if settings.FETCH_ECB_RATES:
|
||||
gs = GlobalSettingsObject()
|
||||
rates_date = gs.settings.get('ecb_rates_date', as_type=date)
|
||||
rates_dict = gs.settings.get('ecb_rates_dict', as_type=dict)
|
||||
convert = (
|
||||
rates_date and rates_dict and
|
||||
rates_date > (now() - timedelta(days=7)).date() and
|
||||
invoice.event.currency in rates_dict and
|
||||
invoice.foreign_currency_display in rates_dict
|
||||
)
|
||||
if convert:
|
||||
invoice.foreign_currency_rate = (
|
||||
Decimal(rates_dict[invoice.foreign_currency_display])
|
||||
/ Decimal(rates_dict[invoice.event.currency])
|
||||
).quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rates_date
|
||||
rate = ExchangeRate.objects.filter(
|
||||
source='cz:cnb:rate-fixing-daily',
|
||||
source_currency=invoice.event.currency,
|
||||
other_currency=invoice.foreign_currency_display,
|
||||
source_date__gt=now().date() - timedelta(days=7)
|
||||
).first()
|
||||
if rate:
|
||||
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rate.source_date
|
||||
invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily'
|
||||
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
@@ -474,23 +496,6 @@ def build_preview_invoice_pdf(event):
|
||||
return event.invoice_renderer.generate(invoice)
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
def fetch_ecb_rates(sender, **kwargs):
|
||||
if not settings.FETCH_ECB_RATES:
|
||||
return
|
||||
|
||||
gs = GlobalSettingsObject()
|
||||
if gs.settings.ecb_rates_date == now().strftime("%Y-%m-%d"):
|
||||
return
|
||||
|
||||
try:
|
||||
date, rates = vat_moss.exchange_rates.fetch()
|
||||
gs.settings.ecb_rates_date = date
|
||||
gs.settings.ecb_rates_dict = json.dumps(rates, cls=DjangoJSONEncoder)
|
||||
except urllib.error.URLError:
|
||||
logger.exception('Could not retrieve rates from ECB')
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def send_invoices_to_organizer(sender, **kwargs):
|
||||
|
||||
@@ -377,13 +377,27 @@ DEFAULTS = {
|
||||
},
|
||||
'invoice_eu_currencies': {
|
||||
'default': 'True',
|
||||
'type': bool,
|
||||
'form_class': forms.BooleanField,
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'form_kwargs': dict(
|
||||
label=_("On invoices from one EU country into another EU country with a different currency, print the "
|
||||
"tax amounts in both currencies if possible"),
|
||||
)
|
||||
label=_("Show exchange rates"),
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('False', _('Never')),
|
||||
('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU '
|
||||
'country that uses a different currency.')),
|
||||
('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')),
|
||||
),
|
||||
),
|
||||
'serializer_kwargs': dict(
|
||||
choices=(
|
||||
('False', _('Never')),
|
||||
('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU '
|
||||
'country that uses a different currency.')),
|
||||
('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')),
|
||||
),
|
||||
),
|
||||
},
|
||||
'invoice_address_required': {
|
||||
'default': 'False',
|
||||
|
||||
Reference in New Issue
Block a user