From f3b4849e0cd373bc29068277a8b601c931a7406e Mon Sep 17 00:00:00 2001 From: Jan Grasnick Date: Fri, 3 Jan 2025 22:28:58 +0100 Subject: [PATCH 01/16] handle tax childs --- mixin.py | 20 ++++++++++++++----- template/Factur-X-1.07.2-extended/invoice.xml | 4 +--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/mixin.py b/mixin.py index b5eed85..2879910 100644 --- a/mixin.py +++ b/mixin.py @@ -5,6 +5,7 @@ from decimal import Decimal +import datetime import html from trytond.exceptions import UserError from trytond.i18n import gettext @@ -100,21 +101,30 @@ class EdocumentMixin(object): """ get tax of invoice-line, fire exception if no/multiple taxes exists """ - if len(line.invoice_taxes) != 1: + if len(line.taxes) != 1: raise UserError(gettext( 'edocument_xrechnung.msg_linetax_invalid_number', linename=line.rec_name, - numtax=len(line.invoice_taxes))) + numtax=len(line.taxes))) + + tax = line.taxes[0] + date = line.invoice.accounting_date or line.invoice.invoice_date + for child in tax.childs: + start_date = tax.start_date or datetime.date.min + end_date = tax.end_date or datetime.date.max + if start_date <= date <= end_date: + tax = child + break allowed_cat = ['AE', 'L', 'M', 'E', 'S', 'Z', 'G', 'O', 'K', 'B'] - unece_category_code = self.get_category_code(line.invoice_taxes[0].tax) + unece_category_code = self.get_category_code(tax) if unece_category_code not in allowed_cat: raise UserError(gettext( 'edocument_xrechnung.msg_linetax_invalid_catcode', - taxname=line.invoice_taxes[0].tax.rec_name, + taxname=tax.rec_name, allowed=', '.join(allowed_cat))) - return line.invoice_taxes[0].tax + return tax def taxident_data(self, tax_identifier): """ get tax-scheme-id and codes diff --git a/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index 8a7a664..09b8e4d 100644 --- a/template/Factur-X-1.07.2-extended/invoice.xml +++ b/template/Factur-X-1.07.2-extended/invoice.xml @@ -83,9 +83,7 @@ this repository contains the full copyright notices and license terms. --> ${line.quantity * this.type_sign} - - ${TradeTax(tax.tax)} - + ${TradeTax(this.invoice_line_tax(line))} ${line.amount} From dba1965894f401ca125daf809173124641491fbc Mon Sep 17 00:00:00 2001 From: Jan Grasnick Date: Sat, 4 Jan 2025 21:08:43 +0100 Subject: [PATCH 02/16] fix typo --- mixin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mixin.py b/mixin.py index 2879910..e8762fe 100644 --- a/mixin.py +++ b/mixin.py @@ -110,8 +110,8 @@ class EdocumentMixin(object): tax = line.taxes[0] date = line.invoice.accounting_date or line.invoice.invoice_date for child in tax.childs: - start_date = tax.start_date or datetime.date.min - end_date = tax.end_date or datetime.date.max + start_date = child.start_date or datetime.date.min + end_date = child.end_date or datetime.date.max if start_date <= date <= end_date: tax = child break From 9a13bc325d535b43ae9e9b3a24effc72fef1c831 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Thu, 9 Jan 2025 11:50:00 +0100 Subject: [PATCH 03/16] calculation of the tax adjusted --- mixin.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/mixin.py b/mixin.py index e8762fe..8a75aca 100644 --- a/mixin.py +++ b/mixin.py @@ -5,7 +5,6 @@ from decimal import Decimal -import datetime import html from trytond.exceptions import UserError from trytond.i18n import gettext @@ -101,20 +100,19 @@ class EdocumentMixin(object): """ get tax of invoice-line, fire exception if no/multiple taxes exists """ + Tax = Pool().get('account.tax') + if len(line.taxes) != 1: raise UserError(gettext( 'edocument_xrechnung.msg_linetax_invalid_number', linename=line.rec_name, numtax=len(line.taxes))) - tax = line.taxes[0] - date = line.invoice.accounting_date or line.invoice.invoice_date - for child in tax.childs: - start_date = child.start_date or datetime.date.min - end_date = child.end_date or datetime.date.max - if start_date <= date <= end_date: - tax = child - break + taxlines = Tax.compute( + line.taxes, Decimal('1'), 1.0, + line.invoice.accounting_date or line.invoice.invoice_date) + assert len(taxlines) == 1 + tax = taxlines[0]['tax'] allowed_cat = ['AE', 'L', 'M', 'E', 'S', 'Z', 'G', 'O', 'K', 'B'] unece_category_code = self.get_category_code(tax) From e47380f93049594f75f458a1a579a8b5c33b05d8 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Thu, 9 Jan 2025 11:50:09 +0100 Subject: [PATCH 04/16] formatting --- template/Factur-X-1.07.2-extended/invoice.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index 09b8e4d..f23354e 100644 --- a/template/Factur-X-1.07.2-extended/invoice.xml +++ b/template/Factur-X-1.07.2-extended/invoice.xml @@ -83,7 +83,7 @@ this repository contains the full copyright notices and license terms. --> ${line.quantity * this.type_sign} - ${TradeTax(this.invoice_line_tax(line))} + ${TradeTax(this.invoice_line_tax(line))} ${line.amount} From ceb2a72fedeadbf517e3ee803e7a803ed3ceec1c Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Thu, 9 Jan 2025 11:50:52 +0100 Subject: [PATCH 05/16] tests: drop usage of Mock, generate invoice in db --- tests/test_edocument.py | 304 ++++++++++++++++++++++++---------------- 1 file changed, 183 insertions(+), 121 deletions(-) diff --git a/tests/test_edocument.py b/tests/test_edocument.py index 4ff38b7..5ba67ac 100644 --- a/tests/test_edocument.py +++ b/tests/test_edocument.py @@ -5,20 +5,159 @@ from lxml import etree import os -from unittest.mock import Mock from decimal import Decimal from datetime import date from trytond.tests.test_tryton import ModuleTestCase, with_transaction from trytond.pool import Pool -from trytond.modules.edocument_uncefact.tests.test_module import get_invoice from trytond.modules.company.tests import create_company, set_company +from trytond.modules.account.tests import create_chart, get_fiscalyear from trytond.exceptions import UserError +def set_invoice_sequences(fiscalyear): + pool = Pool() + Sequence = pool.get('ir.sequence.strict') + SequenceType = pool.get('ir.sequence.type') + InvoiceSequence = pool.get('account.fiscalyear.invoice_sequence') + ModelData = pool.get('ir.model.data') + + sequence = Sequence( + name=fiscalyear.name, + sequence_type=SequenceType(ModelData.get_id( + 'account_invoice', 'sequence_type_account_invoice')), + company=fiscalyear.company) + sequence.save() + fiscalyear.invoice_sequences = [] + invoice_sequence = InvoiceSequence() + invoice_sequence.fiscalyear = fiscalyear + invoice_sequence.in_invoice_sequence = sequence + invoice_sequence.in_credit_note_sequence = sequence + invoice_sequence.out_invoice_sequence = sequence + invoice_sequence.out_credit_note_sequence = sequence + invoice_sequence.save() + return fiscalyear + + class EdocTestCase(ModuleTestCase): 'Test e-rechnung module' module = 'edocument_xrechnung' + def prep_fiscalyear(self, company1): + """ prepare fiscal year, sequences... + """ + pool = Pool() + FiscalYear = pool.get('account.fiscalyear') + + fisc_year = get_fiscalyear(company1, today=date(2024, 1, 15)) + set_invoice_sequences(fisc_year) + self.assertEqual(len(fisc_year.invoice_sequences), 1) + FiscalYear.create_period([fisc_year]) + + def prep_company(self): + """ create company, add country and bank-account + """ + pool = Pool() + Country = pool.get('country.country') + Party = pool.get('party.party') + Bank = pool.get('bank') + BankAccount = pool.get('bank.account') + + country_de, = Country.create([{ + 'name': 'Germany', + 'code': 'DE', + 'code3': 'DEU'}]) + + company = create_company('m-ds') + Party.write(*[[company.party], { + 'addresses': [('write', [company.party.addresses[0]], { + 'country': country_de.id})]}]) + + bank_party, = Party.create([{ + 'name': 'Bank 123', + 'addresses': [('create', [{}])]}]) + bank, = Bank.create([{'party': bank_party.id}]) + BankAccount.create([{ + 'bank': bank.id, + 'owners': [('add', [company.party.id])], + 'numbers': [('create', [{ + 'type': 'iban', + 'number': 'DE02300209000106531065'}])]}]) + return company + + def prep_invoice(self, credit_note=False): + """ add invoice + """ + pool = Pool() + Invoice = pool.get('account.invoice') + Taxes = pool.get('account.tax') + Account = pool.get('account.account') + Journal = pool.get('account.journal') + Currency = pool.get('currency.currency') + Uom = pool.get('product.uom') + Country = pool.get('country.country') + Party = pool.get('party.party') + + country_de, = Country.search([('code', '=', 'DE')]) + customer, = Party.create([{ + 'name': 'Customer', + 'identifiers': [('create', [{ + 'type': 'edoc_route_id', 'code': 'xrechn-route-id-123'}])], + 'addresses': [('create', [{ + 'invoice': True, + 'street': 'Customer Street 1', + 'postal_code': '12345', + 'city': 'Usertown', + 'country': country_de.id, + }])], + }]) + + currency1, = Currency.search([('code', '=', 'usd')]) + + tax, = Taxes.search([('name', '=', '20% VAT')]) + Taxes.write(*[ + [tax], + {'unece_code': 'GST', 'unece_category_code': 'S', + 'legal_notice': 'Legal Notice'}]) + + account_lst = Account.search([ + ('name', 'in', ['Main Revenue', 'Main Receivable']) + ], order=[('name', 'ASC')]) + self.assertEqual(len(account_lst), 2) + self.assertEqual(account_lst[0].name, 'Main Receivable') + + journ_lst = Journal.search([('name', '=', 'Revenue')]) + self.assertEqual(len(journ_lst), 1) + + to_create_invoice = [{ + 'type': 'out', + 'description': 'description of invoice', + 'comment': 'note line 1\nnote line 2', + 'invoice_date': date(2024, 7, 1), + 'party': customer.id, + 'invoice_address': customer.addresses[0].id, + 'account': account_lst[0].id, + 'journal': journ_lst[0].id, + 'currency': currency1.id, + 'lines': [('create', [{ + 'type': 'line', + 'quantity': 2.0 if not credit_note else -2.0, + 'description': 'Product 1', + 'unit': Uom.search([('symbol', '=', 'u')])[0].id, + 'unit_price': Decimal('50.0'), + 'taxes': [('add', [tax.id])], + 'account': account_lst[1].id, + 'currency': currency1.id, + }])], + }] + inv_lst, = Invoice.create(to_create_invoice) + inv_lst.on_change_lines() + inv_lst.save() + Invoice.validate_invoice([inv_lst]) + Invoice.post([inv_lst]) + self.assertEqual(inv_lst.currency.code, 'usd') + self.assertEqual(len(inv_lst.move.lines), 3) + return inv_lst + @with_transaction() def test_xrechn_bank_account_owned(self): """ check field 'company_owned' on bank.account.number @@ -100,45 +239,24 @@ class EdocTestCase(ModuleTestCase): """ pool = Pool() Template = pool.get('edocument.facturxext.invoice') - Identifier = pool.get('party.identifier') - Party = pool.get('party.party') - Bank = pool.get('bank') - BankAccount = pool.get('bank.account') - BankNumber = pool.get('bank.account.number') - invoice = get_invoice() - invoice.payment_term_date = date.today() - invoice.party.get_xrechnung_route_id = Mock( - return_value='xrechn-route-id-123') - invoice.company.party.bank_accounts = [ - Mock( - spec=BankAccount, - currency=invoice.currency, - bank=Mock(spec=Bank, party=Mock(spec=Party, name='Bank')), - owners=[invoice.company.party], - numbers=[Mock(spec=BankNumber, type='other', number='123456')], - )] - invoice.description = 'description of invoice' - invoice.comment = 'note line 1\nnote line 2' - invoice.taxes[0].tax.rate = Decimal('0.1') - invoice.identifiers = [ - Mock( - spec=Identifier, - type='edoc_route_id', - code='xrechn-route-id-123') - ] + company = self.prep_company() + with set_company(company): + create_chart(company=company, tax=True) + self.prep_fiscalyear(company) + invoice = self.prep_invoice() - template = Template(invoice) + template = Template(invoice) - schema_file = os.path.join( - os.path.dirname(__file__), - 'Factur-X_1.07.2_EXTENDED', - 'Factur-X_1.07.2_EXTENDED.xsd') + schema_file = os.path.join( + os.path.dirname(__file__), + 'Factur-X_1.07.2_EXTENDED', + 'Factur-X_1.07.2_EXTENDED.xsd') - invoice_string = template.render('Factur-X-1.07.2-extended') - invoice_xml = etree.fromstring(invoice_string) - schema = etree.XMLSchema(etree.parse(schema_file)) - schema.assertValid(invoice_xml) + invoice_string = template.render('Factur-X-1.07.2-extended') + invoice_xml = etree.fromstring(invoice_string) + schema = etree.XMLSchema(etree.parse(schema_file)) + schema.assertValid(invoice_xml) @with_transaction() def test_xrechn_export_xml_invoice(self): @@ -146,45 +264,24 @@ class EdocTestCase(ModuleTestCase): """ pool = Pool() Template = pool.get('edocument.xrechnung.invoice') - Identifier = pool.get('party.identifier') - Party = pool.get('party.party') - Bank = pool.get('bank') - BankAccount = pool.get('bank.account') - BankNumber = pool.get('bank.account.number') - invoice = get_invoice() - invoice.payment_term_date = date.today() - invoice.party.get_xrechnung_route_id = Mock( - return_value='xrechn-route-id-123') - invoice.company.party.bank_accounts = [ - Mock( - spec=BankAccount, - currency=invoice.currency, - bank=Mock(spec=Bank, party=Mock(spec=Party, name='Bank')), - owners=[invoice.company.party], - numbers=[Mock(spec=BankNumber, type='other', number='123456')], - )] - invoice.description = 'description of invoice' - invoice.comment = 'note line 1\nnote line 2' - invoice.taxes[0].tax.rate = Decimal('0.1') - invoice.identifiers = [ - Mock( - spec=Identifier, - type='edoc_route_id', - code='xrechn-route-id-123') - ] + company = self.prep_company() + with set_company(company): + create_chart(company=company, tax=True) + self.prep_fiscalyear(company) + invoice = self.prep_invoice() - template = Template(invoice) + template = Template(invoice) - schema_file = os.path.join( - os.path.dirname(__file__), 'os-UBL-2.1', - 'xsd', 'maindoc', 'UBL-Invoice-2.1.xsd') + schema_file = os.path.join( + os.path.dirname(__file__), 'os-UBL-2.1', + 'xsd', 'maindoc', 'UBL-Invoice-2.1.xsd') - for x in ['XRechnung-2.2', 'XRechnung-2.3', 'XRechnung-3.0']: - invoice_string = template.render(x) - invoice_xml = etree.fromstring(invoice_string) - schema = etree.XMLSchema(etree.parse(schema_file)) - schema.assertValid(invoice_xml) + for x in ['XRechnung-2.2', 'XRechnung-2.3', 'XRechnung-3.0']: + invoice_string = template.render(x) + invoice_xml = etree.fromstring(invoice_string) + schema = etree.XMLSchema(etree.parse(schema_file)) + schema.assertValid(invoice_xml) @with_transaction() def test_xrechn_export_xml_creditnote(self): @@ -192,59 +289,24 @@ class EdocTestCase(ModuleTestCase): """ pool = Pool() Template = pool.get('edocument.xrechnung.invoice') - Identifier = pool.get('party.identifier') - Party = pool.get('party.party') - Bank = pool.get('bank') - BankAccount = pool.get('bank.account') - BankNumber = pool.get('bank.account.number') - invoice = get_invoice() + company = self.prep_company() + with set_company(company): + create_chart(company=company, tax=True) + self.prep_fiscalyear(company) + invoice = self.prep_invoice(credit_note=True) - # credit note - invoice.lines[0].quantity = -1 - invoice.lines[0].amount = Decimal('-100.0') - invoice.taxes[0].base = Decimal('-100.0') - invoice.taxes[0].amount = Decimal('-10.0') - invoice.untaxed_amount = Decimal('-100.0') - invoice.tax_amount = Decimal('-10.0') - invoice.total_amount = Decimal('-110.0') - invoice.lines_to_pay[0].debit = Decimal('-110.0') + template = Template(invoice) - invoice.party.get_xrechnung_route_id = Mock( - return_value='xrechn-route-id-123') - invoice.company.party.bank_accounts = [ - Mock( - spec=BankAccount, - currency=invoice.currency, - bank=Mock(spec=Bank, party=Mock(spec=Party, name='Bank')), - owners=[invoice.company.party], - numbers=[Mock(spec=BankNumber, type='other', number='123456')], - )] - invoice.description = 'description of invoice' - invoice.comment = 'note line 1\nnote line 2' - invoice.taxes[0].tax.rate = Decimal('0.1') - invoice.identifiers = [ - Mock( - spec=Identifier, - type='edoc_route_id', - code='xrechn-route-id-123') - ] + schema_file = os.path.join( + os.path.dirname(__file__), 'os-UBL-2.1', + 'xsd', 'maindoc', 'UBL-CreditNote-2.1.xsd') - template = Template(invoice) - - schema_file = os.path.join( - os.path.dirname(__file__), 'os-UBL-2.1', - 'xsd', 'maindoc', 'UBL-CreditNote-2.1.xsd') - - for x in ['XRechnung-2.2', 'XRechnung-2.3', 'XRechnung-3.0']: - invoice_string = template.render(x) - invoice_xml = etree.fromstring(invoice_string) - schema = etree.XMLSchema(etree.parse(schema_file)) - schema.assertValid(invoice_xml) - - # invoice_string = template.render('XRechnung-2.2') - # with open('xrechnung-test-creditnote.xml', 'wt') as fhdl: - # fhdl.write(invoice_string.decode('utf8')) + for x in ['XRechnung-2.2', 'XRechnung-2.3', 'XRechnung-3.0']: + invoice_string = template.render(x) + invoice_xml = etree.fromstring(invoice_string) + schema = etree.XMLSchema(etree.parse(schema_file)) + schema.assertValid(invoice_xml) # end EdocTestCase From 02221601e7487385682f9535eb8c9e60d8ce8638 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Tue, 28 Jan 2025 12:50:55 +0100 Subject: [PATCH 06/16] round unit_price of invoice-line by price_digits --- mixin.py | 18 +++++++++++++++++- template/Factur-X-1.07.2-extended/invoice.xml | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/mixin.py b/mixin.py index 8a75aca..9550384 100644 --- a/mixin.py +++ b/mixin.py @@ -4,12 +4,13 @@ # full copyright notices and license terms. -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_EVEN import html from trytond.exceptions import UserError from trytond.i18n import gettext from trytond.tools import cached_property from trytond.pool import Pool +from trytond.modules.product import price_digits class EdocumentMixin(object): @@ -183,6 +184,21 @@ class EdocumentMixin(object): taxname=tax.rec_name)) return unece_category_code + def round_unitprice(self, value): + """ round value by digits in unit_price of account.invoice.line + + Args: + value (Decimal): unit-price + + Returns: + Decimal: rounded value + """ + if isinstance(value, Decimal): + return value.quantize( + Decimal(str(1/10 ** price_digits[1])), + ROUND_HALF_EVEN) + return value + def quote_text(self, text): """ replace critical chars """ diff --git a/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index f23354e..db85eb1 100644 --- a/template/Factur-X-1.07.2-extended/invoice.xml +++ b/template/Factur-X-1.07.2-extended/invoice.xml @@ -76,7 +76,7 @@ this repository contains the full copyright notices and license terms. --> - ${this.invoice.currency.round(line.unit_price)} + ${this.round_unitprice(line.unit_price)} From ea5a83d7c1ff41373b99d60db095d37371f4bba0 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 29 Jan 2025 09:33:40 +0100 Subject: [PATCH 07/16] use product.round_price() to round unit_price of invoice-line --- mixin.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mixin.py b/mixin.py index 9550384..c854b7d 100644 --- a/mixin.py +++ b/mixin.py @@ -4,13 +4,13 @@ # full copyright notices and license terms. -from decimal import Decimal, ROUND_HALF_EVEN +from decimal import Decimal import html from trytond.exceptions import UserError from trytond.i18n import gettext from trytond.tools import cached_property from trytond.pool import Pool -from trytond.modules.product import price_digits +from trytond.modules.product import round_price class EdocumentMixin(object): @@ -193,10 +193,8 @@ class EdocumentMixin(object): Returns: Decimal: rounded value """ - if isinstance(value, Decimal): - return value.quantize( - Decimal(str(1/10 ** price_digits[1])), - ROUND_HALF_EVEN) + if value is not None: + return round_price(value) return value def quote_text(self, text): From a5bf930e55c2dbb276b9f626db34436cfa04bbb0 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Fri, 2 May 2025 14:16:47 +0200 Subject: [PATCH 08/16] update license --- COPYRIGHT | 6 ++---- setup.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/COPYRIGHT b/COPYRIGHT index 0fbd54d..27458f9 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,7 +1,5 @@ -Copyright (C) 2015-2023 Cédric Krier. -Copyright (C) 2015-2023 B2CK SPRL. -Copyright (C) 2021-2024 martin-data services. -Copyright (C) 2024 Mathias Behrle +Copyright (C) 2021-2025 martin-data services. +Copyright (C) 2024-2025 Mathias Behrle This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/setup.py b/setup.py index efa68a9..c82d777 100644 --- a/setup.py +++ b/setup.py @@ -13,11 +13,9 @@ here = path.abspath(path.dirname(__file__)) MODULE = 'edocument_xrechnung' PREFIX = 'mds' -# Get the long description from the README file with open(path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = f.read() -# tryton.cfg einlesen config = ConfigParser() config.readfp(open('tryton.cfg')) info = dict(config.items('tryton')) @@ -87,6 +85,8 @@ setup( 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], keywords='tryton xrechnung edcoument', From 214cbb086f0ed275249a214b625eaa33d2c46fdd Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Tue, 10 Jun 2025 13:45:07 +0200 Subject: [PATCH 09/16] get amount on line as net-value (even if invoice is gross-mode) --- mixin.py | 36 ++++++++++++++ template/Factur-X-1.07.2-extended/invoice.xml | 2 +- tests/test_edocument.py | 47 ++++++++++++++++++- tryton.cfg | 3 ++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/mixin.py b/mixin.py index c854b7d..f7cda1c 100644 --- a/mixin.py +++ b/mixin.py @@ -10,6 +10,7 @@ from trytond.exceptions import UserError from trytond.i18n import gettext from trytond.tools import cached_property from trytond.pool import Pool +from trytond.transaction import Transaction from trytond.modules.product import round_price @@ -160,6 +161,41 @@ class EdocumentMixin(object): taxname=tax.rec_name)) return tax.unece_code + def get_line_amount(self, line): + """ get amount of current invoice-line, + depends on modegross of invoice, set used-modegross to 'net' + + Args: + line (record): model account.invoice.line + """ + if line.modegross == 'net': + return line.amount + elif line.modegross == 'gross': + # get net-amount + # copy from account_invoice/invoice.py:2416-2434 + currency = ( + line.invoice.currency + if line.invoice else line.currency) + + amount = (Decimal(str(line.quantity or 0)) * ( + line.unit_price or Decimal(0))) + invoice_type = ( + line.invoice.type + if line.invoice else line.invoice_type) + + if (invoice_type == 'in' + and line.taxes_deductible_rate is not None + and line.taxes_deductible_rate != 1): + with Transaction().set_context(_deductible_rate=1): + tax_amount = sum( + t['amount'] for t in line._get_taxes().values()) + non_deductible_amount = ( + tax_amount * (1 - line.taxes_deductible_rate)) + amount += non_deductible_amount + if currency: + return currency.round(amount) + return amount + def get_tax_unece_code(self, tax): while tax: if tax.unece_code: diff --git a/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index db85eb1..bad23a9 100644 --- a/template/Factur-X-1.07.2-extended/invoice.xml +++ b/template/Factur-X-1.07.2-extended/invoice.xml @@ -85,7 +85,7 @@ this repository contains the full copyright notices and license terms. --> ${TradeTax(this.invoice_line_tax(line))} - ${line.amount} + ${this.get_line_amount(line)} diff --git a/tests/test_edocument.py b/tests/test_edocument.py index 5ba67ac..02669f9 100644 --- a/tests/test_edocument.py +++ b/tests/test_edocument.py @@ -7,7 +7,8 @@ from lxml import etree import os from decimal import Decimal from datetime import date -from trytond.tests.test_tryton import ModuleTestCase, with_transaction +from trytond.tests.test_tryton import ( + ModuleTestCase, with_transaction, activate_module) from trytond.pool import Pool from trytond.modules.company.tests import create_company, set_company from trytond.modules.account.tests import create_chart, get_fiscalyear @@ -42,6 +43,14 @@ class EdocTestCase(ModuleTestCase): 'Test e-rechnung module' module = 'edocument_xrechnung' + @classmethod + def setUpClass(cls): + super().setUpClass() + activate_module([ + 'edocument_uncefact', 'party', 'bank', + 'account_invoice', 'sale_point_invoice', + 'product_grossprice'], 'en') + def prep_fiscalyear(self, company1): """ prepare fiscal year, sequences... """ @@ -84,7 +93,7 @@ class EdocTestCase(ModuleTestCase): 'number': 'DE02300209000106531065'}])]}]) return company - def prep_invoice(self, credit_note=False): + def prep_invoice(self, credit_note=False, modegross='net'): """ add invoice """ pool = Pool() @@ -130,6 +139,7 @@ class EdocTestCase(ModuleTestCase): to_create_invoice = [{ 'type': 'out', + 'modegross': modegross, 'description': 'description of invoice', 'comment': 'note line 1\nnote line 2', 'invoice_date': date(2024, 7, 1), @@ -149,6 +159,11 @@ class EdocTestCase(ModuleTestCase): 'currency': currency1.id, }])], }] + + if modegross == 'gross': + to_create_invoice[0]['lines'][0][1][0]['unit_gross_price'] = ( + Decimal('50.0') * Decimal('1.2')) + inv_lst, = Invoice.create(to_create_invoice) inv_lst.on_change_lines() inv_lst.save() @@ -233,6 +248,34 @@ class EdocTestCase(ModuleTestCase): {'identifiers': [('delete', [party.identifiers[0].id])]}] ) + @with_transaction() + def test_xrechn_export_facturx_gross(self): + """ run export - factur-x, modegross='gross' + """ + pool = Pool() + Template = pool.get('edocument.facturxext.invoice') + + company = self.prep_company() + with set_company(company): + create_chart(company=company, tax=True) + self.prep_fiscalyear(company) + invoice = self.prep_invoice(modegross='gross') + + template = Template(invoice) + + schema_file = os.path.join( + os.path.dirname(__file__), + 'Factur-X_1.07.2_EXTENDED', + 'Factur-X_1.07.2_EXTENDED.xsd') + + invoice_string = template.render('Factur-X-1.07.2-extended') + with open('gross_invoice_string.xml', 'wb') as fhdl: + fhdl.write(invoice_string) + + invoice_xml = etree.fromstring(invoice_string) + schema = etree.XMLSchema(etree.parse(schema_file)) + schema.assertValid(invoice_xml) + @with_transaction() def test_xrechn_export_facturx(self): """ run export - factur-x diff --git a/tryton.cfg b/tryton.cfg index 509c618..ecd8916 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -5,6 +5,9 @@ depends: party bank account_invoice +extras_depend: + sale_point_invoice + product_grossprice xml: message.xml configuration.xml From d065f74482f218151452e8593897c987210ab327 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 11 Jun 2025 11:46:53 +0200 Subject: [PATCH 10/16] add legal id of seller --- mixin.py | 26 +++++++++++++++++++ tests/test_edocument.py | 56 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/mixin.py b/mixin.py index f7cda1c..1ffdd6f 100644 --- a/mixin.py +++ b/mixin.py @@ -239,4 +239,30 @@ class EdocumentMixin(object): if text: return html.escape(text) + def _party_legal_types(self): + """ get list of identifier-types to be used as + legal-ids + """ + return ['de_handelsregisternummer'] + + def party_legal_ids(self, party, address): + """ get list of legal-ids of party + + Args: + party (record): model party.party + address (record): model party.address + """ + result = super().party_legal_ids(party, address) + + legal_types = self._party_legal_types() + if party and party.identifiers: + for x in party.identifiers: + if x.type in legal_types: + if x.address: + if x.address == address: + result.append((x.rec_name, {'schemeID': '0002'})) + else: + result.append((x.rec_name, {'schemeID': '0002'})) + return result + # end EdocumentMixin diff --git a/tests/test_edocument.py b/tests/test_edocument.py index 02669f9..a7c9355 100644 --- a/tests/test_edocument.py +++ b/tests/test_edocument.py @@ -78,6 +78,11 @@ class EdocTestCase(ModuleTestCase): company = create_company('m-ds') Party.write(*[[company.party], { + 'identifiers': [('create', [ + # post.de + {'type': 'de_handelsregisternummer', 'code': 'Bonn HRB 6792'}, + {'type': 'de_vat', 'code': 'DE 169838187'}, + ])], 'addresses': [('write', [company.party.addresses[0]], { 'country': country_de.id})]}]) @@ -121,6 +126,7 @@ class EdocTestCase(ModuleTestCase): }]) currency1, = Currency.search([('code', '=', 'usd')]) + Currency.write(*[[currency1], {'code': 'USD'}]) tax, = Taxes.search([('name', '=', '20% VAT')]) Taxes.write(*[ @@ -169,7 +175,7 @@ class EdocTestCase(ModuleTestCase): inv_lst.save() Invoice.validate_invoice([inv_lst]) Invoice.post([inv_lst]) - self.assertEqual(inv_lst.currency.code, 'usd') + self.assertEqual(inv_lst.currency.code, 'USD') self.assertEqual(len(inv_lst.move.lines), 3) return inv_lst @@ -273,9 +279,44 @@ class EdocTestCase(ModuleTestCase): fhdl.write(invoice_string) invoice_xml = etree.fromstring(invoice_string) + + # check values in xml + nodes = invoice_xml.xpath(self._readxml_xpath([ + 'rsm:CrossIndustryInvoice', 'rsm:SupplyChainTradeTransaction', + 'ram:ApplicableHeaderTradeAgreement', 'ram:SellerTradeParty', + 'ram:SpecifiedLegalOrganization', 'ram:ID']), + namespaces=invoice_xml.nsmap) + self.assertEqual(nodes[0].text, 'Bonn HRB 6792') + + nodes = invoice_xml.xpath(self._readxml_xpath([ + 'rsm:CrossIndustryInvoice', 'rsm:SupplyChainTradeTransaction', + 'ram:IncludedSupplyChainTradeLineItem', + 'ram:SpecifiedLineTradeSettlement', + 'ram:SpecifiedTradeSettlementLineMonetarySummation', + 'ram:LineTotalAmount']), + namespaces=invoice_xml.nsmap) + self.assertEqual(nodes[0].text, '100.00') + schema = etree.XMLSchema(etree.parse(schema_file)) schema.assertValid(invoice_xml) + def _readxml_xpath(self, tags): + """ generate xpath + + Args: + tags (list): list of string or integer to build path + """ + parts = [] + for x in tags: + if isinstance(x, str): + parts.append(x) + elif isinstance(x, int): + if parts[-1].endswith(']'): + raise ValueError('multiple list selector') + parts[-1] += '[%d]' % x + result = '/' + '/'.join(parts) + return result + @with_transaction() def test_xrechn_export_facturx(self): """ run export - factur-x @@ -291,6 +332,10 @@ class EdocTestCase(ModuleTestCase): template = Template(invoice) + self.assertEqual( + template.party_legal_ids(invoice.company_party, None), + [('Bonn HRB 6792', {'schemeID': '0002'})]) + schema_file = os.path.join( os.path.dirname(__file__), 'Factur-X_1.07.2_EXTENDED', @@ -298,6 +343,15 @@ class EdocTestCase(ModuleTestCase): invoice_string = template.render('Factur-X-1.07.2-extended') invoice_xml = etree.fromstring(invoice_string) + + # check values in xml + nodes = invoice_xml.xpath(self._readxml_xpath([ + 'rsm:CrossIndustryInvoice', 'rsm:SupplyChainTradeTransaction', + 'ram:ApplicableHeaderTradeAgreement', 'ram:SellerTradeParty', + 'ram:SpecifiedLegalOrganization', 'ram:ID']), + namespaces=invoice_xml.nsmap) + self.assertEqual(nodes[0].text, 'Bonn HRB 6792') + schema = etree.XMLSchema(etree.parse(schema_file)) schema.assertValid(invoice_xml) From 81f6a3e4a891c6193a7207bceae5e80230a034a9 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 11 Jun 2025 11:48:35 +0200 Subject: [PATCH 11/16] ApplicableTradeTax: disable ExemptionReason if CategoryCode exist --- template/Factur-X-1.07.2-extended/invoice.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index bad23a9..9af1698 100644 --- a/template/Factur-X-1.07.2-extended/invoice.xml +++ b/template/Factur-X-1.07.2-extended/invoice.xml @@ -38,7 +38,7 @@ this repository contains the full copyright notices and license terms. --> ${amount * this.type_sign} ${this.tax_unece_code(tax)} - ${tax.legal_notice} + ${tax.legal_notice} ${base * this.type_sign} ${this.tax_category_code(tax)} ${tax.rate * 100} From 4c2565e15ec3b30e644ba195a58642e15edb2bca Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 11 Jun 2025 11:48:55 +0200 Subject: [PATCH 12/16] add xml-validator --- docs/xrechnung.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/xrechnung.txt b/docs/xrechnung.txt index f8fbbbd..dab13fa 100644 --- a/docs/xrechnung.txt +++ b/docs/xrechnung.txt @@ -12,3 +12,5 @@ https://portal3.gefeg.com/projectdata/invoice/deliverables/installed/publishingp https://erechnungsvalidator.service-bw.de/ https://ecosio.com/de/peppol-und-xml-dokumente-online-validieren/ + +https://www.e-rechnungs-checker.de/ From 2dab5b1b438d7db9f21732bd0fff13005c4422a2 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 11 Jun 2025 11:57:16 +0200 Subject: [PATCH 13/16] SpecifiedTradeProduct: export 'description' as 'name' if no product was used in invoice-line --- template/Factur-X-1.07.2-extended/invoice.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index 9af1698..938c38a 100644 --- a/template/Factur-X-1.07.2-extended/invoice.xml +++ b/template/Factur-X-1.07.2-extended/invoice.xml @@ -71,8 +71,8 @@ this repository contains the full copyright notices and license terms. --> ${line.product.code} - ${this.quote_text(line.product.name if line.product else '')} - ${this.quote_text(line.description)} + ${this.quote_text(line.product.name if line.product else line.description if line.description else 'name not set')} + ${this.quote_text(line.description if line.product else '')} From 437f047e44b63d860dd2345f411318ba8113156f Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 11 Jun 2025 12:14:05 +0200 Subject: [PATCH 14/16] test: fix tax-code --- tests/test_edocument.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_edocument.py b/tests/test_edocument.py index a7c9355..179957d 100644 --- a/tests/test_edocument.py +++ b/tests/test_edocument.py @@ -131,7 +131,7 @@ class EdocTestCase(ModuleTestCase): tax, = Taxes.search([('name', '=', '20% VAT')]) Taxes.write(*[ [tax], - {'unece_code': 'GST', 'unece_category_code': 'S', + {'unece_code': 'VAT', 'unece_category_code': 'S', 'legal_notice': 'Legal Notice'}]) account_lst = Account.search([ From f3375d3d39894eb30f8816694eb8208dc6656a38 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 11 Jun 2025 15:57:35 +0200 Subject: [PATCH 15/16] allow any vat-type to be exported as 'SpecifiedTaxRegistration' --- template/Factur-X-1.07.2-extended/invoice.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index 938c38a..a4620c5 100644 --- a/template/Factur-X-1.07.2-extended/invoice.xml +++ b/template/Factur-X-1.07.2-extended/invoice.xml @@ -19,7 +19,7 @@ this repository contains the full copyright notices and license terms. --> ${TradeAddress(address)} - + ${tax_identifier.code} From a8541f73bdde8d6e3985999cfd6b020750cc3512 Mon Sep 17 00:00:00 2001 From: Frederik Jaeckel Date: Wed, 2 Jul 2025 13:51:57 +0200 Subject: [PATCH 16/16] dont except if no 'modegross' on invoice.line --- mixin.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mixin.py b/mixin.py index 1ffdd6f..dc235ac 100644 --- a/mixin.py +++ b/mixin.py @@ -168,6 +168,9 @@ class EdocumentMixin(object): Args: line (record): model account.invoice.line """ + if not hasattr(line, 'modegross'): + return line.amount + if line.modegross == 'net': return line.amount elif line.modegross == 'gross':