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/ diff --git a/mixin.py b/mixin.py index c854b7d..1ffdd6f 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: @@ -203,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/template/Factur-X-1.07.2-extended/invoice.xml b/template/Factur-X-1.07.2-extended/invoice.xml index db85eb1..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} @@ -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} @@ -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 '')} @@ -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..179957d 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... """ @@ -69,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})]}]) @@ -84,7 +98,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() @@ -112,11 +126,12 @@ class EdocTestCase(ModuleTestCase): }]) currency1, = Currency.search([('code', '=', 'usd')]) + Currency.write(*[[currency1], {'code': 'USD'}]) 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([ @@ -130,6 +145,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,12 +165,17 @@ 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() 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 @@ -233,6 +254,69 @@ 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) + + # 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 @@ -248,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', @@ -255,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) diff --git a/tryton.cfg b/tryton.cfg index 62277bd..42646e1 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