diff --git a/doc/admin/config.rst b/doc/admin/config.rst index c24b468b3f..6f380103da 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -84,7 +84,7 @@ Example:: Enables or disables the "keep me logged in" button. Defaults to ``on``. ``ecb_rates`` - By default, pretix periodically downloads a XML file from the European Central Bank to retrieve exchange rates + By default, pretix periodically downloads currency rates from the European Central Bank as well as other authorities that are used to print tax amounts in the customer currency on invoices for some currencies. Set to ``off`` to disable this feature. Defaults to ``on``. diff --git a/src/pretix/base/apps.py b/src/pretix/base/apps.py index 435eff51ec..6a0d56a466 100644 --- a/src/pretix/base/apps.py +++ b/src/pretix/base/apps.py @@ -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 diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py index 0fffe9d686..57de17cb6a 100644 --- a/src/pretix/base/invoice.py +++ b/src/pretix/base/invoice.py @@ -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'] )) diff --git a/src/pretix/base/migrations/0232_exchangerate.py b/src/pretix/base/migrations/0232_exchangerate.py new file mode 100644 index 0000000000..209009597d --- /dev/null +++ b/src/pretix/base/migrations/0232_exchangerate.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index fe395bcf5a..5f72e624d7 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -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 diff --git a/src/pretix/base/models/currencies.py b/src/pretix/base/models/currencies.py new file mode 100644 index 0000000000..b8f1c79c98 --- /dev/null +++ b/src/pretix/base/models/currencies.py @@ -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 . +# +# 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 +# . +# +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')) diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 624fd5d934..2dd73a5d45 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -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) diff --git a/src/pretix/base/services/currencies.py b/src/pretix/base/services/currencies.py new file mode 100644 index 0000000000..09e0a3c13f --- /dev/null +++ b/src/pretix/base/services/currencies.py @@ -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 . +# +# 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 +# . +# +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: + # + # + # Reference rates + # + # European Central Bank + # + # + # + # + # ... + # + # + # + + 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, + ) + ) diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index c5aa16dd0b..8c1c5ed666 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -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): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 4d0ac834fd..65afdc9d2c 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -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', diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index 1cd9ae1a89..c6d47410c5 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -37,15 +37,14 @@ from datetime import date, timedelta from decimal import Decimal import pytest -from django.core.serializers.json import DjangoJSONEncoder from django.db import DatabaseError, transaction from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scope, scopes_disabled from pretix.base.models import ( - Event, Invoice, InvoiceAddress, Item, ItemVariation, Order, OrderPosition, - Organizer, + Event, ExchangeRate, Invoice, InvoiceAddress, Item, ItemVariation, Order, + OrderPosition, Organizer, ) from pretix.base.models.orders import OrderFee from pretix.base.services.invoices import ( @@ -53,7 +52,6 @@ from pretix.base.services.invoices import ( invoice_pdf_task, invoice_qualified, regenerate_invoice, ) from pretix.base.services.orders import OrderChangeManager -from pretix.base.settings import GlobalSettingsObject @pytest.fixture @@ -102,9 +100,7 @@ def env(): positionid=3, canceled=True ) - gs = GlobalSettingsObject() - gs.settings.ecb_rates_date = date.today() - gs.settings.ecb_rates_dict = json.dumps({ + rates = { "USD": "1.1648", "RON": "4.5638", "CZK": "26.024", @@ -117,7 +113,11 @@ def env(): "PLN": "4.2408", "GBP": "0.89350", "SEK": "9.5883" - }, cls=DjangoJSONEncoder) + } + for currency, rate in rates.items(): + ExchangeRate.objects.create(source_date=date.today(), source='eu:ecb:eurofxref-daily', source_currency='EUR', other_currency=currency, rate=rate) + ExchangeRate.objects.create(source_date=date.today(), source='cz:cnb:rate-fixing-daily', source_currency='EUR', + other_currency='CZK', rate=Decimal('25.0000')) yield event, o @@ -232,6 +232,7 @@ def test_reverse_charge_note(env): assert inv.foreign_currency_display == "PLN" assert inv.foreign_currency_rate == Decimal("4.2408") assert inv.foreign_currency_rate_date == date.today() + assert inv.foreign_currency_source == 'eu:ecb:eurofxref-daily' @pytest.mark.django_db @@ -271,8 +272,7 @@ def test_custom_tax_note(env): @pytest.mark.django_db def test_reverse_charge_foreign_currency_data_too_old(env): event, order = env - gs = GlobalSettingsObject() - gs.settings.ecb_rates_date = date.today() - timedelta(days=14) + ExchangeRate.objects.update(source_date=date.today() - timedelta(days=14)) tr = event.tax_rules.first() tr.eu_reverse_charge = True @@ -296,9 +296,9 @@ def test_reverse_charge_foreign_currency_data_too_old(env): @pytest.mark.django_db -def test_reverse_charge_foreign_currency_disabvled(env): +def test_reverse_charge_foreign_currency_disabled(env): event, order = env - event.settings.invoice_eu_currencies = False + event.settings.invoice_eu_currencies = 'False' tr = event.tax_rules.first() tr.eu_reverse_charge = True @@ -321,6 +321,41 @@ def test_reverse_charge_foreign_currency_disabvled(env): assert inv.foreign_currency_rate_date is None +@pytest.mark.django_db +def test_invoice_indirect_currency_conversion(env): + event, order = env + event.currency = 'SEK' + event.save() + + event.settings.set('invoice_language', 'en') + InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street', zipcode='12345', city='Warsaw', + country=Country('PL'), vat_id='PL123456780', vat_id_validated=True, order=order, + is_business=True) + + inv = generate_invoice(order) + assert inv.foreign_currency_display == "PLN" + assert inv.foreign_currency_rate == Decimal("0.4423") + assert inv.foreign_currency_rate_date == date.today() + assert inv.foreign_currency_source == 'eu:ecb:eurofxref-daily' + + +@pytest.mark.django_db +def test_invoice_czk_currency_conversion(env): + event, order = env + event.settings.invoice_eu_currencies = 'CZK' + + event.settings.set('invoice_language', 'en') + InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street', zipcode='12345', city='Warsaw', + country=Country('PL'), vat_id='PL123456780', vat_id_validated=True, order=order, + is_business=True) + + inv = generate_invoice(order) + assert inv.foreign_currency_display == "CZK" + assert inv.foreign_currency_rate == Decimal("25.0000") + assert inv.foreign_currency_rate_date == date.today() + assert inv.foreign_currency_source == 'cz:cnb:rate-fixing-daily' + + @pytest.mark.django_db def test_positions(env): event, order = env