import io
import logging

from odoo import _, models
from odoo.tools import frozendict, html2plaintext, pdf, str2bool
from odoo.addons.account_edi_ubl_cii.models.account_edi_common import (
    FloatFmt,
    GST_COUNTRY_CODES,
)

_logger = logging.getLogger(__name__)


class AccountEdiUBL(models.AbstractModel):
    _name = "account.edi.ubl"
    _inherit = 'account.edi.common'
    _description = "Base helpers for UBL"

    def _import_attachments(self, invoice, tree):
        """ EXTENDS 'account_edi_common': ATTEMPTS to create a PDF attachment when the XML file doesn't provide one."""
        IrConfigParam = self.env['ir.config_parameter'].sudo()
        disable_pdf_in_xml = str2bool(IrConfigParam.get_param("account_edi_ubl_cii.disable_pdf_in_xml", 'False'))
        additional_docs = super()._import_attachments(invoice, tree)
        if (
            additional_docs or
            invoice.message_main_attachment_id or
            not invoice.is_purchase_document() or
            disable_pdf_in_xml
        ):
            return additional_docs
        try:
            invoices_by_odoo_xmlid = 'account_edi_ubl_cii.action_report_account_invoices_generated_by_odoo'
            report_xmlid = invoices_by_odoo_xmlid if self.env.ref(invoices_by_odoo_xmlid, raise_if_not_found=False) else 'account.account_invoices'

            pdf_raw, pdf_extension = self.env['ir.actions.report'] \
                        ._render_qweb_pdf(report_xmlid, res_ids=[invoice.id])

            if pdf_extension != 'pdf':
                return additional_docs

            # add a watermark to the generated pdf
            with io.BytesIO(pdf_raw) as pdf_stream:
                new_pdf_stream = pdf.add_banner(pdf_stream, _('Generated by Odoo'), logo=False)
                pdf_raw = new_pdf_stream.getvalue()
                new_pdf_stream.close()

            invoice_name = invoice.display_name.replace(_('Draft'), '')
            pdf_filename = _('%(invoice_name)s - Generated by Odoo', invoice_name=invoice_name)

            attachment = self.env['ir.attachment'].create({
                'name': pdf_filename + '.pdf',
                'res_id': invoice.id,
                'res_model': 'account.move',
                'raw': pdf_raw,
                'type': 'binary',
                'mimetype': 'application/pdf',
            })
            invoice._message_set_main_attachment_id(attachment, force=True, filter_xml=False)
            return attachment
        except Exception:  # noqa: BLE001
            _logger.exception("Error while generating substitute PDF attachment for invoice %s", invoice.id)
        return additional_docs

    # -------------------------------------------------------------------------
    # BASE LINES HELPERS
    # -------------------------------------------------------------------------

    def _ubl_is_recycling_contribution_tax(self, tax_data):
        """ Indicate if the 'tax_data' passed as parameter is a recycling contribution tax.

        :param tax_data:    One of the tax data in base_line['tax_details']['taxes_data'].
        :return:            True if tax_data['tax'] is a recycling contribution tax, False otherwise.
        """
        if not tax_data:
            return False

        tax = tax_data['tax']
        return tax.amount_type == 'fixed' and tax.include_base_amount

    def _ubl_is_excise_tax(self, tax_data):
        """ Indicate if the 'tax_data' passed as parameter is an excise tax.

        :param tax_data:    One of the tax data in base_line['tax_details']['taxes_data'].
        :return:            True if tax_data['tax'] is an excise tax, False otherwise.
        """
        if not tax_data:
            return False

        tax = tax_data['tax']
        return tax.amount_type == 'code' and tax.include_base_amount

    def _ubl_is_reverse_charge_tax(self, tax_data):
        """ Indicate if the 'tax_data' passed as parameter is an intracommunity reverse charge purchase tax.

        :param tax_data:    One of the tax data in base_line['tax_details']['taxes_data'].
        :return:            True if tax_data['tax'] is an intracommunity reverse charge purchase tax, False otherwise.
        """
        if not tax_data:
            return False

        tax = tax_data['tax']
        return tax.amount_type == 'percent' and tax.has_negative_factor

    def _ubl_is_early_payment_base_line(self, base_line):
        """ Indicate if the 'base_line' passed as parameter has been generated by an 'mixed' early payment.

        :param      base_line: A base line (see '_prepare_base_line_for_taxes_computation').
        :return:    True if the 'base_line' is a 'mixed' early payment line, False otherwise.
        """
        return base_line['special_type'] == 'early_payment'

    def _ubl_is_cash_rounding_base_line(self, base_line):
        """ Indicate if the 'base_line' passed as parameter has been generated by a cash rounding method.

        :param      base_line: A base line (see '_prepare_base_line_for_taxes_computation').
        :return:    True if the 'base_line' is a cash rounding line, False otherwise.
        """
        return base_line['special_type'] == 'cash_rounding'

    def _ubl_default_tax_category_grouping_key(self, base_line, tax_data, vals, currency):
        """ Give the values about the tax category for a given tax.

        :param base_line:   A base line (see '_prepare_base_line_for_taxes_computation').
        :param tax_data:    One of the tax data in base_line['tax_details']['taxes_data'].
        :param vals:        Some custom data.
        :param currency:    The currency for which the grouping key is expressed.
        :return:            A dictionary that could be used as a grouping key for the taxes helpers.
        """
        customer = vals['customer']
        supplier = vals['supplier']
        if tax_data and (
            tax_data['tax'].amount_type != 'percent'
            or self._ubl_is_recycling_contribution_tax(tax_data)
            or self._ubl_is_excise_tax(tax_data)
        ):
            return
        else:
            supplier_country_code = supplier.commercial_partner_id.country_id.code
            if supplier_country_code in GST_COUNTRY_CODES:
                scheme_id = 'GST'
            else:
                scheme_id = 'VAT'
            if self._ubl_is_reverse_charge_tax(tax_data):
                # Reverse-charge taxes with +100/-100% repartition lines are used in vendor bills.
                # In self-billed invoices, we report them from the seller's perspective, as 0% taxes.
                tax = tax_data['tax']
                return {
                    'tax_category_code': self._get_tax_category_code(customer.commercial_partner_id, supplier, tax),
                    **self._get_tax_exemption_reason(customer.commercial_partner_id, supplier, tax),
                    'percent': 0.0,
                    'scheme_id': scheme_id,
                    'is_withholding': False,
                    'currency': currency,
                }
            elif tax_data:
                tax = tax_data['tax']
                return {
                    'tax_category_code': self._get_tax_category_code(customer.commercial_partner_id, supplier, tax),
                    **self._get_tax_exemption_reason(customer.commercial_partner_id, supplier, tax),
                    'percent': tax.amount,
                    'scheme_id': scheme_id,
                    'is_withholding': tax.amount < 0.0,
                    'currency': currency,
                }
            else:
                return {
                    'tax_category_code': self._get_tax_category_code(customer.commercial_partner_id, supplier, self.env['account.tax']),
                    **self._get_tax_exemption_reason(customer.commercial_partner_id, supplier, self.env['account.tax']),
                    'percent': 0.0,
                    'scheme_id': scheme_id,
                    'is_withholding': False,
                    'currency': currency,
                }

    def _ubl_default_tax_subtotal_tax_category_grouping_key(self, tax_grouping_key, vals):
        """ Give the values about how taxes are grouped together in TaxTotal -> TaxSubtotal -> TaxCategory
        (or WithholdingTaxTotal depending on 'is_withholding').

        :param tax_grouping_key:            The grouping key returned by '_ubl_default_tax_category_grouping_key'.
        :param vals:                        Some custom data.
        :return:                            A dictionary that could be used as a grouping key for the taxes helpers.
        """
        return dict(tax_grouping_key)

    def _ubl_default_tax_subtotal_grouping_key(self, tax_category_grouping_key, vals):
        """ Give the values about how taxes are grouped together in TaxTotal -> TaxSubtotal
        (or WithholdingTaxTotal depending on 'is_withholding').

        :param tax_category_grouping_key:   The grouping key returned by '_ubl_default_tax_subtotal_tax_category_grouping_key'.
        :param vals:                        Some custom data.
        :return:                            A dictionary that could be used as a grouping key for the taxes helpers.
        """
        return dict(tax_category_grouping_key)

    def _ubl_default_tax_total_grouping_key(self, tax_subtotal_grouping_key, vals):
        """ Give the values about how taxes are grouped together in TaxTotal
        (or WithholdingTaxTotal depending on 'is_withholding').

        :param tax_subtotal_grouping_key:   The grouping key returned by '_ubl_default_tax_subtotal_grouping_key'.
        :param vals:                        Some custom data.
        :return:                            A dictionary that could be used as a grouping key for the taxes helpers.
        """
        return {
            'is_withholding': tax_subtotal_grouping_key['is_withholding'],
            'currency': tax_subtotal_grouping_key['currency'],
        }

    def _ubl_default_allowance_charge_early_payment_grouping_key(self, base_line, tax_data, vals, currency):
        """ Give the grouping key when generating the allowance/charge from an early payment discount.

        :param base_line:   A base line (see '_prepare_base_line_for_taxes_computation').
        :param tax_data:    One of the tax data in base_line['tax_details']['taxes_data'].
        :param vals:        Some custom data.
        :param currency:    The currency for which the grouping key is expressed.
        :return:            A dictionary that could be used as a grouping key for the taxes helpers.
        """
        if not self._ubl_is_early_payment_base_line(base_line):
            return

        tax_grouping_key = self._ubl_default_tax_category_grouping_key(base_line, tax_data, vals, currency)
        if not tax_grouping_key or tax_grouping_key['is_withholding']:
            return
        return tax_grouping_key

    def _ubl_default_payable_amount_tax_withholding_grouping_key(self, base_line, tax_data, vals, currency):
        """ Give the grouping key when moving the tax withholding amounts to PrepaidAmount.

        :param base_line:   A base line (see '_prepare_base_line_for_taxes_computation').
        :param tax_data:    One of the tax data in base_line['tax_details']['taxes_data'].
        :param vals:        Some custom data.
        :param currency:    The currency for which the grouping key is expressed.
        :return:            A dictionary that could be used as a grouping key for the taxes helpers.
        """
        if not tax_data:
            return
        tax_grouping_key = self._ubl_default_tax_category_grouping_key(base_line, tax_data, vals, currency)
        if not tax_grouping_key:
            return
        return tax_grouping_key['is_withholding']

    def _ubl_default_base_line_item_classified_tax_category_grouping_key(self, base_line, tax_data, vals, currency):
        """ Give the grouping key when computing taxes for Item -> ClassifiedTaxCategory.

        :param base_line:   A base line (see '_prepare_base_line_for_taxes_computation').
        :param tax_data:    One of the tax data in base_line['tax_details']['taxes_data'].
        :param vals:        Some custom data.
        :param currency:    The currency for which the grouping key is expressed.
        :return:            A dictionary that could be used as a grouping key for the taxes helpers.
        """
        tax_grouping_key = self._ubl_default_tax_category_grouping_key(base_line, tax_data, vals, currency)
        if not tax_grouping_key or tax_grouping_key['is_withholding']:
            return
        return tax_grouping_key

    def _ubl_turn_base_lines_price_unit_as_always_positive(self, vals):
        """ Helper to make sure the base_lines don't contain any negative price_unit.

        :param vals: Some custom data.
        """
        for base_line in vals['base_lines']:
            if base_line['price_unit'] < 0.0:
                base_line['quantity'] *= -1
                base_line['price_unit'] *= -1

    def _ubl_turn_emptying_taxes_as_new_base_lines(self, base_lines, company, vals):
        """ Extract emptying taxes such as "Vidanges" on bottles from the current base lines and turn them into
        additional base lines.

        :param base_lines:  The original 'base_lines' of the document.
        :param company:     The company owning the 'base_lines'.
        :param vals:        Some custom data.
        """
        AccountTax = self.env['account.tax']

        def exclude_function(base_line, tax_data):
            if not tax_data:
                return

            tax = tax_data['tax']
            return tax.amount_type in ('fixed', 'code') and not tax.include_base_amount

        new_base_lines = AccountTax._dispatch_taxes_into_new_base_lines(base_lines, company, exclude_function)

        def aggregate_function(target_base_line, base_line):
            target_base_line.setdefault('_aggregated_quantity', 0.0)
            target_base_line['_aggregated_quantity'] += base_line['quantity']

        def grouping_function(base_line):
            return {'tax': base_line['_removed_tax_data']['tax']}

        extra_base_lines = AccountTax._turn_removed_taxes_into_new_base_lines(
            base_lines=new_base_lines,
            company=company,
            grouping_function=grouping_function,
            aggregate_function=aggregate_function,
        )

        # Restore back the values per quantity.
        for base_line in extra_base_lines:
            base_line['quantity'] = base_line['_aggregated_quantity']
            if base_line['_aggregated_quantity']:
                base_line['price_unit'] /= base_line['_aggregated_quantity']
            base_line['product_id'] = self.env['product.product']

        return new_base_lines + extra_base_lines

    # -------------------------------------------------------------------------
    # EXPORT: Collecting data
    # -------------------------------------------------------------------------

    def _ubl_add_values_company(self, vals, company):
        vals['company'] = company
        vals['supplier'] = company.partner_id

    def _ubl_add_values_currency(self, vals, currency):
        vals['currency'] = currency
        # TODO: For retro-compatibility with previous code
        vals['currency_id'] = currency

    def _ubl_add_values_customer(self, vals, customer):
        vals['customer'] = customer

    def _ubl_add_values_delivery(self, vals, delivery):
        vals['delivery'] = delivery

    def _ubl_add_base_line_ubl_values_allowance_charges_recycling_contribution(self, vals):
        """ Extract recycling contribution taxes such as RECUPEL, AUVIBEL, etc from the current base lines.
        Instead, add them under 'base_line' -> '_ubl_values' -> 'allowance_charges_recycling_contribution'
        to be reported as allowances/charges.

        From a 'base_line' having
            price_unit = 99
            tax_ids = RECUPEL of 1 + 21% tax
            total_excluded_currency = 99
            total_included_currency = 121
            taxes_data = [1, 21]
            recycling_contribution_data = []
        ... turn it to:
            price_unit = 99
            tax_ids = 21% tax
            total_excluded_currency = 99
            total_included_currency = 121
            taxes_data = [21]
            recycling_contribution_data = [1]

        TO BE REMOVED IN MASTER

        :param vals:        Some custom data.
        """
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        for base_line in base_lines:
            ubl_values = base_line['_ubl_values']
            tax_details = base_line['tax_details']
            taxes_data = tax_details['taxes_data']

            allowance_charges_recycling_contribution = ubl_values['allowance_charges_recycling_contribution'] = []
            allowance_charges_recycling_contribution_currency = ubl_values['allowance_charges_recycling_contribution_currency'] = []
            for tax_data in taxes_data:
                if self._ubl_is_recycling_contribution_tax(tax_data):
                    allowance_charges_recycling_contribution.append({
                        'tax': tax_data['tax'],
                        'is_charge': tax_data['tax_amount'] > 0.0,
                        'amount': tax_data['tax_amount'],
                        'currency': company_currency,
                    })
                    allowance_charges_recycling_contribution_currency.append({
                        'tax': tax_data['tax'],
                        'is_charge': tax_data['tax_amount_currency'] > 0.0,
                        'amount': tax_data['tax_amount_currency'],
                        'currency': currency,
                    })

    def _ubl_add_base_line_ubl_values_allowance_charges_excise(self, vals):
        """ Extract excise taxes from the current base lines.
        Instead, add them under 'base_line' -> '_ubl_values' -> 'allowance_charges_excise'
        to be reported as allowances/charges.

        From a 'base_line' having
            price_unit = 99
            tax_ids = EXCISE of 1 + 21% tax
            total_excluded_currency = 99
            total_included_currency = 121
            taxes_data = [1, 21]
            recycling_contribution_data = []
        ... turn it to:
            price_unit = 99
            tax_ids = 21% tax
            total_excluded_currency = 99
            total_included_currency = 121
            taxes_data = [21]
            recycling_contribution_data = [1]

        TO BE REMOVED IN MASTER

        :param vals:        Some custom data.
        """
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        for base_line in base_lines:
            ubl_values = base_line['_ubl_values']
            tax_details = base_line['tax_details']
            taxes_data = tax_details['taxes_data']

            allowance_charges_excise = ubl_values['allowance_charges_excise'] = []
            allowance_charges_excise_currency = ubl_values['allowance_charges_excise_currency'] = []
            for tax_data in taxes_data:
                if self._ubl_is_excise_tax(tax_data):
                    allowance_charges_excise.append({
                        'tax': tax_data['tax'],
                        'is_charge': tax_data['tax_amount'] > 0.0,
                        'amount': tax_data['tax_amount'],
                        'currency': company_currency,
                    })
                    allowance_charges_excise_currency.append({
                        'tax': tax_data['tax'],
                        'is_charge': tax_data['tax_amount_currency'] > 0.0,
                        'amount': tax_data['tax_amount_currency'],
                        'currency': currency,
                    })

    def _ubl_add_base_line_ubl_values_allowance_charges_discount(self, vals):
        """ Extract the amount implies by a discount. This amount will be turned into an allowances/charge
        into 'base_line' -> '_ubl_values' -> 'allowance_charge_discount'.

        From a 'base_line' having
            price_unit = 100
            quantity = 5
            discount = 20
            total_excluded_currency = (5 * 100) * 0.8 = 400
        ... compute an 'allowance_charge_discount' or (5 * 100) - 400 = 100:

        TO BE REMOVED IN MASTER

        :param vals:        Some custom data.
        """
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        for base_line in base_lines:
            ubl_values = base_line['_ubl_values']
            tax_details = base_line['tax_details']
            raw_discount_amount_currency = tax_details['raw_discount_amount_currency']
            raw_discount_amount = tax_details['raw_discount_amount']

            if (
                base_line['currency_id'].is_zero(raw_discount_amount_currency)
                and company.currency_id.is_zero(raw_discount_amount)
            ):
                ubl_values['allowance_charge_discount'] = None
                ubl_values['allowance_charge_discount_currency'] = None
            else:
                ubl_values['allowance_charge_discount'] = {
                    'currency': company_currency,
                    'percent': base_line['discount'],
                    'is_charge': raw_discount_amount < 0.0,
                    'amount': raw_discount_amount,
                    'base_amount': tax_details['raw_gross_total_excluded'],
                }
                ubl_values['allowance_charge_discount_currency'] = {
                    'currency': currency,
                    'percent': base_line['discount'],
                    'amount': raw_discount_amount_currency,
                    'is_charge': raw_discount_amount_currency < 0.0,
                    'base_amount': tax_details['raw_gross_total_excluded_currency'],
                }

    def _ubl_add_base_line_ubl_values_line_extension_amount(self, vals, use_company_currency=False):
        """ Add 'base_line' -> '_ubl_values' -> 'line_extension_amount[_currency]'.

        'line_extension_amount' is the subtotal of the line but without tax plus charges.

        TO BE REMOVED IN MASTER

        :param vals:                    Some custom data.
        :param use_company_currency:    Express the amount in company currency.
        """
        base_lines = vals['base_lines']
        suffix = '' if use_company_currency else '_currency'

        for base_line in base_lines:
            tax_details = base_line['tax_details']
            ubl_values = base_line['_ubl_values']
            amount = (
                tax_details[f'total_excluded{suffix}']
                + tax_details[f'delta_total_excluded{suffix}']
                + sum(
                    (1 if allowance_charge_values['is_charge'] else -1) * allowance_charge_values['amount']
                    for allowance_charge_values in ubl_values[f'allowance_charges_recycling_contribution{suffix}']
                )
                + sum(
                    (1 if allowance_charge_values['is_charge'] else -1) * allowance_charge_values['amount']
                    for allowance_charge_values in ubl_values[f'allowance_charges_excise{suffix}']
                )
            )
            ubl_values[f'line_extension_amount{suffix}'] = amount

    def _ubl_add_base_line_ubl_values_item(self, vals):
        """ Add 'base_line' -> '_ubl_values' -> 'item'.

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:        Some custom data.
        """
        _logger.warning("DEPRECATED")
        AccountTax = self.env['account.tax']
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        for sub_currency, suffix in ((currency, '_currency'), (company_currency, '')):
            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=base_lines,
                grouping_function=lambda base_line, tax_data: self._ubl_default_base_line_item_classified_tax_category_grouping_key(
                    base_line=base_line,
                    tax_data=tax_data,
                    vals=vals,
                    currency=sub_currency,
                ),
            )
            for base_line, aggregated_values in base_lines_aggregated_values:
                item = base_line['_ubl_values'][f'item{suffix}'] = {
                    'currency': sub_currency,
                    'base_line': base_line,
                    'classified_tax_categories': {},
                }
                for grouping_key, values in aggregated_values.items():
                    if grouping_key:
                        item['classified_tax_categories'][grouping_key] = {
                            **grouping_key,
                            'base_amount': values[f'base_amount{suffix}'],
                            'tax_amount': values[f'tax_amount{suffix}'],
                        }

    def _ubl_add_base_line_ubl_values_price(self, vals):
        """ Add 'price_amount' under 'base_line' -> '_ubl_values' -> 'price_amount[_currency]'.

        'price_amount' is price unit of a single unit of the product.

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:        Some custom data.
        """
        _logger.warning("DEPRECATED")
        base_lines = vals['base_lines']

        for base_line in base_lines:
            tax_details = base_line['tax_details']
            ubl_values = base_line['_ubl_values']
            for currency_suffix in ('_currency', ''):
                ubl_values[f'price_amount{currency_suffix}'] = tax_details[f'raw_gross_price_unit{currency_suffix}']

    def _ubl_add_values_tax_currency_code_company_currency_if_foreign_currency(self, vals):
        """ Add 'vals' -> '_ubl_values' -> 'tax_currency_code'

        The value is set only at the company currency when there is a foreign currency.

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:    Some custom data.
        """
        _logger.warning("DEPRECATED")
        company = vals['company']
        currency = vals['currency_id']
        vals['tax_currency_code'] = None if currency == company.currency_id else company.currency_id.name

    def _ubl_add_values_tax_currency_code_company_currency(self, vals):
        """ Add 'vals' -> '_ubl_values' -> 'tax_currency_code'

        The company currency will always be set on it.

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:    Some custom data.
        """
        _logger.warning("DEPRECATED")
        vals['tax_currency_code'] = vals['company'].currency_id.name

    def _ubl_add_values_tax_currency_code_empty(self, vals):
        """ Add 'vals' -> '_ubl_values' -> 'tax_currency_code'

        The value is empty.

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:    Some custom data.
        """
        _logger.warning("DEPRECATED")
        vals['tax_currency_code'] = None

    def _ubl_add_values_tax_currency_code(self, vals):
        """ Add 'vals' -> '_ubl_values' -> 'tax_currency_code'

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:    Some custom data.
        """
        _logger.warning("DEPRECATED")
        self._ubl_add_values_tax_currency_code_company_currency_if_foreign_currency(vals)

    def _ubl_add_values_tax_totals(self, vals):
        """ Add
            'vals' -> '_ubl_values' -> 'tax_totals'
            'vals' -> '_ubl_values' -> 'withholding_tax_totals'

        'tax_totals' will contain the total and subtotals for not-withholding taxes.
        'withholding_tax_totals' will contain the total and subtotals for withholding taxes.

        TO BE REMOVED IN MASTER

        :param vals:                        Some custom data.
        """
        AccountTax = self.env['account.tax']
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        ubl_values = vals['_ubl_values']
        ubl_values['tax_totals'] = {}
        ubl_values['tax_totals_currency'] = {}
        ubl_values['withholding_tax_totals'] = {}
        ubl_values['withholding_tax_totals_currency'] = {}

        def tax_category_grouping_function(base_line, tax_data, sub_currency):
            tax_grouping_key = self._ubl_default_tax_category_grouping_key(base_line, tax_data, vals, sub_currency)
            if not tax_grouping_key:
                return
            return self._ubl_default_tax_subtotal_tax_category_grouping_key(tax_grouping_key, vals)

        def tax_subtotal_grouping_function(base_line, tax_data, sub_currency):
            tax_category_grouping_key = tax_category_grouping_function(base_line, tax_data, sub_currency)
            if not tax_category_grouping_key:
                return
            return self._ubl_default_tax_subtotal_grouping_key(tax_category_grouping_key, vals)

        def tax_totals_grouping_function(base_line, tax_data, sub_currency):
            tax_subtotal_grouping_key = tax_subtotal_grouping_function(base_line, tax_data, sub_currency)
            if not tax_subtotal_grouping_key:
                return
            return self._ubl_default_tax_total_grouping_key(tax_subtotal_grouping_key, vals)

        for sub_currency, suffix in ((currency, '_currency'), (company_currency, '')):

            # tax_totals / withholding_tax_totals

            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=base_lines,
                grouping_function=lambda base_line, tax_data: tax_totals_grouping_function(base_line, tax_data, sub_currency),
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue

                if grouping_key['is_withholding']:
                    target_key = f'withholding_tax_totals{suffix}'
                    sign = -1
                else:
                    target_key = f'tax_totals{suffix}'
                    sign = 1

                ubl_values[target_key][frozendict(grouping_key)] = {
                    **grouping_key,
                    'amount': sign * values[f'tax_amount{suffix}'],
                    'subtotals': {},
                }

            # tax_subtotals

            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=base_lines,
                grouping_function=lambda base_line, tax_data: tax_subtotal_grouping_function(base_line, tax_data, sub_currency),
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue

                if grouping_key['is_withholding']:
                    target_key = f'withholding_tax_totals{suffix}'
                    sign = -1
                else:
                    target_key = f'tax_totals{suffix}'
                    sign = 1

                tax_total_grouping_key = self._ubl_default_tax_total_grouping_key(grouping_key, vals)
                if not tax_total_grouping_key:
                    continue

                tax_total_values = ubl_values[target_key][frozendict(tax_total_grouping_key)]
                tax_total_values['subtotals'][frozendict(grouping_key)] = {
                    **grouping_key,
                    'base_amount': values[f'base_amount{suffix}'],
                    'tax_amount': sign * values[f'tax_amount{suffix}'],
                    'tax_categories': {},
                }

            # tax_categories

            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=base_lines,
                grouping_function=lambda base_line, tax_data: tax_category_grouping_function(base_line, tax_data, sub_currency),
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue

                if grouping_key['is_withholding']:
                    target_key = f'withholding_tax_totals{suffix}'
                    sign = -1
                else:
                    target_key = f'tax_totals{suffix}'
                    sign = 1

                tax_subtotal_grouping_key = self._ubl_default_tax_subtotal_grouping_key(grouping_key, vals)
                if not tax_subtotal_grouping_key:
                    continue

                tax_total_grouping_key = self._ubl_default_tax_total_grouping_key(tax_subtotal_grouping_key, vals)
                if not tax_total_grouping_key:
                    continue

                tax_total_values = ubl_values[target_key][frozendict(tax_total_grouping_key)]
                tax_total_values['subtotals'][frozendict(tax_subtotal_grouping_key)]['tax_categories'][frozendict(grouping_key)] = {
                    **grouping_key,
                    'base_amount': values[f'base_amount{suffix}'],
                    'tax_amount': sign * values[f'tax_amount{suffix}'],
                }

            for key in (f'withholding_tax_totals{suffix}', f'tax_totals{suffix}'):
                if not ubl_values[key]:
                    ubl_values[key][None] = {
                        'currency': sub_currency,
                        'amount': 0.0,
                        'subtotals': {},
                    }

    def _ubl_add_values_payable_amount_tax_withholding(self, vals):
        # DEPRECATED: TO BE REMOVED IN MASTER
        _logger.warning("DEPRECATED")
        AccountTax = self.env['account.tax']
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        ubl_values = vals['_ubl_values']
        ubl_values['payable_amount_tax_withholding'] = 0.0
        ubl_values['payable_amount_tax_withholding_currency'] = 0.0
        for sub_currency, suffix in ((currency, '_currency'), (company_currency, '')):
            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=base_lines,
                grouping_function=lambda base_line, tax_data: self._ubl_default_payable_amount_tax_withholding_grouping_key(
                    base_line=base_line,
                    tax_data=tax_data,
                    vals=vals,
                    currency=sub_currency,
                ),
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)

            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue

                ubl_values[f'payable_amount_tax_withholding{suffix}'] -= values[f'tax_amount{suffix}']

    def _ubl_add_values_payable_rounding_amount(self, vals):
        """ Add
            'vals' -> '_ubl_values' -> 'payable_rounding_amount[_currency]'.
            'vals' -> '_ubl_values' -> 'payable_rounding_base_lines'.

        'payable_rounding_amount' is rounding amount to be added to the total in case of a cash rounding.
        'payable_rounding_base_lines' are the rounding base lines.

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:        Some custom data.
        """
        _logger.warning("DEPRECATED")
        AccountTax = self.env['account.tax']
        base_lines = vals['base_lines']

        def grouping_function(base_line, tax_data):
            return base_line['special_type'] == 'cash_rounding'

        base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(base_lines, grouping_function)
        values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
        ubl_values = vals['_ubl_values']
        ubl_values['payable_rounding_amount'] = 0.0
        ubl_values['payable_rounding_amount_currency'] = 0.0
        ubl_values['payable_rounding_base_lines'] = []
        for grouping_key, values in values_per_grouping_key.items():
            if not grouping_key:
                continue

            ubl_values['payable_rounding_amount_currency'] += values['total_excluded_currency']
            ubl_values['payable_rounding_amount'] += values['total_excluded']
            for base_line, _taxes_data in values['base_line_x_taxes_data']:
                ubl_values['payable_rounding_base_lines'].append(base_line)

    def _ubl_add_values_allowance_charge_early_payment(self, vals):
        """ Add 'vals' -> '_ubl_values' -> 'allowance_charges_early_payment' representing the allowance/charges
        corresponding to a 'mixed' early payment.

        Suppose an invoice with a base amount of 100 and a 21% tax.
        The total of your invoice is 121.
        With a 'mixed' early payment of 5%, 2 additional lines are added to the invoice:
        One line having a negative amount of -5 with 21% tax.
        Another line having a positive amount of 5 with no tax.
        It means the 21% tax line will now be based on 95 instead of 100 leading to
        - an untaxed amount of 95.0
        - a tax amount of 95 * 0.21 = 19.95
        - a total amount of 114.95

        In the UBL, an allowance is added with an amount of 5 and 21% tax applied on it plus a charge with an amount of 5.
        Basically, it's like you had a discount on the full amount but we put back the discount you get on the base as a charge
        to only get the discount regarding the tax amount.

        DEPRECATED: TO BE REMOVED IN MASTER

        :param vals:        Some custom data.
        """
        _logger.warning("DEPRECATED")
        AccountTax = self.env['account.tax']
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        ubl_values = vals['_ubl_values']

        for sub_currency, suffix in ((currency, '_currency'), (company_currency, '')):
            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=base_lines,
                grouping_function=lambda base_line, tax_data: self._ubl_default_allowance_charge_early_payment_grouping_key(
                    base_line=base_line,
                    tax_data=tax_data,
                    vals=vals,
                    currency=sub_currency,
                ),
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)

            allowance_charges_early_payment = ubl_values[f'allowance_charges_early_payment{suffix}'] = []
            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue

                allowance_charges_early_payment.append({
                    'currency': sub_currency,
                    'amount': values[f'total_excluded{suffix}'],
                    'is_charge': values[f'total_excluded{suffix}'] > 0.0,
                    'tax_categories': {
                        grouping_key: {
                            **grouping_key,
                            'base_amount': values[f'base_amount{suffix}'],
                            'tax_amount': values[f'tax_amount{suffix}'],
                        },
                    },
                })

    # -------------------------------------------------------------------------
    # EXPORT: Building nodes
    # -------------------------------------------------------------------------

    def _ubl_add_line_id_node(self, vals):
        vals['line_node']['cbc:ID'] = {'_text': vals['line_vals']['index']}

    def _ubl_add_line_note_nodes(self, vals):
        vals['line_node']['cbc:Note'] = []

    def _ubl_add_line_quantity_node(self, vals):
        base_line = vals['line_vals']['base_line']
        vals['line_node']['cbc:Quantity'] = {
            '_text': base_line['quantity'],
            'unitCode': self._get_uom_unece_code(base_line['product_uom_id']),
        }

    def _ubl_add_line_invoiced_quantity_node(self, vals):
        base_line = vals['line_vals']['base_line']
        vals['line_node']['cbc:InvoicedQuantity'] = {
            '_text': base_line['quantity'],
            'unitCode': self._get_uom_unece_code(base_line['product_uom_id']),
        }

    def _ubl_add_line_credited_quantity_node(self, vals):
        base_line = vals['line_vals']['base_line']
        vals['line_node']['cbc:CreditedQuantity'] = {
            '_text': base_line['quantity'],
            'unitCode': self._get_uom_unece_code(base_line['product_uom_id']),
        }

    def _ubl_add_line_debited_quantity_node(self, vals):
        base_line = vals['line_vals']['base_line']
        vals['line_node']['cbc:DebitedQuantity'] = {
            '_text': base_line['quantity'],
            'unitCode': self._get_uom_unece_code(base_line['product_uom_id']),
        }

    def _ubl_add_line_item_name_description_nodes(self, vals):
        item_node = vals['item_node']
        base_line = vals['line_vals']['base_line']
        product = base_line['product_id']

        if base_line.get('_removed_tax_data'):
            # Emptying tax extra line.
            name = description = base_line['_removed_tax_data']['tax'].name
        else:
            name = product.name or ''
            if line_name := base_line.get('name'):
                # Regular business line.
                description = line_name
                if not name:
                    name = line_name
            else:
                # Undefined line.
                description = product.description_sale or ''

        if description:
            item_node['cbc:Description'] = {'_text': description}
        else:
            item_node['cbc:Description'] = None

        if name:
            item_node['cbc:Name'] = {'_text': name}
        else:
            item_node['cbc:Name'] = None

    def _ubl_add_line_item_identification_nodes(self, vals):
        item_node = vals['item_node']
        base_line = vals['line_vals']['base_line']
        product = base_line['product_id']

        if product.default_code:
            item_node['cac:SellersItemIdentification'] = {
                'cbc:ID': {'_text': product.default_code},
            }
        else:
            item_node['cac:SellersItemIdentification'] = None
        if product.barcode:
            item_node['cac:StandardItemIdentification'] = {
                'cbc:ID': {
                    '_text': product.barcode,
                    'schemeID': '0160',  # GTIN
                },
            }
        else:
            item_node['cac:StandardItemIdentification'] = None

    def _ubl_add_line_item_additional_item_property_nodes(self, vals):
        item_node = vals['item_node']
        base_line = vals['line_vals']['base_line']
        product = base_line['product_id']

        item_node['cac:AdditionalItemProperty'] = [
            {
                'cbc:Name': {'_text': value.attribute_id.name},
                'cbc:Value': {'_text': value.name},
            }
            for value in product.product_template_attribute_value_ids
        ]

    def _ubl_get_line_item_commodity_classification_node_from_intrastat_code(self, vals, intrastat_code):
        return {
            'cbc:ItemClassificationCode': {
                '_text': intrastat_code.code,
                'listID': 'HS',
                'listVersionID': None,
            }
        }

    def _ubl_get_line_item_commodity_classification_node_from_unspsc_code(self, vals, unspsc_code):
        return {
            'cbc:ItemClassificationCode': {
                '_text': unspsc_code.code,
                'listID': 'TST',
                'listVersionID': None,
            }
        }

    def _ubl_get_line_item_commodity_classification_node_from_cpv_code(self, vals, cpv_code):
        return {
            'cbc:ItemClassificationCode': {
                '_text': cpv_code.code,
                'listID': 'STI',
                'listVersionID': None,
            }
        }

    def _ubl_get_line_item_commodity_classification_node_from_cg_code(self, vals, cg_code):
        return {
            'cbc:ItemClassificationCode': {
                '_text': cg_code.name,
                'listID': 'CG',
                'listVersionID': None,
            }
        }

    def _ubl_add_line_item_commodity_classification_nodes(self, vals):
        item_node = vals['item_node']
        base_line = vals['line_vals']['base_line']
        product = base_line['product_id']
        nodes = item_node['cac:CommodityClassification'] = []

        if self.module_installed('account_intrastat'):
            intrastat_code = product.intrastat_code_id
            if intrastat_code.code:
                nodes.append(self._ubl_get_line_item_commodity_classification_node_from_intrastat_code(vals, intrastat_code))

        if self.module_installed('product_unspsc'):
            unspsc_code = product.unspsc_code_id
            if unspsc_code.code:
                nodes.append(self._ubl_get_line_item_commodity_classification_node_from_unspsc_code(vals, unspsc_code))

        if self.module_installed('l10n_ro_cpv_code'):
            cpv_code = product.cpv_code_id
            if cpv_code.code:
                nodes.append(self._ubl_get_line_item_commodity_classification_node_from_cpv_code(vals, cpv_code))

        if self.module_installed('l10n_hr_edi'):
            cg_code = base_line.get('cg_item_classification_code') or product.l10n_hr_kpd_category_id
            if cg_code.name:
                nodes.append(self._ubl_get_line_item_commodity_classification_node_from_cg_code(vals, cg_code))

        return nodes

    def _ubl_get_line_item_node_classified_tax_category_node(self, vals, tax_category):
        """ Generate the node 'cac:ClassifiedTaxCategory' in 'cac:Item'.

        :param vals:            Some custom data.
        :param tax_category:    An entry of vals['_ubl_values']['item_classified_tax_categories']
                                containing all the necessary data to build the node.
        :return:                A new node in 'cac:Item' -> 'cac:ClassifiedTaxCategory'.
        """
        return {
            '_currency': tax_category['currency'],
            'cbc:ID': {'_text': tax_category['tax_category_code']},
            'cbc:Name': {'_text': None},
            'cbc:Percent': {'_text': tax_category['percent']},
            'cbc:TaxExemptionReasonCode': {'_text': None},
            'cbc:TaxExemptionReason': {'_text': None},
            'cac:TaxScheme': {
                'cbc:ID': {'_text': tax_category['scheme_id']},
            }
        }

    def _ubl_add_line_item_classified_tax_category_nodes(self, vals, in_foreign_currency=True):
        AccountTax = self.env['account.tax']
        item_node = vals['item_node']
        base_line = vals['line_vals']['base_line']
        currency = base_line['currency_id'] if in_foreign_currency else vals['company_currency']
        suffix = '_currency' if in_foreign_currency else ''

        classified_tax_category_nodes = item_node['cac:ClassifiedTaxCategory'] = []
        aggregated_values = AccountTax._aggregate_base_line_tax_details(
            base_line=base_line,
            grouping_function=lambda base_line, tax_data: self._ubl_default_base_line_item_classified_tax_category_grouping_key(
                base_line=base_line,
                tax_data=tax_data,
                vals=vals,
                currency=currency,
            ),
        )
        for grouping_key, values in aggregated_values.items():
            if not grouping_key:
                continue

            classified_tax_category_nodes.append(self._ubl_get_line_item_node_classified_tax_category_node(vals, {
                **grouping_key,
                'base_amount': values[f'base_amount{suffix}'],
                'tax_amount': values[f'tax_amount{suffix}'],
            }))

    def _ubl_add_line_item_node(self, vals):
        node = vals['line_node']['cac:Item'] = {}
        sub_vals = {**vals, 'item_node': node}
        self._ubl_add_line_item_name_description_nodes(sub_vals)
        self._ubl_add_line_item_identification_nodes(sub_vals)
        self._ubl_add_line_item_additional_item_property_nodes(sub_vals)
        self._ubl_add_line_item_commodity_classification_nodes(sub_vals)
        self._ubl_add_line_item_classified_tax_category_nodes(sub_vals)

    def _ubl_add_line_price_node(self, vals, in_foreign_currency=True):
        line_node = vals['line_node']
        base_line = vals['line_vals']['base_line']
        suffix = '_currency' if in_foreign_currency else ''
        currency = base_line['currency_id'] if in_foreign_currency else vals['company_currency']

        line_node['cac:Price'] = {
            'cbc:PriceAmount': {
                '_text': FloatFmt(base_line['tax_details'][f'raw_gross_price_unit{suffix}'], min_dp=1, max_dp=6),
                'currencyID': currency.name,
            },
        }

    def _ubl_get_line_item_node(self, vals, item_values):
        # TO BE REMOVED IN MASTER
        _logger.warning("DEPRECATED")
        item_node = {}
        base_line = item_values['base_line']
        product = base_line['product_id']

        if product.default_code:
            item_node['cac:SellersItemIdentification'] = {
                'cbc:ID': {'_text': product.default_code},
            }
        else:
            item_node['cac:SellersItemIdentification'] = None
        if product.barcode:
            item_node['cac:StandardItemIdentification'] = {
                'cbc:ID': {
                    '_text': product.barcode,
                    'schemeID': '0160',  # GTIN
                },
            }
        else:
            item_node['cac:StandardItemIdentification'] = None
        item_node['cac:AdditionalItemProperty'] = [
            {
                'cbc:Name': {'_text': value.attribute_id.name},
                'cbc:Value': {'_text': value.name},
            }
            for value in product.product_template_attribute_value_ids
        ]

        if base_line.get('_removed_tax_data'):
            # Emptying tax extra line.
            name = description = base_line['_removed_tax_data']['tax'].name
        else:
            name = product.name or ''
            if line_name := base_line.get('name'):
                # Regular business line.
                description = line_name
                if not name:
                    name = line_name
            else:
                # Undefined line.
                description = product.description_sale or ''

        if description:
            item_node['cbc:Description'] = {'_text': description}
        else:
            item_node['cbc:Description'] = None

        if name:
            item_node['cbc:Name'] = {'_text': name}
        else:
            item_node['cbc:Name'] = None

        item_node['cac:ClassifiedTaxCategory'] = [
            self._ubl_get_line_item_node_classified_tax_category_node(vals, tax_category)
            for tax_category in item_values['classified_tax_categories'].values()
        ]
        return item_node

    def _ubl_get_line_allowance_charge_recycling_contribution_node(self, vals, recycling_contribution_values):
        currency = recycling_contribution_values['currency']
        amount = recycling_contribution_values['amount']
        tax = recycling_contribution_values['tax']
        if 'bebat' in tax.name.lower():
            charge_reason_code = 'CAV'
        else:
            charge_reason_code = 'AEO'
        is_charge = recycling_contribution_values['is_charge']
        return {
            '_currency': currency,
            'cbc:ChargeIndicator': {'_text': 'true' if is_charge else 'false'},
            'cbc:AllowanceChargeReasonCode': {'_text': charge_reason_code if is_charge else '100'},
            'cbc:AllowanceChargeReason': {'_text': tax.name},
            'cbc:Amount': {
                '_text': FloatFmt(abs(amount), max_dp=currency.decimal_places),
                'currencyID': currency.name,
            },
        }

    def _ubl_get_line_allowance_charge_excise_node(self, vals, excise_values):
        currency = excise_values['currency']
        amount = excise_values['amount']
        tax = excise_values['tax']
        is_charge = excise_values['is_charge']
        return {
            '_currency': currency,
            'cbc:ChargeIndicator': {'_text': 'true' if is_charge else 'false'},
            'cbc:AllowanceChargeReason': {'_text': tax.name},
            'cbc:Amount': {
                '_text': FloatFmt(abs(amount), max_dp=currency.decimal_places),
                'currencyID': currency.name,
            },
        }

    def _ubl_get_line_allowance_charge_discount_node(self, vals, discount_values):
        currency = discount_values['currency']
        amount = discount_values['amount']
        base_amount = discount_values['base_amount']
        percent = discount_values['percent']
        is_charge = discount_values['is_charge']
        return {
            '_currency': currency,
            'cbc:ChargeIndicator': {'_text': 'true' if is_charge else 'false'},
            'cbc:MultiplierFactorNumeric': {'_text': abs(percent)},
            'cbc:AllowanceChargeReasonCode': {'_text': '95' if amount > 0.0 else 'ADK'},
            'cbc:AllowanceChargeReason': {'_text': _("Discount")},
            'cbc:Amount': {
                '_text': FloatFmt(abs(amount), max_dp=currency.decimal_places),
                'currencyID': currency.name,
            },
            'cbc:BaseAmount': {
                '_text': FloatFmt(abs(base_amount), max_dp=currency.decimal_places),
                'currencyID': currency.name,
            },
        }

    def _ubl_add_line_allowance_charge_nodes_for_discount(self, vals, in_foreign_currency=True):
        line_node = vals['line_node']
        base_line = vals['line_vals']['base_line']
        currency = base_line['currency_id'] if in_foreign_currency else vals['company_currency']
        suffix = '_currency' if in_foreign_currency else ''
        tax_details = base_line['tax_details']

        raw_discount_amount = tax_details[f'discount_amount{suffix}']
        if currency.is_zero(raw_discount_amount):
            return

        allowance_charges_nodes = line_node['cac:AllowanceCharge']
        allowance_charges_nodes.append(self._ubl_get_line_allowance_charge_discount_node(vals, {
            'currency': currency,
            'percent': base_line['discount'],
            'is_charge': raw_discount_amount < 0.0,
            'amount': raw_discount_amount,
            'base_amount': tax_details[f'gross_total_excluded{suffix}'],
        }))

    def _ubl_add_line_allowance_charge_nodes_for_recycling_contribution_taxes(self, vals, in_foreign_currency=True):
        line_node = vals['line_node']
        base_line = vals['line_vals']['base_line']
        currency = base_line['currency_id'] if in_foreign_currency else vals['company_currency']
        suffix = '_currency' if in_foreign_currency else ''

        allowance_charges_nodes = line_node['cac:AllowanceCharge']
        for tax_data in base_line['tax_details']['taxes_data']:
            if not self._ubl_is_recycling_contribution_tax(tax_data):
                continue

            allowance_charges_nodes.append(self._ubl_get_line_allowance_charge_recycling_contribution_node(vals, {
                'tax': tax_data['tax'],
                'is_charge': tax_data['tax_amount'] > 0.0,
                'amount': tax_data[f'tax_amount{suffix}'],
                'currency': currency,
            }))

    def _ubl_add_line_allowance_charge_nodes_for_excise_taxes(self, vals, in_foreign_currency=True):
        line_node = vals['line_node']
        base_line = vals['line_vals']['base_line']
        currency = base_line['currency_id'] if in_foreign_currency else vals['company_currency']
        suffix = '_currency' if in_foreign_currency else ''

        allowance_charges_nodes = line_node['cac:AllowanceCharge']
        for tax_data in base_line['tax_details']['taxes_data']:
            if not self._ubl_is_excise_tax(tax_data):
                continue

            allowance_charges_nodes.append(self._ubl_get_line_allowance_charge_excise_node(vals, {
                'tax': tax_data['tax'],
                'is_charge': tax_data['tax_amount'] > 0.0,
                'amount': tax_data[f'tax_amount{suffix}'],
                'currency': currency,
            }))

    def _ubl_add_line_allowance_charge_nodes(self, vals):
        vals['line_node']['cac:AllowanceCharge'] = []

    def _ubl_add_line_extension_amount_node(self, vals, in_foreign_currency=True):
        line_node = vals['line_node']
        base_line = vals['line_vals']['base_line']
        currency = base_line['currency_id'] if in_foreign_currency else vals['company_currency']
        suffix = '_currency' if in_foreign_currency else ''
        tax_details = base_line['tax_details']

        gross_total_excluded = currency.round(tax_details[f'raw_gross_total_excluded{suffix}'])
        for allowance_charge_node in line_node['cac:AllowanceCharge']:
            sign = 1 if allowance_charge_node['cbc:ChargeIndicator']['_text'] == 'true' else -1
            gross_total_excluded += sign * allowance_charge_node['cbc:Amount']['_text']

        line_node['cbc:LineExtensionAmount'] = {
            '_text': FloatFmt(gross_total_excluded, min_dp=currency.decimal_places),
            'currencyID': currency.name,
        }

    def _ubl_add_line_period_nodes(self, vals):
        vals['line_node']['cac:InvoicePeriod'] = []

    def _ubl_add_line_pricing_reference_node(self, vals):
        vals['line_node']['cac:PricingReference'] = {}

    def _ubl_add_line_tax_totals_nodes(self, vals):
        vals['line_node']['cac:TaxTotal'] = []

    def _line_nodes_filter_base_lines(self, vals, filter_function=None):
        index = 1
        for base_line in vals['base_lines']:
            if not filter_function or filter_function(base_line):
                line_vals = {'base_line': base_line, 'index': index}
                line_node = {}
                index += 1
                yield {**vals, 'line_vals': line_vals, 'line_node': line_node}

    def _ubl_add_party_endpoint_id_node(self, vals):
        vals['party_node']['cbc:EndpointID'] = {
            '_text': None,
            'schemeID': None,
        }

    def _ubl_add_party_identification_nodes(self, vals):
        vals['party_node']['cac:PartyIdentification'] = []

    def _ubl_add_party_name_node(self, vals):
        partner = vals['party_vals']['partner']

        # When the selected partner is a contact or an invoice address, there is nothing ensuring the partner's name is set.
        # In that case, fallback on the commercial partner's name.
        if partner.name:
            name = partner.display_name
        else:
            name = partner.commercial_partner_id.display_name

        vals['party_node']['cac:PartyName'] = {
            'cbc:Name': {'_text': name},
        }

    def _ubl_get_partner_address_node(self, vals, partner):
        return {
            'cbc:StreetName': {'_text': partner.street},
            'cbc:AdditionalStreetName': {'_text': partner.street2},
            'cbc:CityName': {'_text': partner.city},
            'cbc:PostalZone': {'_text': partner.zip},
            'cbc:CountrySubentity': {'_text': partner.state_id.name},
            'cbc:CountrySubentityCode': {'_text': partner.state_id.code},
            'cac:Country': {
                'cbc:IdentificationCode': {'_text': partner.country_id.code},
                'cbc:Name': {'_text': partner.country_id.name},
            },
        }

    def _ubl_add_party_postal_address_node(self, vals):
        partner = vals['party_vals']['partner']
        vals['party_node']['cac:PostalAddress'] = self._ubl_get_partner_address_node(vals, partner)

    def _ubl_add_party_tax_scheme_nodes(self, vals):
        vals['party_node']['cac:PartyTaxScheme'] = []

    def _ubl_add_party_legal_entity_nodes(self, vals):
        vals['party_node']['cac:PartyLegalEntity'] = []

    def _ubl_add_party_contact_node(self, vals):
        partner = vals['party_vals']['partner']
        vals['party_node']['cac:Contact'] = {
            'cbc:ID': {'_text': None},
            'cbc:Name': {'_text': partner.name},
            'cbc:Telephone': {'_text': partner.phone},
            'cbc:ElectronicMail': {'_text': partner.email},
        }

    def _ubl_add_accounting_supplier_party_endpoint_id_node(self, vals):
        self._ubl_add_party_endpoint_id_node(vals)

    def _ubl_add_accounting_supplier_party_identification_nodes(self, vals):
        self._ubl_add_party_identification_nodes(vals)

    def _ubl_add_accounting_supplier_party_name_node(self, vals):
        self._ubl_add_party_name_node(vals)

    def _ubl_add_accounting_supplier_party_postal_address_node(self, vals):
        self._ubl_add_party_postal_address_node(vals)

    def _ubl_add_accounting_supplier_party_tax_scheme_nodes(self, vals):
        self._ubl_add_party_tax_scheme_nodes(vals)

    def _ubl_add_accounting_supplier_party_legal_entity_nodes(self, vals):
        self._ubl_add_party_legal_entity_nodes(vals)

    def _ubl_add_accounting_supplier_party_contact_node(self, vals):
        self._ubl_add_party_contact_node(vals)

    def _ubl_add_accounting_supplier_party_node(self, vals):
        node = vals['document_node']['cac:AccountingSupplierParty'] = {'cac:Party': {}}
        party_node = node['cac:Party']
        sub_vals = {
            **vals,
            'party_vals': {'partner': vals['supplier']},
            'party_node': party_node,
        }
        self._ubl_add_accounting_supplier_party_endpoint_id_node(sub_vals)
        self._ubl_add_accounting_supplier_party_identification_nodes(sub_vals)
        self._ubl_add_accounting_supplier_party_name_node(sub_vals)
        self._ubl_add_accounting_supplier_party_postal_address_node(sub_vals)
        self._ubl_add_accounting_supplier_party_tax_scheme_nodes(sub_vals)
        self._ubl_add_accounting_supplier_party_legal_entity_nodes(sub_vals)
        self._ubl_add_accounting_supplier_party_contact_node(sub_vals)

    def _ubl_add_accounting_customer_party_endpoint_id_node(self, vals):
        self._ubl_add_party_endpoint_id_node(vals)

    def _ubl_add_accounting_customer_party_identification_nodes(self, vals):
        self._ubl_add_party_identification_nodes(vals)

    def _ubl_add_accounting_customer_party_name_node(self, vals):
        self._ubl_add_party_name_node(vals)

    def _ubl_add_accounting_customer_party_postal_address_node(self, vals):
        self._ubl_add_party_postal_address_node(vals)

    def _ubl_add_accounting_customer_party_tax_scheme_nodes(self, vals):
        self._ubl_add_party_tax_scheme_nodes(vals)

    def _ubl_add_accounting_customer_party_legal_entity_nodes(self, vals):
        self._ubl_add_party_legal_entity_nodes(vals)

    def _ubl_add_accounting_customer_party_contact_node(self, vals):
        self._ubl_add_party_contact_node(vals)

    def _ubl_add_accounting_customer_party_node(self, vals):
        node = vals['document_node']['cac:AccountingCustomerParty'] = {'cac:Party': {}}
        party_node = node['cac:Party']
        sub_vals = {
            **vals,
            'party_vals': {'partner': vals['customer']},
            'party_node': party_node,
        }
        self._ubl_add_accounting_customer_party_endpoint_id_node(sub_vals)
        self._ubl_add_accounting_customer_party_identification_nodes(sub_vals)
        self._ubl_add_accounting_customer_party_name_node(sub_vals)
        self._ubl_add_accounting_customer_party_postal_address_node(sub_vals)
        self._ubl_add_accounting_customer_party_tax_scheme_nodes(sub_vals)
        self._ubl_add_accounting_customer_party_legal_entity_nodes(sub_vals)
        self._ubl_add_accounting_customer_party_contact_node(sub_vals)

    def _ubl_add_seller_supplier_party_node(self, vals):
        node = vals['document_node']['cac:SellerSupplierParty'] = {'cac:Party': {}}
        party_node = node['cac:Party']
        sub_vals = {
            **vals,
            'party_vals': {'partner': vals['supplier']},
            'party_node': party_node,
        }
        self._ubl_add_accounting_supplier_party_endpoint_id_node(sub_vals)
        self._ubl_add_accounting_supplier_party_identification_nodes(sub_vals)
        self._ubl_add_accounting_supplier_party_name_node(sub_vals)
        self._ubl_add_accounting_supplier_party_postal_address_node(sub_vals)
        self._ubl_add_accounting_supplier_party_tax_scheme_nodes(sub_vals)
        self._ubl_add_accounting_supplier_party_legal_entity_nodes(sub_vals)
        self._ubl_add_accounting_supplier_party_contact_node(sub_vals)

    def _ubl_add_buyer_customer_party_node(self, vals):
        node = vals['document_node']['cac:BuyerCustomerParty'] = {'cac:Party': {}}
        party_node = node['cac:Party']
        sub_vals = {
            **vals,
            'party_vals': {'partner': vals['customer']},
            'party_node': party_node,
        }
        self._ubl_add_accounting_customer_party_endpoint_id_node(sub_vals)
        self._ubl_add_accounting_customer_party_identification_nodes(sub_vals)
        self._ubl_add_accounting_customer_party_name_node(sub_vals)
        self._ubl_add_accounting_customer_party_postal_address_node(sub_vals)
        self._ubl_add_accounting_customer_party_tax_scheme_nodes(sub_vals)
        self._ubl_add_accounting_customer_party_legal_entity_nodes(sub_vals)
        self._ubl_add_accounting_customer_party_contact_node(sub_vals)

    def _ubl_add_delivery_party_endpoint_id_node(self, vals):
        self._ubl_add_party_endpoint_id_node(vals)

    def _ubl_add_delivery_party_identification_nodes(self, vals):
        self._ubl_add_party_identification_nodes(vals)

    def _ubl_add_delivery_party_name_node(self, vals):
        self._ubl_add_party_name_node(vals)

    def _ubl_add_delivery_party_postal_address_node(self, vals):
        self._ubl_add_party_postal_address_node(vals)

    def _ubl_add_delivery_party_tax_scheme_nodes(self, vals):
        self._ubl_add_party_tax_scheme_nodes(vals)

    def _ubl_add_delivery_party_legal_entity_nodes(self, vals):
        self._ubl_add_party_legal_entity_nodes(vals)

    def _ubl_add_delivery_party_contact_node(self, vals):
        self._ubl_add_party_contact_node(vals)

    def _ubl_get_delivery_node_from_delivery_address(self, vals):
        delivery_partner = vals['delivery']
        node = {
            'cbc:ActualDeliveryDate': {'_text': None},
            'cac:DeliveryLocation': {
                'cbc:ID': {
                    'schemeID': None,
                    '_text': None,
                },
                'cac:Address': self._ubl_get_partner_address_node(vals, delivery_partner),
            },
        }

        if self.module_installed('account_add_gln') and delivery_partner.global_location_number:
            node['cac:DeliveryLocation']['cbc:ID']['schemeID'] = '0088'
            node['cac:DeliveryLocation']['cbc:ID']['_text'] = delivery_partner.global_location_number

        party_node = node['cac:DeliveryParty'] = {}

        sub_vals = {
            **vals,
            'party_vals': {'partner': vals['delivery']},
            'party_node': party_node,
        }
        self._ubl_add_delivery_party_endpoint_id_node(sub_vals)
        self._ubl_add_delivery_party_identification_nodes(sub_vals)
        self._ubl_add_delivery_party_name_node(sub_vals)
        self._ubl_add_delivery_party_postal_address_node(sub_vals)
        self._ubl_add_delivery_party_tax_scheme_nodes(sub_vals)
        self._ubl_add_delivery_party_legal_entity_nodes(sub_vals)
        self._ubl_add_delivery_party_contact_node(sub_vals)

        return node

    def _ubl_add_delivery_nodes(self, vals):
        nodes = vals['document_node']['cac:Delivery'] = []

        if vals.get('delivery'):
            nodes.append(self._ubl_get_delivery_node_from_delivery_address(vals))

    def _ubl_add_invoice_line_node(self, vals):
        self._ubl_add_line_id_node(vals)
        self._ubl_add_line_note_nodes(vals)
        self._ubl_add_line_invoiced_quantity_node(vals)
        self._ubl_add_line_allowance_charge_nodes(vals)
        self._ubl_add_line_extension_amount_node(vals)
        self._ubl_add_line_period_nodes(vals)
        self._ubl_add_line_pricing_reference_node(vals)
        self._ubl_add_line_tax_totals_nodes(vals)
        self._ubl_add_line_item_node(vals)
        self._ubl_add_line_price_node(vals)

    def _ubl_add_invoice_line_nodes(self, vals, filter_function=None):
        nodes = vals['document_node']['cac:InvoiceLine'] = []
        for sub_vals in self._line_nodes_filter_base_lines(vals, filter_function=filter_function):
            self._ubl_add_invoice_line_node(sub_vals)
            nodes.append(sub_vals['line_node'])

    def _ubl_add_credit_note_line_node(self, vals):
        self._ubl_add_line_id_node(vals)
        self._ubl_add_line_note_nodes(vals)
        self._ubl_add_line_credited_quantity_node(vals)
        self._ubl_add_line_allowance_charge_nodes(vals)
        self._ubl_add_line_extension_amount_node(vals)
        self._ubl_add_line_period_nodes(vals)
        self._ubl_add_line_pricing_reference_node(vals)
        self._ubl_add_line_tax_totals_nodes(vals)
        self._ubl_add_line_item_node(vals)
        self._ubl_add_line_price_node(vals)

    def _ubl_add_credit_note_line_nodes(self, vals, filter_function=None):
        nodes = vals['document_node']['cac:CreditNoteLine'] = []
        for sub_vals in self._line_nodes_filter_base_lines(vals, filter_function=filter_function):
            self._ubl_add_credit_note_line_node(sub_vals)
            nodes.append(sub_vals['line_node'])

    def _ubl_add_debit_note_line_node(self, vals):
        self._ubl_add_line_id_node(vals)
        self._ubl_add_line_note_nodes(vals)
        self._ubl_add_line_debited_quantity_node(vals)
        self._ubl_add_line_allowance_charge_nodes(vals)
        self._ubl_add_line_extension_amount_node(vals)
        self._ubl_add_line_period_nodes(vals)
        self._ubl_add_line_pricing_reference_node(vals)
        self._ubl_add_line_tax_totals_nodes(vals)
        self._ubl_add_line_item_node(vals)
        self._ubl_add_line_price_node(vals)

    def _ubl_add_debit_note_line_nodes(self, vals, filter_function=None):
        nodes = vals['document_node']['cac:DebitNoteLine'] = []
        for sub_vals in self._line_nodes_filter_base_lines(vals, filter_function=filter_function):
            self._ubl_add_debit_note_line_node(sub_vals)
            nodes.append(sub_vals['line_node'])

    def _ubl_add_version_id_node(self, vals):
        vals['document_node']['cbc:UBLVersionID'] = {'_text': None}

    def _ubl_add_customization_id_node(self, vals):
        vals['document_node']['cbc:CustomizationID'] = {'_text': None}

    def _ubl_add_profile_id_node(self, vals):
        vals['document_node']['cbc:ProfileID'] = {'_text': None}

    def _ubl_add_id_node(self, vals):
        vals['document_node']['cbc:ID'] = {'_text': None}

    def _ubl_add_copy_indicator_node(self, vals):
        vals['document_node']['cbc:CopyIndicator'] = {'_text': None}

    def _ubl_add_issue_date_node(self, vals):
        vals['document_node']['cbc:IssueDate'] = {'_text': None}
        vals['document_node']['cbc:IssueTime'] = {'_text': None}

    def _ubl_add_due_date_node(self, vals):
        vals['document_node']['cbc:DueDate'] = {'_text': None}

    def _ubl_add_invoice_type_code_node(self, vals):
        vals['document_node']['cbc:InvoiceTypeCode'] = {'_text': None}

    def _ubl_add_credit_note_type_code_node(self, vals):
        vals['document_node']['cbc:CreditNoteTypeCode'] = {'_text': None}

    def _ubl_add_order_type_code_node(self, vals):
        vals['document_node']['cbc:OrderTypeCode'] = {'_text': None}

    def _ubl_add_notes_nodes(self, vals):
        vals['document_node']['cbc:Note'] = []

    def _ubl_add_document_currency_code_node_foreign_currency(self, vals):
        vals['document_node']['cbc:DocumentCurrencyCode'] = {'_text': vals['currency'].name}

    def _ubl_add_document_currency_code_node_company_currency(self, vals):
        vals['document_node']['cbc:DocumentCurrencyCode'] = {'_text': vals['company'].currency_id.name}

    def _ubl_add_document_currency_code_node(self, vals):
        vals['document_node']['cbc:DocumentCurrencyCode'] = {'_text': None}

    def _ubl_add_tax_currency_code_node_company_currency_if_foreign_currency(self, vals):
        company = vals['company']
        currency = vals['currency_id']
        vals['document_node']['cbc:TaxCurrencyCode'] = {'_text': None if currency == company.currency_id else company.currency_id.name}

    def _ubl_add_tax_currency_code_node_company_currency(self, vals):
        vals['document_node']['cbc:TaxCurrencyCode'] = {'_text': vals['company'].currency_id.name}

    def _ubl_add_tax_currency_code_node_empty(self, vals):
        vals['document_node']['cbc:TaxCurrencyCode'] = {'_text': None}

    def _ubl_add_tax_currency_code_node(self, vals):
        vals['document_node']['cbc:TaxCurrencyCode'] = {'_text': None}

    def _ubl_add_buyer_reference_node(self, vals):
        vals['document_node']['cbc:BuyerReference'] = {'_text': None}

    def _ubl_add_invoice_period_nodes(self, vals):
        vals['document_node']['cac:InvoicePeriod'] = {}

    def _ubl_add_order_reference_node(self, vals):
        vals['document_node']['cac:OrderReference'] = {
            'cbc:ID': {'_text': None},
            'cbc:SalesOrderID': {
                '_text': None,
            },
        }

    def _ubl_add_billing_reference_nodes(self, vals):
        vals['document_node']['cac:BillingReference'] = []

    def _ubl_get_partner_bank_address_node(self, vals, bank):
        return {
            'cbc:StreetName': {'_text': bank.street},
            'cbc:AdditionalStreetName': {'_text': bank.street2},
            'cbc:CityName': {'_text': bank.city},
            'cbc:PostalZone': {'_text': bank.zip},
            'cbc:CountrySubentity': {'_text': bank.state.name},
            'cbc:CountrySubentityCode': {'_text': bank.state.code},
            'cac:Country': {
                'cbc:IdentificationCode': {'_text': bank.country.code},
                'cbc:Name': {'_text': bank.country.name},
            },
        }

    def _ubl_get_payment_means_payee_financial_account_institution_branch_node_from_partner_bank(self, vals, partner_bank):
        bank = partner_bank.bank_id
        if not bank:
            return None

        return {
            'cbc:ID': {
                '_text': bank.bic,
                'schemeID': 'BIC',
            },
            'cac:FinancialInstitution': {
                'cbc:ID': {
                    '_text': bank.bic,
                    'schemeID': 'BIC',
                },
                'cbc:Name': {'_text': bank.name},
                'cac:Address': self._ubl_get_partner_bank_address_node(vals, bank)
            }
        }

    def _ubl_get_payment_means_payee_financial_account_node_from_partner_bank(self, vals, partner_bank):
        return {
            'cbc:ID': {'_text': partner_bank.sanitized_acc_number},
            'cac:FinancialInstitutionBranch': self._ubl_get_payment_means_payee_financial_account_institution_branch_node_from_partner_bank(vals, partner_bank),
        }

    def _ubl_add_payment_means_nodes(self, vals):
        vals['document_node']['cac:PaymentMeans'] = []

    def _ubl_get_payment_terms_node_from_payment_term(self, vals, payment_term):
        note = payment_term.note and html2plaintext(payment_term.note) or None
        if not note:
            return

        return {
            'cbc:Note': {'_text': note}
        }

    def _ubl_add_payment_terms_nodes(self, vals):
        vals['document_node']['cac:PaymentTerms'] = []

    def _ubl_get_allowance_charge_early_payment_tax_category_node(self, vals, tax_category):
        return {
            '_currency': tax_category['currency'],
            'cbc:ID': {'_text': tax_category['tax_category_code']},
            'cbc:Percent': {'_text': tax_category['percent']},
            'cac:TaxScheme': {
                'cbc:ID': {'_text': tax_category['scheme_id']},
            }
        }

    def _ubl_get_allowance_charge_early_payment_node(self, vals, early_payment_values):
        currency = early_payment_values['currency']
        amount = early_payment_values['amount']
        is_charge = early_payment_values['is_charge']
        return {
            '_currency': currency,
            'cbc:ChargeIndicator': {'_text': 'true' if is_charge else 'false'},
            'cbc:AllowanceChargeReasonCode': {'_text': 'ZZZ' if is_charge else '64'},
            'cbc:AllowanceChargeReason': {'_text': _("Conditional cash/payment discount")},
            'cbc:Amount': {
                '_text': currency.round(abs(amount)),
                'currencyID': currency.name,
            },
            'cac:TaxCategory': [
                self._ubl_get_allowance_charge_early_payment_tax_category_node(vals, tax_category)
                for tax_category in early_payment_values['tax_categories'].values()
            ],
        }

    def _ubl_get_allowance_charge_early_payment(self, vals, early_payment_values):
        # DEPRECATED: TO BE REMOVED IN MASTER
        _logger.warning("DEPRECATED")
        return self._ubl_get_allowance_charge_early_payment_node(vals, early_payment_values)

    def _ubl_add_allowance_charge_nodes_early_payment_discount(self, vals, in_foreign_currency=True):
        AccountTax = self.env['account.tax']
        suffix = '_currency' if in_foreign_currency else ''
        currency = vals['currency'] if in_foreign_currency else vals['company_currency']

        allowance_charge_nodes = vals['document_node']['cac:AllowanceCharge']
        base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
            base_lines=vals['base_lines'],
            grouping_function=lambda base_line, tax_data: self._ubl_default_allowance_charge_early_payment_grouping_key(
                base_line=base_line,
                tax_data=tax_data,
                vals=vals,
                currency=currency,
            ),
        )
        values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
        for grouping_key, values in values_per_grouping_key.items():
            if not grouping_key:
                continue

            allowance_charge_nodes.append(self._ubl_get_allowance_charge_early_payment_node(vals, {
                'currency': currency,
                'amount': values[f'total_excluded{suffix}'],
                'is_charge': values[f'total_excluded{suffix}'] > 0.0,
                'tax_categories': {
                    grouping_key: {
                        **grouping_key,
                        'base_amount': values[f'base_amount{suffix}'],
                        'tax_amount': values[f'tax_amount{suffix}'],
                    },
                },
            }))

    def _ubl_add_allowance_charge_nodes(self, vals):
        vals['document_node']['cac:AllowanceCharge'] = []

    def _ubl_get_tax_category_node(self, vals, tax_category):
        """ Generate the node 'cac:TaxCategory' in 'cac:SubTotal'.

        :param vals:            Some custom data.
        :param tax_category:    An entry of vals['_ubl_values'](['tax_totals']|['withholding_tax_totals'])['tax_subtotals']
                                containing all the necessary data to build the node.
        :return:                A new node in 'cac:TaxTotal'.
        """
        return {
            '_currency': tax_category['currency'],
            'cbc:ID': {'_text': tax_category['tax_category_code']},
            'cbc:Name': {'_text': None},
            'cbc:Percent': {'_text': tax_category['percent']},
            'cbc:TaxExemptionReasonCode': {'_text': tax_category.get('tax_exemption_reason_code')},
            'cbc:TaxExemptionReason': {'_text': tax_category.get('tax_exemption_reason')},
            'cac:TaxScheme': {
                'cbc:ID': {'_text': tax_category['scheme_id']},
            }
        }

    def _ubl_get_tax_subtotal_node(self, vals, tax_subtotal):
        """ Generate the node 'cac:SubTotal' in 'cac:TaxTotal'/'cac:WithholdingTaxTotal'.

        Note: 'cac:TaxCategory' is managed by '_ubl_get_tax_category_node'.

        :param vals:            Some custom data.
        :param tax_subtotal:    An entry of vals['_ubl_values'](['tax_totals']|['withholding_tax_totals'])['tax_subtotals']
                                containing all the necessary data to build the node.
        :return:                A new node in 'cac:TaxTotal'.
        """
        currency = tax_subtotal['currency']
        return {
            '_currency': currency,
            'cbc:TaxableAmount': {
                '_text': FloatFmt(tax_subtotal['base_amount'], min_dp=currency.decimal_places),
                'currencyID': currency.name
            },
            'cbc:TaxAmount': {
                '_text': FloatFmt(tax_subtotal['tax_amount'], min_dp=currency.decimal_places),
                'currencyID': currency.name
            },
            'cbc:Percent': {
                '_text': (
                    tax_subtotal['percent']
                    if tax_subtotal.get('percent') is not None
                    else None
                ),
            },
            'cac:TaxCategory': [
                self._ubl_get_tax_category_node(vals, tax_category)
                for tax_category in tax_subtotal['tax_categories'].values()
            ],
        }

    def _ubl_get_tax_total_node(self, vals, tax_total):
        """ Generate the node 'cac:TaxTotal'.

        Note: 'cac:Subtotal' is managed by '_ubl_get_tax_subtotal_node'.

        :param vals:            Some custom data.
        :param tax_total:       An entry of vals['_ubl_values']['tax_totals'] containing all the necessary data to build the node.
        :return:                A new node in 'cac:TaxTotal'.
        """
        currency = tax_total['currency']
        return {
            '_currency': currency,
            'cbc:TaxAmount': {
                '_text': FloatFmt(tax_total['amount'], min_dp=currency.decimal_places),
                'currencyID': currency.name
            },
            'cac:TaxSubtotal': [
                self._ubl_get_tax_subtotal_node(vals, subtotal)
                for subtotal in tax_total['subtotals'].values()
            ],
        }

    def _ubl_get_withholding_tax_total_node(self, vals, tax_total):
        """ Generate the node 'cac:WithholdingTaxTotal'.

        Note: 'cac:Subtotal' is managed by '_ubl_get_tax_subtotal_node'.

        :param vals:            Some custom data.
        :param tax_total:       An entry of vals['_ubl_values']['withholding_tax_totals'] containing all the necessary data to build the node.
        :return:                A new node in 'cac:WithholdingTaxTotal'.
        """
        return self._ubl_get_tax_total_node(vals, tax_total)

    def _ubl_tax_totals_node_grouping_key(self, base_line, tax_data, vals, currency):
        tax_category_key = self._ubl_default_tax_category_grouping_key(base_line, tax_data, vals, currency)
        if tax_category_key:
            tax_subtotal_key = {
                'currency': tax_category_key['currency'],
                'is_withholding': tax_category_key['is_withholding'],
                'tax_category_code': tax_category_key['tax_category_code'],
                'scheme_id': tax_category_key['scheme_id'],
                'percent': tax_category_key['percent'],
            }
        else:
            tax_subtotal_key = None
        if tax_category_key:
            tax_total_key = {
                'is_withholding': tax_category_key['is_withholding'],
                'currency': currency,
            }
        else:
            tax_total_key = None
        return {
            'tax_category_key': tax_category_key,
            'tax_subtotal_key': tax_subtotal_key,
            'tax_total_key': tax_total_key
        }

    def _ubl_add_tax_totals_nodes(self, vals):
        AccountTax = self.env['account.tax']
        base_lines = vals['base_lines']
        company = vals['company']
        company_currency = company.currency_id
        currency = vals['currency_id']

        iter_currency = [(currency, '_currency')]
        if currency != company_currency:
            iter_currency.append((company_currency, ''))

        # Since we have to combine 3 keys given by the same grouping key but used at 3 different places, we compute them in advance.
        # /!\ Even without tax, a base line could get a grouping key for a 0% tax without any real record set.
        new_base_lines = [
            {
                **base_line,
                '_tax_totals_keys': {
                    (tax_data['tax'] if tax_data else None, currency): self._ubl_tax_totals_node_grouping_key(base_line, tax_data, vals, currency)
                    for currency, _suffix in iter_currency
                    for tax_data in base_line['tax_details']['taxes_data'] or [None]
                }
            }
            for base_line in base_lines
        ]

        collected_tax_totals_values = {
            'cac:TaxTotal': {},
            'cac:WithholdingTaxTotal': {},
        }
        for sub_currency, suffix in iter_currency:

            # tax_totals / withholding_tax_totals

            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=new_base_lines,
                grouping_function=lambda base_line, tax_data: base_line['_tax_totals_keys'][(tax_data or {}).get('tax'), sub_currency]['tax_total_key']
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue

                if grouping_key['is_withholding']:
                    target_key = 'cac:WithholdingTaxTotal'
                    sign = -1
                else:
                    target_key = 'cac:TaxTotal'
                    sign = 1

                collected_tax_totals_values[target_key][frozendict(grouping_key)] = {
                    **grouping_key,
                    'amount': sign * values[f'tax_amount{suffix}'],
                    'subtotals': {},
                }

            # tax_subtotals

            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=new_base_lines,
                grouping_function=lambda base_line, tax_data: {
                    k: v
                    for k, v in base_line['_tax_totals_keys'][(tax_data or {}).get('tax'), sub_currency].items()
                    if k in ('tax_total_key', 'tax_subtotal_key')
                },
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue
                tax_total_key = grouping_key['tax_total_key']
                tax_subtotal_key = grouping_key['tax_subtotal_key']
                if not tax_total_key or not tax_subtotal_key:
                    continue

                if tax_total_key['is_withholding']:
                    target_key = 'cac:WithholdingTaxTotal'
                    sign = -1
                else:
                    target_key = 'cac:TaxTotal'
                    sign = 1

                tax_total_values = collected_tax_totals_values[target_key][frozendict(tax_total_key)]
                tax_total_values['subtotals'][frozendict(tax_subtotal_key)] = {
                    **tax_subtotal_key,
                    'base_amount': values[f'base_amount{suffix}'],
                    'tax_amount': sign * values[f'tax_amount{suffix}'],
                    'tax_categories': {},
                }

            # tax_categories

            base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
                base_lines=new_base_lines,
                grouping_function=lambda base_line, tax_data: base_line['_tax_totals_keys'][(tax_data or {}).get('tax'), sub_currency],
            )
            values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
            for grouping_key, values in values_per_grouping_key.items():
                if not grouping_key:
                    continue
                tax_total_key = grouping_key['tax_total_key']
                tax_subtotal_key = grouping_key['tax_subtotal_key']
                tax_category_key = grouping_key['tax_category_key']
                if not tax_total_key or not tax_subtotal_key or not tax_category_key:
                    continue

                if tax_total_key['is_withholding']:
                    target_key = 'cac:WithholdingTaxTotal'
                    sign = -1
                else:
                    target_key = 'cac:TaxTotal'
                    sign = 1

                tax_total_values = collected_tax_totals_values[target_key][frozendict(tax_total_key)]
                tax_subtotal_values = tax_total_values['subtotals'][frozendict(tax_subtotal_key)]
                tax_subtotal_values['tax_categories'][frozendict(tax_category_key)] = {
                    **tax_category_key,
                    'base_amount': values[f'base_amount{suffix}'],
                    'tax_amount': sign * values[f'tax_amount{suffix}'],
                }

        for key, tax_totals_values in collected_tax_totals_values.items():
            nodes = vals['document_node'][key] = []
            for tax_total_values in tax_totals_values.values():
                tax_total_node = self._ubl_get_tax_total_node(vals, tax_total_values)
                nodes.append(tax_total_node)

    def _ubl_add_legal_monetary_total_line_extension_amount_node(self, vals, in_foreign_currency=True):
        currency = vals['currency_id'] if in_foreign_currency else vals['company_currency']

        line_extension_amount = sum(
            line_node['cbc:LineExtensionAmount']['_text']
            for line_key in ('cac:InvoiceLine', 'cac:CreditNoteLine', 'cac:DebitNoteLine')
            for line_node in vals['document_node'].get(line_key, [])
        )
        vals['legal_monetary_total_node']['cbc:LineExtensionAmount'] = {
            '_text': FloatFmt(line_extension_amount, min_dp=currency.decimal_places),
            'currencyID': currency.name,
        }

    def _ubl_add_legal_monetary_total_tax_exclusive_amount_node(self, vals, in_foreign_currency=True):
        currency = vals['currency_id'] if in_foreign_currency else vals['company_currency']
        node = vals['legal_monetary_total_node']

        node['cbc:TaxExclusiveAmount'] = {
            '_text': FloatFmt(node['cbc:LineExtensionAmount']['_text'], min_dp=currency.decimal_places),
            'currencyID': currency.name,
        }

    def _ubl_add_legal_monetary_total_tax_inclusive_amount_node(self, vals, in_foreign_currency=True):
        currency = vals['currency_id'] if in_foreign_currency else vals['company_currency']
        document_node = vals['document_node']
        node = vals['legal_monetary_total_node']

        tax_amount = sum(
            tax_total_node['cbc:TaxAmount']['_text']
                for tax_total_node in document_node['cac:TaxTotal']
                if tax_total_node['_currency'] == currency
        ) + sum(
            -tax_total_node['cbc:TaxAmount']['_text']
                for tax_total_node in document_node['cac:WithholdingTaxTotal']
                if tax_total_node['_currency'] == currency
        )

        node['cbc:TaxInclusiveAmount'] = {
            '_text': FloatFmt(
                node['cbc:TaxExclusiveAmount']['_text'] + tax_amount,
                min_dp=currency.decimal_places,
            ),
            'currencyID': currency.name,
        }

    def _ubl_add_legal_monetary_total_allowance_charge_total_amount_node(self, vals, in_foreign_currency=True):
        currency = vals['currency_id'] if in_foreign_currency else vals['company_currency']
        node = vals['legal_monetary_total_node']

        total_allowance = sum(
            allowance_node['cbc:Amount']['_text']
                for allowance_node in vals['document_node']['cac:AllowanceCharge']
                if allowance_node['cbc:ChargeIndicator']['_text'] != 'false'
        )
        total_charge = sum(
            charge_node['cbc:Amount']['_text']
                for charge_node in vals['document_node']['cac:AllowanceCharge']
                if charge_node['cbc:ChargeIndicator']['_text'] != 'true'
        )

        node.update({
            'cbc:AllowanceTotalAmount': {
                '_text': FloatFmt(total_allowance, min_dp=currency.decimal_places),
                'currencyID': currency.name,
            } if total_allowance else None,
            'cbc:ChargeTotalAmount': {
                '_text': FloatFmt(total_charge, min_dp=currency.decimal_places),
                'currencyID': currency.name,
            } if total_charge else None,
        })

    def _ubl_add_legal_monetary_total_prepaid_payable_amount_node(self, vals, in_foreign_currency=True):
        currency = vals['currency_id'] if in_foreign_currency else vals['company_currency']
        node = vals['legal_monetary_total_node']

        payable_rounding_amount = (node['cbc:PayableRoundingAmount'] or {}).get('_text') or 0.0
        node['cbc:PrepaidAmount'] = {
            '_text': FloatFmt(0.0, min_dp=currency.decimal_places),
            'currencyID': currency.name,
        }
        node['cbc:PayableAmount'] = {
            '_text': FloatFmt(
                node['cbc:TaxInclusiveAmount']['_text']
                + payable_rounding_amount,
                min_dp=currency.decimal_places,
            ),
            'currencyID': currency.name,
        }

    def _ubl_add_legal_monetary_total_payable_rounding_amount_node_from_cash_rounding(self, vals, in_foreign_currency=True):
        # DEPRECATED: TO BE REMOVED
        pass

    def _ubl_add_legal_monetary_total_payable_rounding_amount_node(self, vals):
        AccountTax = self.env['account.tax']
        base_lines = vals['base_lines']
        currency = vals['currency_id']
        node = vals['legal_monetary_total_node']
        tax_inclusive_amount = node['cbc:TaxInclusiveAmount']['_text']

        base_lines_aggregated_values = AccountTax._aggregate_base_lines_tax_details(
            base_lines=base_lines,
            grouping_function=lambda base_line, tax_data: True,
        )
        values_per_grouping_key = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values)
        expected_tax_inclusive_amount = sum(
             values['base_amount_currency'] + values['tax_amount_currency']
             for values in values_per_grouping_key.values()
        )

        payable_rounding_amount = expected_tax_inclusive_amount - tax_inclusive_amount
        if currency.is_zero(payable_rounding_amount):
            node['cbc:PayableRoundingAmount'] = {
                '_text': None,
                'currencyID': None,
            }
        else:
            node['cbc:PayableRoundingAmount'] = {
                '_text': FloatFmt(payable_rounding_amount, min_dp=currency.decimal_places),
                'currencyID': currency.name,
            }

    def _ubl_add_legal_monetary_total_node(self, vals):
        node = vals['document_node']['cac:LegalMonetaryTotal'] = {}
        sub_vals = {**vals, 'legal_monetary_total_node': node}
        self._ubl_add_legal_monetary_total_line_extension_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_tax_exclusive_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_tax_inclusive_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_allowance_charge_total_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_payable_rounding_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_prepaid_payable_amount_node(sub_vals)

    def _ubl_add_requested_monetary_total_node(self, vals):
        node = vals['document_node']['cac:RequestedMonetaryTotal'] = {}
        sub_vals = {**vals, 'legal_monetary_total_node': node}
        self._ubl_add_legal_monetary_total_line_extension_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_tax_exclusive_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_tax_inclusive_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_allowance_charge_total_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_payable_rounding_amount_node(sub_vals)
        self._ubl_add_legal_monetary_total_prepaid_payable_amount_node(sub_vals)
