From 737c2356cc8bab9e9ebda31172096f1111a1196b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Scarafia?= Date: Tue, 16 Dec 2025 18:00:14 -0300 Subject: [PATCH 1/2] [IMP] account_exchange_difference_invoice: Tax proration and fiscal position support This commit improves the exchange difference wizard to properly handle taxes and fiscal positions when creating debit/credit notes. Main changes: - Add fiscal_position field to wizard (automatic/manual selection) - Add fiscal_position_id field for manual fiscal position assignment - Implement tax proration logic in _prepare_invoice_lines_by_taxes(): * Groups invoice lines by tax combination * For Argentina: only considers VAT taxes (with l10n_ar_vat_afip_code) * Prorates exchange difference amount across tax groups based on their weight * Handles rounding differences by adjusting the last line - Refactor _prepare_debit_credit_note() to: * Accept exch_moves parameter to retrieve related invoice lines * Generate separate lines for each tax combination * Apply manual fiscal position when configured - Update wizard view to show fiscal position fields This ensures exchange difference debit/credit notes have the same tax structure as the original invoices, maintaining proper tax tracking and compliance. --- .../wizards/exchange_difference_wizard.py | 153 ++++++++++++++++-- .../exchange_difference_wizard_views.xml | 2 + 2 files changed, 140 insertions(+), 15 deletions(-) diff --git a/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py b/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py index c8ff1a041..f3e85f6ea 100644 --- a/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py +++ b/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py @@ -17,6 +17,15 @@ class ExchangeDifferenceWizard(models.TransientModel): domain=[("type", "=", "sale")], check_company=True, ) + fiscal_position = fields.Selection( + [("automatic", "Automatic"), ("manual", "Manual")], + default="automatic", + required=True, + help="If automatic, fiscal position will be auto detected for each customer, if manual you can force one or none fiscal position.", + ) + fiscal_position_id = fields.Many2one( + "account.fiscal.position", string="Fiscal Position", help="Fiscal position to use for all the customers." + ) @api.model def default_get(self, fields): @@ -110,6 +119,7 @@ def _create_invoice_and_reconcile(self, journal): ) exch_moves = partner_to_moves.get(rec.partner_id.id, self.env["account.move"]) + exch_moves.write({"exchange_reversal_id": move.id}) move.action_post() @@ -120,7 +130,7 @@ def _create_invoice_and_reconcile(self, journal): debit_credit_note = ( self.env["account.move"] .with_context(exchange_diff_account_receivable_id=rec_account.id) - .create(rec._prepare_debit_credit_note(journal=journal)) + .create(rec._prepare_debit_credit_note(exch_moves, journal)) ) if debit_credit_note.currency_id.round(debit_credit_note.amount_total) < 0: # switch to credit note if the amount is negative @@ -221,15 +231,137 @@ def _get_receivable_account(self): return rec_account - def _prepare_debit_credit_note(self, journal): + def _prepare_invoice_lines_by_taxes(self, invoice_lines, account, amount): + """ + Agrupa las líneas de factura por combinación de impuestos y prorratea el balance. + Retorna una lista de tuplas (0, 0, vals) para crear las líneas de la nota de débito/crédito. + """ + self.ensure_one() + + product = self.env.company.exchange_difference_product + partner = self.partner_id + + # Si no hay líneas de factura, retornamos una única línea con el balance total + if not invoice_lines: + return [ + ( + 0, + 0, + { + "account_id": account.id, + "quantity": 1.0, + "price_unit": self.balance, + "partner_id": partner.id, + "product_id": product.id, + }, + ) + ] + + # En contexto de localización argentina, filtramos los impuestos que no son IVA + # (aquellos cuyo tax_group_id no tiene l10n_ar_vat_afip_code seteado) + is_argentina = self.env["account.tax.group"]._fields.get("l10n_ar_vat_afip_code") + + tax_groups = {} + for line in invoice_lines: + if is_argentina: + # Solo consideramos impuestos de IVA (con l10n_ar_vat_afip_code seteado) + taxes = line.tax_ids.filtered("tax_group_id.l10n_ar_vat_afip_code") + else: + taxes = line.tax_ids + tax_key = frozenset(taxes.ids) + if tax_key not in tax_groups: + tax_groups[tax_key] = { + "tax_ids": taxes, + "subtotal": 0.0, + } + tax_groups[tax_key]["subtotal"] += line.price_total + + # Calcular el total de subtotales para el prorrateo + total_subtotal = sum(group["subtotal"] for group in tax_groups.values()) + # total_subtotal = sum(invoice_lines.mapped("price_total")) + + # Si el total es cero, distribuimos equitativamente + if total_subtotal == 0: + return [ + ( + 0, + 0, + { + "account_id": account.id, + "quantity": 1.0, + "price_unit": amount, + "partner_id": partner.id, + "product_id": product.id, + }, + ) + ] + + # Crear las líneas prorrateadas + invoice_line_vals = [] + remaining_balance = amount + groups_list = list(tax_groups.values()) + + for i, group in enumerate(groups_list): + if i == len(groups_list) - 1: + # Última línea: usar el saldo restante para evitar diferencias de redondeo + price_unit = remaining_balance + else: + # Prorratear según el peso del subtotal + proportion = group["subtotal"] / total_subtotal + price_unit = self.wizard_id.company_id.currency_id.round(amount * proportion) + remaining_balance -= price_unit + + invoice_line_vals.append( + ( + 0, + 0, + { + "account_id": account.id, + "quantity": 1.0, + "price_unit": price_unit, + "partner_id": partner.id, + "product_id": product.id, + "tax_ids": [(6, 0, group["tax_ids"].ids)], + }, + ) + ) + + return invoice_line_vals + + def _prepare_debit_credit_note(self, exch_moves, journal): """ Retorna un diccionario con los datos para crear la nota de débito/crédito + Prorratea el balance entre las diferentes combinaciones de impuestos presentes en las líneas de factura """ self.ensure_one() company = self.wizard_id.company_id partner = self.partner_id account = self.env["account.move.line"]._get_exchange_account(company, self.balance) + + # approach si hacemos una sola linea + # partial_reconciles = self.env["account.partial.reconcile"].search( + # [("exchange_move_id", "in", exch_moves.ids)] + # ) + # invoice_lines = (partial_reconciles.mapped("debit_move_id") + partial_reconciles.mapped("credit_move_id")).mapped("move_id").filtered(lambda move: move.is_sale_document()).mapped("invoice_line_ids") + # invoice_line_vals = self._prepare_invoice_lines_by_taxes(invoice_lines, account, self.balance) + + # en este approach iteramos sobre cada exhcange diff y generamos lineas para cada grupo de impuestos + invoice_line_vals = [] + for exchange_move in exch_moves: + partial_reconcile = self.env["account.partial.reconcile"].search( + [("exchange_move_id", "=", exchange_move.ids)] + ) + invoice_lines = ( + (partial_reconcile.debit_move_id + partial_reconcile.credit_move_id) + .mapped("move_id") + .filtered(lambda move: move.is_sale_document()) + .mapped("invoice_line_ids") + ) + invoice_line_vals += self._prepare_invoice_lines_by_taxes( + invoice_lines, account, exchange_move.amount_total_signed + ) + invoice_vals = { "move_type": "out_invoice", "currency_id": company.currency_id.id, @@ -239,21 +371,12 @@ def _prepare_debit_credit_note(self, journal): "journal_id": journal.id, "invoice_origin": "Ajuste por diferencia de cambio", "invoice_payment_term_id": False, - "invoice_line_ids": [ - ( - 0, - 0, - { - "account_id": account.id, - "quantity": 1.0, - "price_unit": self.balance, - "partner_id": partner.id, - "product_id": self.env.company.exchange_difference_product.id, - }, - ) - ], + "invoice_line_ids": invoice_line_vals, } + if self.wizard_id.fiscal_position == "manual" and self.wizard_id.fiscal_position_id: + invoice_vals["fiscal_position_id"] = self.wizard_id.fiscal_position_id.id + # hack para evitar modulo glue con l10n_latam_document # hasta el momento tenemos feedback de dos clientes uruguayos de que los ajustes por intereses # se hacen comoo factura normal y no ND. Si eventualmente otros clintes solicitan ND tendremos diff --git a/account_exchange_difference_invoice/wizards/exchange_difference_wizard_views.xml b/account_exchange_difference_invoice/wizards/exchange_difference_wizard_views.xml index c8d0b63aa..c43bfc58e 100644 --- a/account_exchange_difference_invoice/wizards/exchange_difference_wizard_views.xml +++ b/account_exchange_difference_invoice/wizards/exchange_difference_wizard_views.xml @@ -7,6 +7,8 @@
+ + From f008011f9238c874d3452978f535d3f55d8317bf Mon Sep 17 00:00:00 2001 From: Camila Vives Date: Fri, 26 Dec 2025 15:24:54 +0000 Subject: [PATCH 2/2] [FIX] _exchange_difference_invoice: Fix tax calculation with fiscal position When calculating tax groups for proration in the exchange difference wizard, we need to ensure that if the invoice has a fiscal position, the perceptions and other tax amounts should not be included in the subtotal used for proration. This case only applies if the user selects automatic fiscal position application in the wizard. Also, if user selects manual and not fiscal position id, the fiscal position will be cleaned from the invoice to avoid re-calculation taxes when adding the date. --- .../wizards/exchange_difference_wizard.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py b/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py index f3e85f6ea..3ddae2d6f 100644 --- a/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py +++ b/account_exchange_difference_invoice/wizards/exchange_difference_wizard.py @@ -20,11 +20,14 @@ class ExchangeDifferenceWizard(models.TransientModel): fiscal_position = fields.Selection( [("automatic", "Automatic"), ("manual", "Manual")], default="automatic", + string="Fiscal Position Mode", required=True, help="If automatic, fiscal position will be auto detected for each customer, if manual you can force one or none fiscal position.", ) fiscal_position_id = fields.Many2one( - "account.fiscal.position", string="Fiscal Position", help="Fiscal position to use for all the customers." + "account.fiscal.position", + string="Fiscal Position", + help="Fiscal position to use for all the customers.", ) @api.model @@ -45,7 +48,10 @@ def default_get(self, fields): # Recuperamos las líneas de movimiento # por ahora directamente filtramos los que ya se procesaron move_lines = self.env["account.move.line"].search( - [("move_id.exchange_reversal_id", "=", False), ("move_id.exchange_reversed_move_ids", "=", False)] + [ + ("move_id.exchange_reversal_id", "=", False), + ("move_id.exchange_reversed_move_ids", "=", False), + ] + [("id", "in", move_line_ids)] ) @@ -266,6 +272,7 @@ def _prepare_invoice_lines_by_taxes(self, invoice_lines, account, amount): if is_argentina: # Solo consideramos impuestos de IVA (con l10n_ar_vat_afip_code seteado) taxes = line.tax_ids.filtered("tax_group_id.l10n_ar_vat_afip_code") + other_taxes = line.tax_ids - taxes else: taxes = line.tax_ids tax_key = frozenset(taxes.ids) @@ -274,7 +281,10 @@ def _prepare_invoice_lines_by_taxes(self, invoice_lines, account, amount): "tax_ids": taxes, "subtotal": 0.0, } - tax_groups[tax_key]["subtotal"] += line.price_total + if not self.wizard_id.fiscal_position_id: + tax_groups[tax_key]["subtotal"] += line.price_total + else: + tax_groups[tax_key]["subtotal"] += line.price_total - sum(other_taxes.mapped("amount")) # Calcular el total de subtotales para el prorrateo total_subtotal = sum(group["subtotal"] for group in tax_groups.values()) @@ -374,8 +384,11 @@ def _prepare_debit_credit_note(self, exch_moves, journal): "invoice_line_ids": invoice_line_vals, } - if self.wizard_id.fiscal_position == "manual" and self.wizard_id.fiscal_position_id: - invoice_vals["fiscal_position_id"] = self.wizard_id.fiscal_position_id.id + if self.wizard_id.fiscal_position == "manual": + if self.wizard_id.fiscal_position_id: + invoice_vals["fiscal_position_id"] = self.wizard_id.fiscal_position_id.id + else: + invoice_vals["fiscal_position_id"] = False # hack para evitar modulo glue con l10n_latam_document # hasta el momento tenemos feedback de dos clientes uruguayos de que los ajustes por intereses