details: https://code.tryton.org/tryton/commit/237ad99f3031 branch: default user: Cédric Krier <[email protected]> date: Thu Nov 20 13:08:49 2025 +0100 description: Add parser for UBL invoice and credit note diffstat:
modules/edocument_ubl/edocument.py | 890 +++++++++- modules/edocument_ubl/exceptions.py | 8 + modules/edocument_ubl/message.xml | 33 + modules/edocument_ubl/setup.py | 8 +- modules/edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml | 409 ++++ modules/edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml | 39 + modules/edocument_ubl/tests/UBL-Invoice-2.1-Example.xml | 470 +++++ modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.json | 4 + modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.rst | 90 + modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.json | 4 + modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.rst | 90 + modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse_trivial.rst | 56 + modules/edocument_ubl/tests/test_module.py | 9 +- modules/edocument_ubl/tests/test_scenario.py | 8 + modules/edocument_ubl/tryton.cfg | 7 + 15 files changed, 2112 insertions(+), 13 deletions(-) diffs (2264 lines): diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/edocument.py --- a/modules/edocument_ubl/edocument.py Wed Oct 29 15:55:04 2025 +0100 +++ b/modules/edocument_ubl/edocument.py Thu Nov 20 13:08:49 2025 +0100 @@ -1,22 +1,35 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +import bisect +import datetime as dt import mimetypes import os -from base64 import b64encode -from itertools import groupby +from base64 import b64decode, b64encode +from decimal import Decimal +from io import BytesIO +from itertools import chain, groupby +from operator import itemgetter import genshi import genshi.template # XXX fix: https://genshi.edgewall.org/ticket/582 from genshi.template.astutil import ASTCodeGenerator, ASTTransformer +from lxml import etree +from trytond.i18n import gettext from trytond.model import Model -from trytond.pool import Pool +from trytond.modules.product import round_price +from trytond.pool import Pool, PoolMeta from trytond.rpc import RPC from trytond.tools import cached_property, slugify from trytond.transaction import Transaction +from .exceptions import InvoiceError +from .party import ISO6523_TYPES + +ISO6523 = {v: k for k, v in ISO6523_TYPES.items()} + if not hasattr(ASTCodeGenerator, 'visit_NameConstant'): def visit_NameConstant(self, node): if node.value is None: @@ -53,6 +66,7 @@ super().__setup__() cls.__rpc__.update({ 'render': RPC(instantiate=0), + 'parse': RPC(readonly=False, result=int), }) def __init__(self, invoice): @@ -168,3 +182,873 @@ @cached_property def lines(self): return [l for l in self.invoice.lines if l.type == 'line'] + + @classmethod + def parse(cls, document): + pool = Pool() + Attachment = pool.get('ir.attachment') + + tree = etree.parse(BytesIO(document)) + root = tree.getroot() + namespace = root.nsmap.get(None) + invoice, attachments = cls.parser(namespace)(root) + invoice.save() + invoice.update_taxes() + cls.checker(namespace)(root, invoice) + attachments = list(attachments) + for attachment in attachments: + attachment.resource = invoice + Attachment.save(attachments) + return invoice + + @classmethod + def parser(cls, namespace): + return { + 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2': ( + cls._parse_invoice_2), + 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2': ( + cls._parse_credit_note_2), + }.get(namespace) + + @classmethod + def checker(cls, namespace): + return { + 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2': ( + cls._check_invoice_2), + 'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2': ( + cls._check_credit_note_2), + }.get(namespace) + + @classmethod + def _parse_invoice_2(cls, root): + pool = Pool() + Invoice = pool.get('account.invoice') + Currency = pool.get('currency.currency') + + type_code = root.findtext('./{*}InvoiceTypeCode') + if type_code and type_code != '380': + raise InvoiceError(gettext( + 'edocument_ubl.msg_invoice_type_code_unsupported', + type_code=type_code)) + + invoice = Invoice(type='in') + invoice.reference = root.findtext('./{*}ID') + invoice.invoice_date = dt.date.fromisoformat( + root.findtext('./{*}IssueDate')) + invoice.party = cls._parse_2_supplier( + root.find('./{*}AccountingSupplierParty'), create=True) + invoice.set_journal() + invoice.on_change_party() + invoice.invoice_address = cls._parse_2_address( + root.find( + './{*}AccountingSupplierParty/{*}Party/{*}PostalAddress'), + party=invoice.party) + invoice.party_tax_identifier = cls._parse_2_tax_identifier( + root.findall( + './{*}AccountingSupplierParty/{*}Party/{*}PartyTaxScheme'), + party=invoice.party, create=True) + if (seller := root.find('./{*}SellerSupplierParty')) is not None: + supplier = cls._parse_2_supplier(seller) + else: + supplier = invoice.party + if (customer_party := root.find('./{*}AccountingCustomerParty') + ) is not None: + invoice.company = cls._parse_2_company(customer_party) + else: + invoice.company = Invoice.default_company() + if not invoice.company: + raise InvoiceError(gettext( + 'edocument_ubl.msg_company_not_found', + company=etree.tostring( + customer_party, pretty_print=True).decode() + if customer_party else '')) + + if (payee_party := root.find('./{*}PayeeParty')) is not None: + party = cls._parse_2_party(payee_party) + if not party: + party = cls._create_2_party(payee_party) + party.save() + invoice.alternative_payees = [party] + + currency_code = root.findtext('./{*}DocumentCurrencyCode') + if not currency_code: + payable_amount = root.find( + './{*}LegalMonetaryTotal/{*}PayableAmount') + currency_code = payable_amount.get('currencyID') + if currency_code is not None: + try: + invoice.currency, = Currency.search([ + ('code', '=', currency_code), + ], limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_ubl.msg_currency_not_found', + code=currency_code)) + + invoice.payment_term_date = cls._parse_2_payment_term_date( + root.findall('./{*}PaymentTerms')) + invoice.lines = [ + cls._parse_invoice_2_line( + line, company=invoice.company, currency=invoice.currency, + supplier=supplier) + for line in root.iterfind('./{*}InvoiceLine')] + invoice.taxes = [ + cls._parse_2_tax( + tax, company=invoice.company) + for tax in root.iterfind('./{*}TaxTotal/{*}TaxSubtotal')] + + if (hasattr(Invoice, 'cash_rounding') + and (root.find( + './{*}LegalMonetaryTotal/{*}PayableRoundingAmount') + is not None)): + invoice.cash_rounding = True + return invoice, cls._parse_2_attachments(root) + + @classmethod + def _parse_invoice_2_line( + cls, invoice_line, company, currency, supplier=None): + pool = Pool() + Line = pool.get('account.invoice.line') + UoM = pool.get('product.uom') + Tax = pool.get('account.tax') + AccountConfiguration = pool.get('account.configuration') + + account_configuration = AccountConfiguration(1) + + line = Line(type='line', company=company, currency=currency) + if (invoiced_quantity := invoice_line.find('./{*}InvoicedQuantity') + ) is not None: + line.quantity = float(invoiced_quantity.text) + if (unit_code := invoiced_quantity.get('unitCode')) is not None: + try: + line.unit, = UoM.search([ + ('unece_code', '=', unit_code), + ], limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_ubl.msg_unit_not_found', + code=unit_code)) + else: + line.quantity = 1 + line.unit = None + + line.product = cls._parse_2_item( + invoice_line.find('./{*}Item'), supplier=supplier) + if line.product: + line.on_change_product() + + line.description = '\n'.join(e.text for e in chain( + invoice_line.iterfind('./{*}Item/{*}Name'), + invoice_line.iterfind('./{*}Item/{*}BrandName'), + invoice_line.iterfind('./{*}Item/{*}ModelName'), + invoice_line.iterfind('./{*}Item/{*}Description'), + invoice_line.iterfind( + './{*}Item/{*}AdditionalInformation'), + invoice_line.iterfind('./{*}Item/{*}WarrantyInformation'), + ) if e is not None and e.text) + + if not line.product: + if line.description: + similar_lines = Line.search([ + ('description', 'ilike', line.description), + ('invoice.company', '=', company), + ('invoice.type', '=', 'in'), + ('invoice.state', 'in', + ['validated', 'posted', 'paid']), + ], + order=[('invoice.invoice_date', 'DESC')], + limit=1) + else: + similar_lines = [] + if similar_lines: + similar_line, = similar_lines + line.account = similar_line.account + line.product = similar_line.product + else: + line.account = account_configuration.get_multivalue( + 'default_category_account_expense', + company=company.id) + + for line_reference in invoice_line.iterfind('./{*}OrderLineReference'): + line.origin = cls._parse_2_line_reference( + line_reference, line, company, supplier=supplier) + if line.origin: + break + + if (price_amount := invoice_line.findtext('./{*}Price/{*}PriceAmount') + ) is not None: + line.unit_price = round_price(Decimal(price_amount)) + else: + line.unit_price = round_price( + Decimal(invoice_line.findtext('./{*}LineExtensionAmount')) + / Decimal(str(line.quantity))) + + if invoice_line.find('./{*}Item/{*}ClassifiedTaxCategory') is not None: + tax_categories = invoice_line.iterfind( + './{*}Item/{*}ClassifiedTaxCategory') + else: + tax_categories = invoice_line.iterfind( + './{*}TaxTotal/{*}TaxSubtotal/{*}TaxCategory') + taxes = [] + for tax_category in tax_categories: + domain = cls._parse_2_tax_category(tax_category) + domain.extend([ + ['OR', + ('group', '=', None), + ('group.kind', 'in', ['purchase', 'both']), + ], + ('company', '=', company.id), + ]) + try: + tax, = Tax.search(domain, limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_ubl.msg_tax_not_found', + tax_category=etree.tostring( + tax_category, pretty_print=True).decode())) + taxes.append(tax) + line.taxes = taxes + return line + + @classmethod + def _parse_credit_note_2(cls, root): + pool = Pool() + Invoice = pool.get('account.invoice') + Currency = pool.get('currency.currency') + + type_code = root.findtext('./{*}CreditNoteTypeCode') + if type_code and type_code != '381': + raise InvoiceError(gettext( + 'edocument_ubl.msg_credit_note_type_code_unsupported', + type_code=type_code)) + + invoice = Invoice(type='in') + invoice.reference = root.findtext('./{*}ID') + invoice.invoice_date = dt.date.fromisoformat( + root.findtext('./{*}IssueDate')) + invoice.party = cls._parse_2_supplier( + root.find('./{*}AccountingSupplierParty'), create=True) + invoice.set_journal() + invoice.on_change_party() + if (seller := root.find('./{*}SellerSupplierParty')) is not None: + supplier = cls._parse_2_supplier(seller) + else: + supplier = invoice.party + if (customer_party := root.find('./{*}AccountingCustomerParty') + ) is not None: + invoice.company = cls._parse_2_company(customer_party) + else: + invoice.company = Invoice.default_company() + if (payee_party := root.find('./{*}PayeeParty')) is not None: + party = cls._parse_2_party(payee_party) + if not party: + party = cls._create_2_party(payee_party) + party.save() + invoice.alternative_payees = [party] + if (currency_code := root.findtext('./{*}DocumentCurrencyCode') + ) is not None: + try: + invoice.currency, = Currency.search([ + ('code', '=', currency_code), + ], limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_ubl.msg_currency_not_found', + code=currency_code)) + invoice.payment_term_date = cls._parse_2_payment_term_date( + root.findall('./{*}PaymentTerms')) + invoice.lines = [ + cls._parse_credit_note_2_line( + line, company=invoice.company, currency=invoice.currency, + supplier=supplier) + for line in root.iterfind('./{*}CreditNoteLine')] + invoice.taxes = [ + cls._parse_2_tax( + tax, company=invoice.company) + for tax in root.iterfind('./{*}TaxTotal/{*}TaxSubtotal')] + + if (hasattr(Invoice, 'cash_rounding') + and root.find( + './{*}LegalMonetaryTotal/{*}PayableRoundingAmount') + is not None): + invoice.cash_rounding = True + return invoice, cls._parse_2_attachments(root) + + @classmethod + def _parse_credit_note_2_line( + cls, credit_note_line, company, currency, supplier=None): + pool = Pool() + Line = pool.get('account.invoice.line') + UoM = pool.get('product.uom') + Tax = pool.get('account.tax') + AccountConfiguration = pool.get('account.configuration') + + account_configuration = AccountConfiguration(1) + + line = Line(type='line', company=company, currency=currency) + if (credited_quantity := credit_note_line.find('./{*}CreditedQuantity') + ) is not None: + line.quantity = -float(credited_quantity.text) + if (unit_code := credited_quantity.get('unitCode')) is not None: + try: + line.unit, = UoM.search([ + ('unece_code', '=', unit_code), + ], limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_ubl.msg_unit_not_found', + code=unit_code)) + else: + line.quantity = -1 + line.unit = None + + line.product = cls._parse_2_item( + credit_note_line.find('./{*}Item'), supplier=supplier) + if line.product: + line.on_change_product() + + line.description = '\n'.join(e.text for e in chain( + credit_note_line.iterfind('./{*}Item/{*}Name'), + credit_note_line.iterfind('./{*}Item/{*}BrandName'), + credit_note_line.iterfind('./{*}Item/{*}ModelName'), + credit_note_line.iterfind('./{*}Item/{*}Description'), + credit_note_line.iterfind( + './{*}Item/{*}AdditionalInformation'), + credit_note_line.iterfind('./{*}Item/{*}WarrantyInformation'), + ) if e is not None and e.text) + + if not line.product: + if line.description: + similar_lines = Line.search([ + ('description', 'ilike', line.description), + ('invoice.company', '=', company), + ('invoice.type', '=', 'in'), + ('invoice.state', 'in', + ['validated', 'posted', 'paid']), + ], + order=[('invoice.invoice_date', 'DESC')], + limit=1) + else: + similar_lines = [] + if similar_lines: + similar_line, = similar_lines + line.account = similar_line.account + line.product = similar_line.product + else: + line.account = account_configuration.get_multivalue( + 'default_category_account_expense', + company=company.id) + + for line_reference in credit_note_line.iterfind( + './{*}OrderLineReference'): + line.origin = cls._parse_2_line_reference( + line_reference, line, company, supplier=supplier) + if line.origin: + break + + if (price_amount := credit_note_line.findtext( + './{*}Price/{*}PriceAmount')) is not None: + line.unit_price = round_price(Decimal(price_amount)) + else: + line.unit_price = round_price( + Decimal(credit_note_line.findtext('./{*}LineExtensionAmount')) + / line.quantity) + + if (credit_note_line.find('./{*}Item/{*}ClassifiedTaxCategory') + is not None): + tax_categories = credit_note_line.iterfind( + './{*}Item/{*}ClassifiedTaxCategory') + else: + tax_categories = credit_note_line.iterfind( + './{*}TaxTotal/{*}TaxSubtotal/{*}TaxCategory') + taxes = [] + for tax_category in tax_categories: + domain = cls._parse_2_tax_category(tax_category) + domain.extend([ + ['OR', + ('group', '=', None), + ('group.kind', 'in', ['purchase', 'both']), + ], + ('company', '=', company.id), + ]) + try: + tax, = Tax.search(domain, limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_ubl.msg_tax_not_found', + tax_category=etree.tostring( + tax_category, pretty_print=True).decode())) + taxes.append(tax) + line.taxes = taxes + return line + + @classmethod + def _parse_2_supplier(cls, supplier_party, create=False): + pool = Pool() + Party = pool.get('party.party') + for account_id in filter(None, chain( + [supplier_party.find('./{*}CustomerAssignedAccountID')], + supplier_party.iterfind('./{*}AdditionalAccountID'))): + if account_id.text: + try: + party, = Party.search([ + ('code', '=', account_id.text), + ]) + except ValueError: + pass + else: + return party + party_el = supplier_party.find('./{*}Party') + party = cls._parse_2_party(party_el) + if not party and create: + party = cls._create_2_party(party_el) + party.save() + return party + + @classmethod + def _parse_2_party(cls, party_el): + pool = Pool() + Party = pool.get('party.party') + + for identifier in party_el.iterfind('./{*}PartyIdentification/{*}ID'): + if identifier.text: + parties = Party.search([ + ('identifiers.code', '=', identifier.text), + ]) + if len(parties) == 1: + party, = parties + return party + + @classmethod + def _create_2_party(cls, party_el): + pool = Pool() + Party = pool.get('party.party') + party = Party() + party.name = party_el.findtext('./{*}PartyName/{*}Name') + identifiers = [] + for identifier in party_el.iterfind('./{*}PartyIdentification/{*}ID'): + if identifier.text: + identifiers.append(cls._create_2_party_identifier( + identifier)) + party.identifiers = identifiers + if (address := party_el.find('./{*}PostalAddress') + ) is not None: + party.addresses = [cls._create_2_address(address)] + return party + + @classmethod + def _create_2_party_identifier(cls, identifier): + pool = Pool() + Identifier = pool.get('party.identifier') + if schemeId := identifier.get('schemeID'): + type = ISO6523.get(schemeId) + else: + type = None + return Identifier(type=type, code=identifier.text) + + @classmethod + def _parse_2_address(cls, address_el, party): + pool = Pool() + Address = pool.get('party.address') + + address = cls._create_2_address(address_el) + + domain = [('party', '=', party)] + for fname in Address._fields: + if value := getattr(address, fname, None): + domain.append((fname, '=', value)) + try: + address, = Address.search(domain, limit=1) + except ValueError: + address.party = party + address.save() + return address + + @classmethod + def _create_2_address(cls, address_el): + pool = Pool() + Address = pool.get('party.address') + Country = pool.get('country.country') + + address = Address() + + if address_el is None: + return address + + address.post_box = address_el.findtext('./{*}Postbox') + address.floor_number = address_el.findtext('./{*}Floor') + address.room_number = address_el.findtext('./{*}Room') + address.street_name = address_el.findtext('./{*}StreetName') + address.building_name = address_el.findtext('./{*}BuildingName') + address.building_number = address_el.findtext('./{*}BuildingNumber') + address.city = address_el.findtext('./{*}CityName') + address.postal_code = address_el.findtext('./{*}PostalZone') + if (country_code := address_el.findtext( + './{*}Country/{*}IdentificationCode[@listId="ISO3166-1"]') + ) is not None: + try: + country, = Country.search([ + ('code', '=', country_code), + ], limit=1) + except ValueError: + pass + else: + address.country = country + address.street_unstructured = '\n'.join( + (line.text + for line in address_el.iterfind('./{*}AddressLine/{*}Line'))) + return address + + @classmethod + def _parse_2_tax_identifier(cls, party_tax_schemes, party, create=False): + pool = Pool() + Identifier = pool.get('party.identifier') + + tax_identifier_types = party.tax_identifier_types() + + for party_tax_scheme in party_tax_schemes: + company_id = party_tax_scheme.find('./{*}CompanyID') + if company_id is not None: + scheme_id = company_id.get('schemeID') + value = company_id.text + + for identifier in party.identifiers: + if (identifier.type in tax_identifier_types + and identifier.iso_6523 == scheme_id + and identifier.code == value): + return identifier + else: + if create and scheme_id in ISO6523: + identifier = Identifier( + party=party, + type=ISO6523[scheme_id], + code=value) + identifier.save() + return identifier + + @classmethod + def _parse_2_company(cls, customer_party): + pool = Pool() + Company = pool.get('company.company') + try: + CustomerCode = pool.get('party.party.customer_code') + except KeyError: + CustomerCode = None + + if CustomerCode: + for account_id in filter(None, chain( + [customer_party.find('./{*}CustomerAssignedAccountID'), + customer_party.find( + './{*}SupplierAssignedAccountID')], + customer_party.iterfind('./{*}AdditionalAccountID'))): + if account_id.text: + try: + customer_code, = CustomerCode.search([ + ('customer_code', '=', account_id.text), + ]) + except ValueError: + pass + else: + return customer_code.company + for identifier in customer_party.iterfind( + './{*}Party/{*}PartyIdentification/{*}ID'): + if identifier.text: + companies = Company.search([ + ('party.identifiers.code', '=', identifier.text), + ]) + if len(companies) == 1: + company, = companies + return company + + for company_id in customer_party.iterfind( + './{*}Party/{*}PartyTaxScheme/{*}CompanyID'): + companies = Company.search([ + ('party.identifiers.code', '=', company_id.text), + ]) + if len(companies) == 1: + company, = companies + return company + + for name in chain( + customer_party.iterfind( + './{*}Party/{*}PartyTaxScheme/{*}RegistrationName'), + customer_party.iterfind('./{*}Party/{*}PartyName/{*}Name'), + ): + if name is None: + continue + companies = Company.search([ + ('party.name', '=', name.text), + ]) + if len(companies) == 1: + company, = companies + return company + + @classmethod + def _parse_2_payment_term_date(cls, payment_terms): + dates = [] + for payment_term in payment_terms: + if (date := payment_term.findtext('./{*}PaymentDueDate') + ) is not None: + dates.append(dt.date.fromisoformat(date)) + return min(dates, default=None) + + @classmethod + def _parse_2_item(cls, item, supplier=None): + pool = Pool() + Product = pool.get('product.product') + + if (identifier := item.find('./{*}StandardItemIdentification/{*}ID') + ) is not None: + if identifier.get('schemeID') == 'GTIN': + try: + product, = Product.search([ + ('identifiers', 'where', [ + ('type', 'in', ['ean', 'isbn', 'ismn']), + ('code', '=', identifier.text), + ]), + ], limit=1) + except ValueError: + pass + else: + return product + if (code := item.findtext('./{*}BuyersItemIdentification/{*}ID') + ) is not None: + try: + product, = Product.search([ + ('code', '=', code), + ], limit=1) + except ValueError: + pass + else: + return product + + @classmethod + def _parse_2_line_reference( + cls, line_reference, line, company, supplier=None): + return + + @classmethod + def _parse_2_tax_category(cls, tax_category): + domain = [ + ('parent', '=', None), + ] + if (unece_category_code := tax_category.findtext('./{*}ID') + ) is not None: + domain.append(('unece_category_code', '=', unece_category_code)) + if (unece_code := tax_category.findtext('./{*}TaxScheme/{*}ID') + ) is not None: + domain.append(('unece_code', '=', unece_code)) + percent = tax_category.findtext('./{*}Percent') + if percent: + domain.append(('type', '=', 'percentage')) + domain.append(('rate', '=', Decimal(percent) / 100)) + return domain + + @classmethod + def _parse_2_tax(cls, tax, company): + pool = Pool() + Tax = pool.get('account.tax') + InvoiceTax = pool.get('account.invoice.tax') + + invoice_tax = InvoiceTax(manual=False) + + tax_category = tax.find('./{*}TaxCategory') + domain = cls._parse_2_tax_category(tax_category) + domain.extend([ + ['OR', + ('group', '=', None), + ('group.kind', 'in', ['purchase', 'both']), + ], + ('company', '=', company.id), + ]) + try: + invoice_tax.tax, = Tax.search(domain, limit=1) + except ValueError: + raise InvoiceError(gettext( + 'edocument_ubl.msg_tax_not_found', + tax_category=etree.tostring( + tax_category, pretty_print=True).decode())) + + invoice_tax.amount = Decimal(tax.findtext('./{*}TaxAmount')) + if (taxable_amount := tax.findtext('./{*}TaxableAmount')) is not None: + invoice_tax.base = Decimal(taxable_amount) + else: + # Use tax amount to define the sign of unknown base + invoice_tax.base = invoice_tax.amount + + invoice_tax.on_change_tax() + + return invoice_tax + + @classmethod + def _parse_2_attachments(cls, root): + pool = Pool() + Attachment = pool.get('ir.attachment') + + for name in [ + 'DespatchDocumentReference', + 'ReceiptDocumentReference', + 'ContractDocumentReference', + 'AdditionalDocumentReference', + 'StatementDocumentReference', + 'OriginatorDocumentReference' + ]: + for document in root.iterfind(f'./{{*}}{name}'): + attachment = Attachment() + name = ' '.join(filter(None, [ + document.findtext('./{*}DocumentType'), + document.findtext('./{*}ID'), + ])) + if (data := document.find( + './{*}Attachment/{*}EmbeddedDocumentBinaryObject') + ) is not None: + mime_code = ( + data.get('mimeCode') or 'application/octet-stream') + name += mimetypes.guess_extension(mime_code) or '' + attachment.type = 'data' + data = b64decode(data.text) + attachment.data = data + elif data := document.findtext( + './{*}Attachment/{*}EmbeddedDocument'): + name += '.txt' + attachment.type = 'data' + attachment.data = data + elif url := document.findtext( + './{*}Attachment/{*}ExternalReference/{*}URI'): + attachment.type = 'link' + Attachment.link = url + attachment.name = name + yield attachment + + @classmethod + def _check_invoice_2(cls, root, invoice): + pool = Pool() + Lang = pool.get('ir.lang') + lang = Lang.get() + + payable_amount = Decimal( + root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount')) + prepaid_amount = Decimal( + root.findtext('./{*}LegalMonetaryTotal/{*}PrepaidAmount') + or 0) + amount = payable_amount + prepaid_amount + if not getattr(invoice, 'cash_rounding', False): + payable_rounding_amount = Decimal( + root.findtext( + './{*}LegalMonetaryTotal/{*}PayableRoundingAmount') + or 0) + amount -= payable_rounding_amount + if invoice.total_amount != amount: + raise InvoiceError(gettext( + 'edocument_ubl.msg_invoice_total_amount_different', + invoice=invoice.rec_name, + total_amount=lang.format_number(invoice.total_amount), + amount=lang.format_number(amount))) + + tax_total = sum(Decimal(amount.text) for amount in root.iterfind( + './{*}TaxTotal/{*}TaxAmount')) + if invoice.tax_amount != tax_total: + raise InvoiceError(gettext( + 'edocument_ubl.msg_invoice_tax_amount_different', + invoice=invoice.rec_name, + tax_amount=lang.format_number(invoice.tax_amount), + tax_total=lang.format_number(tax_total))) + + @classmethod + def _check_credit_note_2(cls, root, invoice): + pool = Pool() + Lang = pool.get('ir.lang') + lang = Lang.get() + + payable_amount = Decimal( + root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount')) + prepaid_amount = Decimal( + root.findtext('./{*}LegalMonetaryTotal/{*}PrepaidAmount') + or 0) + amount = payable_amount + prepaid_amount + if not getattr(invoice, 'cash_rounding', False): + payable_rounding_amount = Decimal( + root.findtext( + './{*}LegalMonetaryTotal/{*}PayableRoundingAmount') + or 0) + amount -= payable_rounding_amount + if -invoice.total_amount != amount: + raise InvoiceError(gettext( + 'edocument_ubl.msg_invoice_total_amount_different', + invoice=invoice.rec_name, + total_amount=lang.format_number(-invoice.total_amount), + amount=lang.format_number(amount))) + + tax_total = sum(Decimal(amount.text) for amount in root.iterfind( + './{*}TaxTotal/{*}TaxAmount')) + if -invoice.tax_amount != tax_total: + raise InvoiceError(gettext( + 'edocument_ubl.msg_invoice_tax_amount_different', + invoice=invoice.rec_name, + tax_amount=lang.format_number(-invoice.tax_amount), + tax_total=lang.format_number(tax_total))) + + +class Invoice_Purchase(metaclass=PoolMeta): + __name__ = 'edocument.ubl.invoice' + + @classmethod + def _parse_2_item(cls, item, supplier=None): + pool = Pool() + Product = pool.get('product.product') + + product = super()._parse_2_item(item, supplier=supplier) + + if (not product + and supplier + and (code := item.findtext( + './{*}SellersItemIdentification/{*}ID'))): + try: + product, = Product.search([ + ('product_suppliers', 'where', [ + ('party', '=', supplier.id), + ('code', '=', code), + ]), + ], limit=1) + except ValueError: + pass + return product + + @classmethod + def _parse_2_line_reference( + cls, line_reference, line, company, supplier=None): + pool = Pool() + PurchaseLine = pool.get('purchase.line') + UoM = pool.get('product.uom') + + origin = super()._parse_2_item_reference( + line_reference, line, supplier=supplier) + if origin: + return origin + if not line or not line.product or not line.unit: + return + + if numbers := list(filter(None, [ + line_reference.findtext('./{*}OrderReference/{*}ID'), + line_reference.findtext( + './{*}OrderReference/{*}SalesOrderID'), + ])): + purchase_lines = PurchaseLine.search([ + ('purchase.company', '=', company), + ('purchase.rec_name', 'in', numbers), + ('type', '=', 'line'), + ('product', '=', line.product), + ]) + if purchase_lines: + quantities = [] + for purchase_line in purchase_lines: + quantity = UoM.compute_qty( + purchase_line.unit, purchase_line.quantity, line.unit) + quantities.append((quantity, purchase_line)) + key = itemgetter(0) + quantities.sort(key=key) + index = bisect.bisect_left(quantities, line.quantity, key=key) + if index >= len(quantities): + index = -1 + origin = quantities[index][1] + return origin diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/exceptions.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/exceptions.py Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,8 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from trytond.exceptions import UserError + + +class InvoiceError(UserError): + pass diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/message.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/message.xml Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,33 @@ +<?xml version="1.0"?> +<!-- This file is part of Tryton. The COPYRIGHT file at the top level of +this repository contains the full copyright notices and license terms. --> +<tryton> + <data grouped="1"> + <record model="ir.message" id="msg_invoice_type_code_unsupported"> + <field name="text">Unsupported invoice type code "%(type_code)s".</field> + </record> + <record model="ir.message" id="msg_credit_note_type_code_unsupported"> + <field name="text">Unsupported credit note type code "%(type_code)s".</field> + </record> + <record model="ir.message" id="msg_company_not_found"> + <field name="text">Could not find the company for: +%(company)s</field> + </record> + <record model="ir.message" id="msg_currency_not_found"> + <field name="text">Could not find a currency with code "%(code)s".</field> + </record> + <record model="ir.message" id="msg_unit_not_found"> + <field name="text">Could not find a unit with UNECE code "%(code)s".</field> + </record> + <record model="ir.message" id="msg_tax_not_found"> + <field name="text">Could not find tax for: +%(tax_category)s</field> + </record> + <record model="ir.message" id="msg_invoice_total_amount_different"> + <field name="text">The total amount %(total_amount)s of the invoice "%(invoice)s" is different from the amount %(amount)s.</field> + </record> + <record model="ir.message" id="msg_invoice_tax_amount_different"> + <field name="text">The tax amount %(tax_amount)s of the invoice "%(invoice)s" is different from the tax total %(tax_total)s.</field> + </record> + </data> +</tryton> diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/setup.py --- a/modules/edocument_ubl/setup.py Wed Oct 29 15:55:04 2025 +0100 +++ b/modules/edocument_ubl/setup.py Thu Nov 20 13:08:49 2025 +0100 @@ -44,15 +44,17 @@ download_url = 'http://downloads.tryton.org/%s.%s/' % ( major_version, minor_version) -requires = ['Genshi'] +requires = ['Genshi', 'lxml'] for dep in info.get('depends', []): if not re.match(r'(ir|res)(\W|$)', dep): requires.append(get_require_version('trytond_%s' % dep)) requires.append(get_require_version('trytond')) tests_require = [ - 'lxml', + get_require_version('proteus'), + get_require_version('trytond_account_cash_rounding'), get_require_version('trytond_account_invoice'), + get_require_version('trytond_purchase'), ] setup(name=name, @@ -81,7 +83,7 @@ 'trytond.modules.edocument_ubl': (info.get('xml', []) + ['tryton.cfg', 'view/*.xml', 'locale/*.po', '*.fodt', 'icons/*.svg', 'template/*/*.xml', 'tests/*/*/*.xsq', - 'tests/*.rst', 'tests/*.json']), + 'tests/*.rst', 'tests/*.json', 'tests/*.xml']), }, classifiers=[ 'Development Status :: 5 - Production/Stable', diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,409 @@ +<?xml version="1.0" encoding="UTF-8"?> +<CreditNote xmlns="urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ID>TOSL108</cbc:ID> + <cbc:IssueDate>2009-12-15</cbc:IssueDate> + <cbc:Note languageID="en">Ordered in our booth at the convention.</cbc:Note> + <cbc:DocumentCurrencyCode listID="ISO 4217 Alpha" listAgencyID="6">EUR</cbc:DocumentCurrencyCode> + <cbc:AccountingCost>Project cost code 123</cbc:AccountingCost> + <cac:InvoicePeriod> + <cbc:StartDate>2009-11-01</cbc:StartDate> + <cbc:EndDate>2009-11-30</cbc:EndDate> + </cac:InvoicePeriod> + <cac:OrderReference> + <cbc:ID>123</cbc:ID> + </cac:OrderReference> + <cac:ContractDocumentReference> + <cbc:ID>Contract321</cbc:ID> + <cbc:DocumentType>Framework agreement</cbc:DocumentType> + </cac:ContractDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>Doc1</cbc:ID> + <cbc:DocumentType>Timesheet</cbc:DocumentType> + <cac:Attachment> + <cac:ExternalReference> + <cbc:URI>http://www.suppliersite.eu/sheet001.html</cbc:URI> + </cac:ExternalReference> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>Doc2</cbc:ID> + <cbc:DocumentType>Drawing</cbc:DocumentType> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf">UjBsR09EbGhjZ0dTQUxNQUFBUUNBRU1tQ1p0dU1GUXhEUzhi</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AccountingSupplierParty> + <cac:Party> + <cbc:EndpointID schemeID="GLN" schemeAgencyID="9">1234567890123</cbc:EndpointID> + <cac:PartyIdentification> + <cbc:ID schemeID="ZZZ">Supp123</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Salescompany ltd.</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:ID schemeID="GLN" schemeAgencyID="9">1231412341324</cbc:ID> + <cbc:Postbox>5467</cbc:Postbox> + <cbc:StreetName>Main street</cbc:StreetName> + <cbc:AdditionalStreetName>Suite 123</cbc:AdditionalStreetName> + <cbc:BuildingNumber>1</cbc:BuildingNumber> + <cbc:Department>Revenue department</cbc:Department> + <cbc:CityName>Big city</cbc:CityName> + <cbc:PostalZone>54321</cbc:PostalZone> + <cbc:CountrySubentityCode>RegionA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode listID="ISO3166-1" listAgencyID="6">DK</cbc:IdentificationCode> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:CompanyID schemeID="DKVAT" schemeAgencyID="ZZZ">DK12345</cbc:CompanyID> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>The Sellercompany Incorporated</cbc:RegistrationName> + <cbc:CompanyID schemeID="CVR" schemeAgencyID="ZZZ">5402697509</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:CityName>Big city</cbc:CityName> + <cbc:CountrySubentity>RegionA</cbc:CountrySubentity> + <cac:Country> + <cbc:IdentificationCode>DK</cbc:IdentificationCode> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:Telephone>4621230</cbc:Telephone> + <cbc:Telefax>4621231</cbc:Telefax> + <cbc:ElectronicMail>[email protected]</cbc:ElectronicMail> + </cac:Contact> + <cac:Person> + <cbc:FirstName>Antonio</cbc:FirstName> + <cbc:FamilyName>M</cbc:FamilyName> + <cbc:MiddleName>Salemacher</cbc:MiddleName> + <cbc:JobTitle>Sales manager</cbc:JobTitle> + </cac:Person> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cbc:EndpointID schemeID="GLN" schemeAgencyID="9">1234567987654</cbc:EndpointID> + <cac:PartyIdentification> + <cbc:ID schemeID="ZZZ">345KS5324</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Buyercompany ltd</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:ID schemeID="GLN" schemeAgencyID="9">1238764941386</cbc:ID> + <cbc:Postbox>123</cbc:Postbox> + <cbc:StreetName>Anystreet</cbc:StreetName> + <cbc:AdditionalStreetName>Back door</cbc:AdditionalStreetName> + <cbc:BuildingNumber>8</cbc:BuildingNumber> + <cbc:Department>Accounting department</cbc:Department> + <cbc:CityName>Anytown</cbc:CityName> + <cbc:PostalZone>101</cbc:PostalZone> + <cbc:CountrySubentity>RegionB</cbc:CountrySubentity> + <cac:Country> + <cbc:IdentificationCode listID="ISO3166-1" listAgencyID="6">BE</cbc:IdentificationCode> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:CompanyID schemeID="BEVAT" schemeAgencyID="ZZZ">BE54321</cbc:CompanyID> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>The buyercompany inc.</cbc:RegistrationName> + <cbc:CompanyID schemeAgencyID="ZZZ" schemeID="ZZZ">5645342123</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:CityName>Mainplace</cbc:CityName> + <cbc:CountrySubentity>RegionB</cbc:CountrySubentity> + <cac:Country> + <cbc:IdentificationCode>BE</cbc:IdentificationCode> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:Telephone>5121230</cbc:Telephone> + <cbc:Telefax>5121231</cbc:Telefax> + <cbc:ElectronicMail>[email protected]</cbc:ElectronicMail> + </cac:Contact> + <cac:Person> + <cbc:FirstName>John</cbc:FirstName> + <cbc:FamilyName>X</cbc:FamilyName> + <cbc:MiddleName>Doe</cbc:MiddleName> + <cbc:JobTitle>Purchasing manager</cbc:JobTitle> + </cac:Person> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:PayeeParty> + <cac:PartyIdentification> + <cbc:ID schemeID="GLN" schemeAgencyID="9">098740918237</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Ebeneser Scrooge Inc.</cbc:Name> + </cac:PartyName> + <cac:PartyLegalEntity> + <cbc:CompanyID schemeID="UK:CH" schemeAgencyID="ZZZ">6411982340</cbc:CompanyID> + </cac:PartyLegalEntity> + </cac:PayeeParty> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>true</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Packing cost</cbc:AllowanceChargeReason> + <cbc:Amount currencyID="EUR">100</cbc:Amount> + </cac:AllowanceCharge> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>false</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Promotion discount</cbc:AllowanceChargeReason> + <cbc:Amount currencyID="EUR">100</cbc:Amount> + </cac:AllowanceCharge> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">292.20</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">1460.5</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">292.1</cbc:TaxAmount> + <cac:TaxCategory> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">S</cbc:ID> + <cbc:Percent>20</cbc:Percent> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">1</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">0.1</cbc:TaxAmount> + <cac:TaxCategory> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">AA</cbc:ID> + <cbc:Percent>10</cbc:Percent> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">-25</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">0</cbc:TaxAmount> + <cac:TaxCategory> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">E</cbc:ID> + <cbc:Percent>0</cbc:Percent> + <cbc:TaxExemptionReasonCode listID="CWA 15577" listAgencyID="ZZZ">AAM</cbc:TaxExemptionReasonCode> + <cbc:TaxExemptionReason>Exempt New Means of Transport</cbc:TaxExemptionReason> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="EUR">1436.5</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="EUR">1436.5</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="EUR">1729</cbc:TaxInclusiveAmount> + <cbc:AllowanceTotalAmount currencyID="EUR">100</cbc:AllowanceTotalAmount> + <cbc:ChargeTotalAmount currencyID="EUR">100</cbc:ChargeTotalAmount> + <cbc:PrepaidAmount currencyID="EUR">1000</cbc:PrepaidAmount> + <cbc:PayableRoundingAmount currencyID="EUR">0.30</cbc:PayableRoundingAmount> + <cbc:PayableAmount currencyID="EUR">729</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:CreditNoteLine> + <cbc:ID>1</cbc:ID> + <cbc:Note>Scratch on box</cbc:Note> + <cbc:CreditedQuantity unitCode="C62">1</cbc:CreditedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">1273</cbc:LineExtensionAmount> + <cbc:AccountingCost>BookingCode001</cbc:AccountingCost> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">254.6</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Description languageID="EN">Processor: Intel Core 2 Duo SU9400 LV (1.4GHz). RAM: + 3MB. Screen 1440x900</cbc:Description> + <cbc:Name>Labtop computer</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB007</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890124</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">12344321</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434568</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">S</cbc:ID> + <cbc:Percent>20</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + <cac:AdditionalItemProperty> + <cbc:Name>Color</cbc:Name> + <cbc:Value>black</cbc:Value> + </cac:AdditionalItemProperty> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">1273</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>false</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Contract</cbc:AllowanceChargeReason> + <cbc:MultiplierFactorNumeric>0.15</cbc:MultiplierFactorNumeric> + <cbc:Amount currencyID="EUR">225</cbc:Amount> + <cbc:BaseAmount currencyID="EUR">1500</cbc:BaseAmount> + </cac:AllowanceCharge> + </cac:Price> + </cac:CreditNoteLine> + <cac:CreditNoteLine> + <cbc:ID>2</cbc:ID> + <cbc:Note>Cover is slightly damaged.</cbc:Note> + <cbc:CreditedQuantity unitCode="C62">-1</cbc:CreditedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">-3.96</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">-0.396</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>Returned "Advanced computing" book</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB008</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890125</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">32344324</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434567</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">AA</cbc:ID> + <cbc:Percent>10</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">3.96</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + </cac:Price> + </cac:CreditNoteLine> + <cac:CreditNoteLine> + <cbc:ID>3</cbc:ID> + <cbc:CreditedQuantity unitCode="C62">2</cbc:CreditedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">4.96</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">0.496</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>"Computing for dummies" book</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB009</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890126</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">32344324</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434566</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">AA</cbc:ID> + <cbc:Percent>10</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">2.48</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>false</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Contract</cbc:AllowanceChargeReason> + <cbc:MultiplierFactorNumeric>0.1</cbc:MultiplierFactorNumeric> + <cbc:Amount currencyID="EUR">0.275</cbc:Amount> + <cbc:BaseAmount currencyID="EUR">2.75</cbc:BaseAmount> + </cac:AllowanceCharge> + </cac:Price> + </cac:CreditNoteLine> + <cac:CreditNoteLine> + <cbc:ID>4</cbc:ID> + <cbc:CreditedQuantity unitCode="C62">-1</cbc:CreditedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">-25</cbc:LineExtensionAmount> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">0</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>Returned IBM 5150 desktop</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB010</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890127</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">12344322</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434565</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">E</cbc:ID> + <cbc:Percent>0</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">25</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + </cac:Price> + </cac:CreditNoteLine> + <cac:CreditNoteLine> + <cbc:ID>5</cbc:ID> + <cbc:CreditedQuantity unitCode="C62">250</cbc:CreditedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">187.5</cbc:LineExtensionAmount> + <cbc:AccountingCost>BookingCode002</cbc:AccountingCost> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">37.5</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>Network cable</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB011</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890128</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">12344325</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434564</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">S</cbc:ID> + <cbc:Percent>20</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + <cac:AdditionalItemProperty> + <cbc:Name>Type</cbc:Name> + <cbc:Value>Cat5</cbc:Value> + </cac:AdditionalItemProperty> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">0.75</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + </cac:Price> + </cac:CreditNoteLine> +</CreditNote> \ No newline at end of file diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + + <cbc:ID>123</cbc:ID> + <cbc:IssueDate>2011-09-22</cbc:IssueDate> + + <cac:InvoicePeriod> + <cbc:StartDate>2011-08-01</cbc:StartDate> + <cbc:EndDate>2011-08-31</cbc:EndDate> + </cac:InvoicePeriod> + + <cac:AccountingSupplierParty> + <cac:Party> + <cac:PartyName> + <cbc:Name>Custom Cotter Pins</cbc:Name> + </cac:PartyName> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cac:PartyName> + <cbc:Name>North American Veeblefetzer</cbc:Name> + </cac:PartyName> + </cac:Party> + </cac:AccountingCustomerParty> + + <cac:LegalMonetaryTotal> + <cbc:PayableAmount currencyID="CAD">100.00</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + + <cac:InvoiceLine> + <cbc:ID>1</cbc:ID> + <cbc:LineExtensionAmount currencyID="CAD">100.00</cbc:LineExtensionAmount> + <cac:Item> + <cbc:Description>Cotter pin, MIL-SPEC</cbc:Description> + </cac:Item> + </cac:InvoiceLine> + +</Invoice> \ No newline at end of file diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/UBL-Invoice-2.1-Example.xml --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/UBL-Invoice-2.1-Example.xml Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,470 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2" xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"> + <cbc:UBLVersionID>2.1</cbc:UBLVersionID> + <cbc:ID>TOSL108</cbc:ID> + <cbc:IssueDate>2009-12-15</cbc:IssueDate> + <cbc:InvoiceTypeCode listID="UN/ECE 1001 Subset" listAgencyID="6">380</cbc:InvoiceTypeCode> + <cbc:Note languageID="en">Ordered in our booth at the convention.</cbc:Note> + <cbc:TaxPointDate>2009-11-30</cbc:TaxPointDate> + <cbc:DocumentCurrencyCode listID="ISO 4217 Alpha" listAgencyID="6">EUR</cbc:DocumentCurrencyCode> + <cbc:AccountingCost>Project cost code 123</cbc:AccountingCost> + <cac:InvoicePeriod> + <cbc:StartDate>2009-11-01</cbc:StartDate> + <cbc:EndDate>2009-11-30</cbc:EndDate> + </cac:InvoicePeriod> + <cac:OrderReference> + <cbc:ID>123</cbc:ID> + </cac:OrderReference> + <cac:ContractDocumentReference> + <cbc:ID>Contract321</cbc:ID> + <cbc:DocumentType>Framework agreement</cbc:DocumentType> + </cac:ContractDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>Doc1</cbc:ID> + <cbc:DocumentType>Timesheet</cbc:DocumentType> + <cac:Attachment> + <cac:ExternalReference> + <cbc:URI>http://www.suppliersite.eu/sheet001.html</cbc:URI> + </cac:ExternalReference> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AdditionalDocumentReference> + <cbc:ID>Doc2</cbc:ID> + <cbc:DocumentType>Drawing</cbc:DocumentType> + <cac:Attachment> + <cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf">UjBsR09EbGhjZ0dTQUxNQUFBUUNBRU1tQ1p0dU1GUXhEUzhi</cbc:EmbeddedDocumentBinaryObject> + </cac:Attachment> + </cac:AdditionalDocumentReference> + <cac:AccountingSupplierParty> + <cac:Party> + <cbc:EndpointID schemeID="GLN" schemeAgencyID="9">1234567890123</cbc:EndpointID> + <cac:PartyIdentification> + <cbc:ID schemeID="ZZZ">Supp123</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Salescompany ltd.</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:ID schemeID="GLN" schemeAgencyID="9">1231412341324</cbc:ID> + <cbc:Postbox>5467</cbc:Postbox> + <cbc:StreetName>Main street</cbc:StreetName> + <cbc:AdditionalStreetName>Suite 123</cbc:AdditionalStreetName> + <cbc:BuildingNumber>1</cbc:BuildingNumber> + <cbc:Department>Revenue department</cbc:Department> + <cbc:CityName>Big city</cbc:CityName> + <cbc:PostalZone>54321</cbc:PostalZone> + <cbc:CountrySubentityCode>RegionA</cbc:CountrySubentityCode> + <cac:Country> + <cbc:IdentificationCode listID="ISO3166-1" listAgencyID="6">DK</cbc:IdentificationCode> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:CompanyID schemeID="DKVAT" schemeAgencyID="ZZZ">DK12345</cbc:CompanyID> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>The Sellercompany Incorporated</cbc:RegistrationName> + <cbc:CompanyID schemeID="CVR" schemeAgencyID="ZZZ">5402697509</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:CityName>Big city</cbc:CityName> + <cbc:CountrySubentity>RegionA</cbc:CountrySubentity> + <cac:Country> + <cbc:IdentificationCode>DK</cbc:IdentificationCode> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:Telephone>4621230</cbc:Telephone> + <cbc:Telefax>4621231</cbc:Telefax> + <cbc:ElectronicMail>[email protected]</cbc:ElectronicMail> + </cac:Contact> + <cac:Person> + <cbc:FirstName>Antonio</cbc:FirstName> + <cbc:FamilyName>Salemacher</cbc:FamilyName> + <cbc:MiddleName>M</cbc:MiddleName> + <cbc:JobTitle>Sales manager</cbc:JobTitle> + </cac:Person> + </cac:Party> + </cac:AccountingSupplierParty> + <cac:AccountingCustomerParty> + <cac:Party> + <cbc:EndpointID schemeID="GLN" schemeAgencyID="9">1234567987654</cbc:EndpointID> + <cac:PartyIdentification> + <cbc:ID schemeID="ZZZ">345KS5324</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Buyercompany ltd</cbc:Name> + </cac:PartyName> + <cac:PostalAddress> + <cbc:ID schemeID="GLN" schemeAgencyID="9">1238764941386</cbc:ID> + <cbc:Postbox>123</cbc:Postbox> + <cbc:StreetName>Anystreet</cbc:StreetName> + <cbc:AdditionalStreetName>Back door</cbc:AdditionalStreetName> + <cbc:BuildingNumber>8</cbc:BuildingNumber> + <cbc:Department>Accounting department</cbc:Department> + <cbc:CityName>Anytown</cbc:CityName> + <cbc:PostalZone>101</cbc:PostalZone> + <cbc:CountrySubentity>RegionB</cbc:CountrySubentity> + <cac:Country> + <cbc:IdentificationCode listID="ISO3166-1" listAgencyID="6">BE</cbc:IdentificationCode> + </cac:Country> + </cac:PostalAddress> + <cac:PartyTaxScheme> + <cbc:CompanyID schemeID="BEVAT" schemeAgencyID="ZZZ">BE54321</cbc:CompanyID> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:PartyTaxScheme> + <cac:PartyLegalEntity> + <cbc:RegistrationName>The buyercompany inc.</cbc:RegistrationName> + <cbc:CompanyID schemeAgencyID="ZZZ" schemeID="ZZZ">5645342123</cbc:CompanyID> + <cac:RegistrationAddress> + <cbc:CityName>Mainplace</cbc:CityName> + <cbc:CountrySubentity>RegionB</cbc:CountrySubentity> + <cac:Country> + <cbc:IdentificationCode>BE</cbc:IdentificationCode> + </cac:Country> + </cac:RegistrationAddress> + </cac:PartyLegalEntity> + <cac:Contact> + <cbc:Telephone>5121230</cbc:Telephone> + <cbc:Telefax>5121231</cbc:Telefax> + <cbc:ElectronicMail>[email protected]</cbc:ElectronicMail> + </cac:Contact> + <cac:Person> + <cbc:FirstName>John</cbc:FirstName> + <cbc:FamilyName>Doe</cbc:FamilyName> + <cbc:MiddleName>X</cbc:MiddleName> + <cbc:JobTitle>Purchasing manager</cbc:JobTitle> + </cac:Person> + </cac:Party> + </cac:AccountingCustomerParty> + <cac:PayeeParty> + <cac:PartyIdentification> + <cbc:ID schemeID="GLN" schemeAgencyID="9">098740918237</cbc:ID> + </cac:PartyIdentification> + <cac:PartyName> + <cbc:Name>Ebeneser Scrooge Inc.</cbc:Name> + </cac:PartyName> + <cac:PartyLegalEntity> + <cbc:CompanyID schemeID="UK:CH" schemeAgencyID="ZZZ">6411982340</cbc:CompanyID> + </cac:PartyLegalEntity> + </cac:PayeeParty> + <cac:Delivery> + <cbc:ActualDeliveryDate>2009-12-15</cbc:ActualDeliveryDate> + <cac:DeliveryLocation> + <cbc:ID schemeID="GLN" schemeAgencyID="9">6754238987648</cbc:ID> + <cac:Address> + <cbc:StreetName>Deliverystreet</cbc:StreetName> + <cbc:AdditionalStreetName>Side door</cbc:AdditionalStreetName> + <cbc:BuildingNumber>12</cbc:BuildingNumber> + <cbc:CityName>DeliveryCity</cbc:CityName> + <cbc:PostalZone>523427</cbc:PostalZone> + <cbc:CountrySubentity>RegionC</cbc:CountrySubentity> + <cac:Country> + <cbc:IdentificationCode>BE</cbc:IdentificationCode> + </cac:Country> + </cac:Address> + </cac:DeliveryLocation> + </cac:Delivery> + <cac:PaymentMeans> + <cbc:PaymentMeansCode listID="UN/ECE 4461">31</cbc:PaymentMeansCode> + <cbc:PaymentDueDate>2009-12-31</cbc:PaymentDueDate> + <cbc:PaymentChannelCode>IBAN</cbc:PaymentChannelCode> + <cbc:PaymentID>Payref1</cbc:PaymentID> + <cac:PayeeFinancialAccount> + <cbc:ID>DK1212341234123412</cbc:ID> + <cac:FinancialInstitutionBranch> + <cac:FinancialInstitution> + <cbc:ID>DKDKABCD</cbc:ID> + </cac:FinancialInstitution> + </cac:FinancialInstitutionBranch> + </cac:PayeeFinancialAccount> + </cac:PaymentMeans> + <cac:PaymentTerms> + <cbc:Note>Penalty percentage 10% from due date</cbc:Note> + </cac:PaymentTerms> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>true</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Packing cost</cbc:AllowanceChargeReason> + <cbc:Amount currencyID="EUR">100</cbc:Amount> + </cac:AllowanceCharge> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>false</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Promotion discount</cbc:AllowanceChargeReason> + <cbc:Amount currencyID="EUR">100</cbc:Amount> + </cac:AllowanceCharge> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">292.20</cbc:TaxAmount> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">1460.5</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">292.1</cbc:TaxAmount> + <cac:TaxCategory> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">S</cbc:ID> + <cbc:Percent>20</cbc:Percent> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">1</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">0.1</cbc:TaxAmount> + <cac:TaxCategory> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">AA</cbc:ID> + <cbc:Percent>10</cbc:Percent> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + <cac:TaxSubtotal> + <cbc:TaxableAmount currencyID="EUR">-25</cbc:TaxableAmount> + <cbc:TaxAmount currencyID="EUR">0</cbc:TaxAmount> + <cac:TaxCategory> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">E</cbc:ID> + <cbc:Percent>0</cbc:Percent> + <cbc:TaxExemptionReasonCode listID="CWA 15577" listAgencyID="ZZZ">AAM</cbc:TaxExemptionReasonCode> + <cbc:TaxExemptionReason>Exempt New Means of Transport</cbc:TaxExemptionReason> + <cac:TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </cac:TaxScheme> + </cac:TaxCategory> + </cac:TaxSubtotal> + </cac:TaxTotal> + <cac:LegalMonetaryTotal> + <cbc:LineExtensionAmount currencyID="EUR">1436.5</cbc:LineExtensionAmount> + <cbc:TaxExclusiveAmount currencyID="EUR">1436.5</cbc:TaxExclusiveAmount> + <cbc:TaxInclusiveAmount currencyID="EUR">1729</cbc:TaxInclusiveAmount> + <cbc:AllowanceTotalAmount currencyID="EUR">100</cbc:AllowanceTotalAmount> + <cbc:ChargeTotalAmount currencyID="EUR">100</cbc:ChargeTotalAmount> + <cbc:PrepaidAmount currencyID="EUR">1000</cbc:PrepaidAmount> + <cbc:PayableRoundingAmount currencyID="EUR">0.30</cbc:PayableRoundingAmount> + <cbc:PayableAmount currencyID="EUR">729</cbc:PayableAmount> + </cac:LegalMonetaryTotal> + <cac:InvoiceLine> + <cbc:ID>1</cbc:ID> + <cbc:Note>Scratch on box</cbc:Note> + <cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">1273</cbc:LineExtensionAmount> + <cbc:AccountingCost>BookingCode001</cbc:AccountingCost> + <cac:OrderLineReference> + <cbc:LineID>1</cbc:LineID> + </cac:OrderLineReference> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>false</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Damage</cbc:AllowanceChargeReason> + <cbc:Amount currencyID="EUR">12</cbc:Amount> + </cac:AllowanceCharge> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>true</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Testing</cbc:AllowanceChargeReason> + <cbc:Amount currencyID="EUR">10</cbc:Amount> + </cac:AllowanceCharge> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">254.6</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Description languageID="EN">Processor: Intel Core 2 Duo SU9400 LV (1.4GHz). RAM: + 3MB. Screen 1440x900</cbc:Description> + <cbc:Name>Labtop computer</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB007</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890124</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">12344321</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434568</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">S</cbc:ID> + <cbc:Percent>20</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + <cac:AdditionalItemProperty> + <cbc:Name>Color</cbc:Name> + <cbc:Value>black</cbc:Value> + </cac:AdditionalItemProperty> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">1273</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>false</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Contract</cbc:AllowanceChargeReason> + <cbc:MultiplierFactorNumeric>0.15</cbc:MultiplierFactorNumeric> + <cbc:Amount currencyID="EUR">225</cbc:Amount> + <cbc:BaseAmount currencyID="EUR">1500</cbc:BaseAmount> + </cac:AllowanceCharge> + </cac:Price> + </cac:InvoiceLine> + <cac:InvoiceLine> + <cbc:ID>2</cbc:ID> + <cbc:Note>Cover is slightly damaged.</cbc:Note> + <cbc:InvoicedQuantity unitCode="C62">-1</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">-3.96</cbc:LineExtensionAmount> + <cac:OrderLineReference> + <cbc:LineID>5</cbc:LineID> + </cac:OrderLineReference> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">-0.396</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>Returned "Advanced computing" book</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB008</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890125</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">32344324</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434567</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">AA</cbc:ID> + <cbc:Percent>10</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">3.96</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + </cac:Price> + </cac:InvoiceLine> + <cac:InvoiceLine> + <cbc:ID>3</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">2</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">4.96</cbc:LineExtensionAmount> + <cac:OrderLineReference> + <cbc:LineID>3</cbc:LineID> + </cac:OrderLineReference> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">0.496</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>"Computing for dummies" book</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB009</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890126</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">32344324</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434566</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">AA</cbc:ID> + <cbc:Percent>10</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">2.48</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + <cac:AllowanceCharge> + <cbc:ChargeIndicator>false</cbc:ChargeIndicator> + <cbc:AllowanceChargeReason>Contract</cbc:AllowanceChargeReason> + <cbc:MultiplierFactorNumeric>0.1</cbc:MultiplierFactorNumeric> + <cbc:Amount currencyID="EUR">0.275</cbc:Amount> + <cbc:BaseAmount currencyID="EUR">2.75</cbc:BaseAmount> + </cac:AllowanceCharge> + </cac:Price> + </cac:InvoiceLine> + <cac:InvoiceLine> + <cbc:ID>4</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">-1</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">-25</cbc:LineExtensionAmount> + <cac:OrderLineReference> + <cbc:LineID>2</cbc:LineID> + </cac:OrderLineReference> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">0</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>Returned IBM 5150 desktop</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB010</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890127</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">12344322</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434565</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">E</cbc:ID> + <cbc:Percent>0</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">25</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + </cac:Price> + </cac:InvoiceLine> + <cac:InvoiceLine> + <cbc:ID>5</cbc:ID> + <cbc:InvoicedQuantity unitCode="C62">250</cbc:InvoicedQuantity> + <cbc:LineExtensionAmount currencyID="EUR">187.5</cbc:LineExtensionAmount> + <cbc:AccountingCost>BookingCode002</cbc:AccountingCost> + <cac:OrderLineReference> + <cbc:LineID>4</cbc:LineID> + </cac:OrderLineReference> + <cac:TaxTotal> + <cbc:TaxAmount currencyID="EUR">37.5</cbc:TaxAmount> + </cac:TaxTotal> + <cac:Item> + <cbc:Name>Network cable</cbc:Name> + <cac:SellersItemIdentification> + <cbc:ID>JB011</cbc:ID> + </cac:SellersItemIdentification> + <cac:StandardItemIdentification> + <cbc:ID schemeID="GTIN" schemeAgencyID="9">1234567890128</cbc:ID> + </cac:StandardItemIdentification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="113" listID="UNSPSC">12344325</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <cac:CommodityClassification> + <cbc:ItemClassificationCode listAgencyID="2" listID="CPV">65434564</cbc:ItemClassificationCode> + </cac:CommodityClassification> + <ClassifiedTaxCategory xmlns="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"> + <cbc:ID schemeID="UN/ECE 5305" schemeAgencyID="6">S</cbc:ID> + <cbc:Percent>20</cbc:Percent> + <TaxScheme> + <cbc:ID schemeID="UN/ECE 5153" schemeAgencyID="6">VAT</cbc:ID> + </TaxScheme> + </ClassifiedTaxCategory> + <cac:AdditionalItemProperty> + <cbc:Name>Type</cbc:Name> + <cbc:Value>Cat5</cbc:Value> + </cac:AdditionalItemProperty> + </cac:Item> + <cac:Price> + <cbc:PriceAmount currencyID="EUR">0.75</cbc:PriceAmount> + <cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity> + </cac:Price> + </cac:InvoiceLine> +</Invoice> \ No newline at end of file diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.json Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,4 @@ +[ + {"cash_rounding": true} + ,{"cash_rounding": false} +] diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.rst --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/scenario_ubl_2_credit_note_parse.rst Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,90 @@ +======================= +UBL 2 Credit Note Parse +======================= + +Imports:: + + >>> import datetime as dt + >>> from decimal import Decimal + + >>> from proteus import Model + >>> from trytond.modules.account.tests.tools import create_chart, create_tax + >>> from trytond.modules.company.tests.tools import create_company, get_company + >>> from trytond.modules.currency.tests.tools import get_currency + >>> from trytond.tests.tools import activate_modules, assertEqual + >>> from trytond.tools import file_open + + >>> cash_rounding = globals().get('cash_rounding', False) + +Activate modules:: + + >>> modules = ['edocument_ubl', 'account_invoice'] + >>> if cash_rounding: + ... modules.append('account_cash_rounding') + >>> config = activate_modules(modules, create_company, create_chart) + + >>> Attachment = Model.get('ir.attachment') + >>> EInvoice = Model.get('edocument.ubl.invoice') + >>> Invoice = Model.get('account.invoice') + +Setup company:: + + >>> company = get_company() + >>> company.party.name = 'Buyercompany ltd' + >>> company.party.save() + +Create currency:: + + >>> eur = get_currency('EUR') + >>> if cash_rounding: + ... eur.cash_rounding = Decimal('1') + >>> eur.save() + +Create tax:: + + >>> tax20 = create_tax(Decimal('.2')) + >>> tax20.unece_category_code = 'S' + >>> tax20.unece_code = 'VAT' + >>> tax20.save() + + >>> tax10 = create_tax(Decimal('.1')) + >>> tax10.unece_category_code = 'AA' + >>> tax10.unece_code = 'VAT' + >>> tax10.save() + + >>> tax0 = create_tax(Decimal('0')) + >>> tax0.unece_category_code = 'E' + >>> tax0.unece_code = 'VAT' + >>> tax0.save() + +Parse the UBL invoice:: + + >>> with file_open( + ... 'edocument_ubl/tests/UBL-CreditNote-2.1-Example.xml', + ... mode='rb') as fp: + ... invoice_id = EInvoice.parse(fp.read(), config.context) + + >>> invoice = Invoice(invoice_id) + + >>> invoice.reference + 'TOSL108' + >>> assertEqual(invoice.invoice_date, dt.date(2009, 12, 15)) + >>> invoice.party.name + 'Salescompany ltd.' + >>> invoice.invoice_address.rec_name + 'Salescompany ltd., Main street 1 5467, 54321, Big city' + >>> assertEqual(invoice.company, company) + >>> assertEqual( + ... invoice.total_amount, + ... Decimal('-1729.00') if cash_rounding else Decimal('-1728.70')) + >>> invoice.tax_amount + Decimal('-292.20') + >>> len(invoice.lines) + 5 + + >>> attachments = Attachment.find([]) + >>> len(attachments) + 3 + >>> assertEqual({a.resource for a in attachments}, {invoice}) + >>> sorted((a.name, a.type) for a in attachments) + [('Drawing Doc2.pdf', 'data'), ('Framework agreement Contract321', 'data'), ('Timesheet Doc1', 'link')] diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.json Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,4 @@ +[ + {"cash_rounding": true} + ,{"cash_rounding": false} +] diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.rst --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse.rst Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,90 @@ +=================== +UBL 2 Invoice Parse +=================== + +Imports:: + + >>> import datetime as dt + >>> from decimal import Decimal + + >>> from proteus import Model + >>> from trytond.modules.account.tests.tools import create_chart, create_tax + >>> from trytond.modules.company.tests.tools import create_company, get_company + >>> from trytond.modules.currency.tests.tools import get_currency + >>> from trytond.tests.tools import activate_modules, assertEqual + >>> from trytond.tools import file_open + + >>> cash_rounding = globals().get('cash_rounding', False) + +Activate modules:: + + >>> modules = ['edocument_ubl', 'account_invoice'] + >>> if cash_rounding: + ... modules.append('account_cash_rounding') + >>> config = activate_modules(modules, create_company, create_chart) + + >>> Attachment = Model.get('ir.attachment') + >>> EInvoice = Model.get('edocument.ubl.invoice') + >>> Invoice = Model.get('account.invoice') + +Setup company:: + + >>> company = get_company() + >>> company.party.name = 'Buyercompany ltd' + >>> company.party.save() + +Create currency:: + + >>> eur = get_currency('EUR') + >>> if cash_rounding: + ... eur.cash_rounding = Decimal('1') + >>> eur.save() + +Create tax:: + + >>> tax20 = create_tax(Decimal('.2')) + >>> tax20.unece_category_code = 'S' + >>> tax20.unece_code = 'VAT' + >>> tax20.save() + + >>> tax10 = create_tax(Decimal('.1')) + >>> tax10.unece_category_code = 'AA' + >>> tax10.unece_code = 'VAT' + >>> tax10.save() + + >>> tax0 = create_tax(Decimal('0')) + >>> tax0.unece_category_code = 'E' + >>> tax0.unece_code = 'VAT' + >>> tax0.save() + +Parse the UBL invoice:: + + >>> with file_open( + ... 'edocument_ubl/tests/UBL-Invoice-2.1-Example.xml', + ... mode='rb') as fp: + ... invoice_id = EInvoice.parse(fp.read(), config.context) + + >>> invoice = Invoice(invoice_id) + + >>> invoice.reference + 'TOSL108' + >>> assertEqual(invoice.invoice_date, dt.date(2009, 12, 15)) + >>> invoice.party.name + 'Salescompany ltd.' + >>> invoice.invoice_address.rec_name + 'Salescompany ltd., Main street 1 5467, 54321, Big city' + >>> assertEqual(invoice.company, company) + >>> assertEqual( + ... invoice.total_amount, + ... Decimal('1729.00') if cash_rounding else Decimal('1728.70')) + >>> invoice.tax_amount + Decimal('292.20') + >>> len(invoice.lines) + 5 + + >>> attachments = Attachment.find([]) + >>> len(attachments) + 3 + >>> assertEqual({a.resource for a in attachments}, {invoice}) + >>> sorted((a.name, a.type) for a in attachments) + [('Drawing Doc2.pdf', 'data'), ('Framework agreement Contract321', 'data'), ('Timesheet Doc1', 'link')] diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse_trivial.rst --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/scenario_ubl_2_invoice_parse_trivial.rst Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,56 @@ +=========================== +UBL 2 Invoice Parse Trivial +=========================== + +Imports:: + + >>> import datetime as dt + + >>> from proteus import Model + >>> from trytond.modules.account.tests.tools import create_chart + >>> from trytond.modules.company.tests.tools import create_company, get_company + >>> from trytond.modules.currency.tests.tools import get_currency + >>> from trytond.tests.tools import activate_modules, assertEqual + >>> from trytond.tools import file_open + +Activate modules:: + + >>> config = activate_modules( + ... ['edocument_ubl', 'account_invoice'], create_company, create_chart) + + >>> EInvoice = Model.get('edocument.ubl.invoice') + >>> Invoice = Model.get('account.invoice') + +Setup company:: + + >>> company = get_company() + >>> company.party.name = 'North American Veeblefetzer' + >>> company.party.save() + +Create currency:: + + >>> eur = get_currency('CAD') + +Parse the UBL invoice:: + + >>> with file_open( + ... 'edocument_ubl/tests/UBL-Invoice-2.1-Example-Trivial.xml', + ... mode='rb') as fp: + ... invoice_id = EInvoice.parse(fp.read(), config.context) + + >>> invoice = Invoice(invoice_id) + + >>> assertEqual(invoice.invoice_date, dt.date(2011, 9, 22)) + >>> invoice.party.name + 'Custom Cotter Pins' + >>> assertEqual(invoice.company, company) + >>> invoice.total_amount + Decimal('100.00') + >>> line, = invoice.lines + >>> line.description + 'Cotter pin, MIL-SPEC' + >>> line.quantity + 1.0 + >>> line.unit + >>> line.unit_price + Decimal('100.0000') diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/test_module.py --- a/modules/edocument_ubl/tests/test_module.py Wed Oct 29 15:55:04 2025 +0100 +++ b/modules/edocument_ubl/tests/test_module.py Thu Nov 20 13:08:49 2025 +0100 @@ -9,8 +9,7 @@ from lxml import etree from trytond.pool import Pool -from trytond.tests.test_tryton import ( - ModuleTestCase, activate_module, with_transaction) +from trytond.tests.test_tryton import ModuleTestCase, with_transaction def get_invoice(): @@ -135,11 +134,7 @@ class EdocumentUblTestCase(ModuleTestCase): "Test Edocument Ubl module" module = 'edocument_ubl' - - @classmethod - def setUpClass(cls): - super().setUpClass() - activate_module('account_invoice') + extras = ['account_invoice', 'purchase'] @with_transaction() def test_Invoice_2(self): diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tests/test_scenario.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/edocument_ubl/tests/test_scenario.py Thu Nov 20 13:08:49 2025 +0100 @@ -0,0 +1,8 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. + +from trytond.tests.test_tryton import load_doc_tests + + +def load_tests(*args, **kwargs): + return load_doc_tests(__name__, __file__, *args, **kwargs) diff -r 99a8099fd8fd -r 237ad99f3031 modules/edocument_ubl/tryton.cfg --- a/modules/edocument_ubl/tryton.cfg Wed Oct 29 15:55:04 2025 +0100 +++ b/modules/edocument_ubl/tryton.cfg Thu Nov 20 13:08:49 2025 +0100 @@ -5,9 +5,12 @@ ir party extras_depend: + account_cash_rounding account_invoice + purchase xml: account.xml + message.xml [register] model: @@ -18,3 +21,7 @@ model: edocument.Invoice account.InvoiceEdocumentStart + +[register account_invoice purchase] +model: + edocument.Invoice_Purchase
