diff --git a/l10n_ve_accountant/__manifest__.py b/l10n_ve_accountant/__manifest__.py index f01c4dc7..4122498b 100644 --- a/l10n_ve_accountant/__manifest__.py +++ b/l10n_ve_accountant/__manifest__.py @@ -13,9 +13,9 @@ "web", "account", "account_reports", - "purchase", - "sale", - "l10n_ve_base", + "purchase", + "sale", + "l10n_ve_base", "l10n_ve_rate", "l10n_ve_contact", ], @@ -54,5 +54,5 @@ "web.assets_backend": ["l10n_ve_accountant/static/src/components/**/*"], }, "application": True, - "pre_init_hook": "pre_init_hook" + "pre_init_hook": "pre_init_hook", } diff --git a/l10n_ve_accountant/models/account_move.py b/l10n_ve_accountant/models/account_move.py index ecb55806..ee8d0ced 100644 --- a/l10n_ve_accountant/models/account_move.py +++ b/l10n_ve_accountant/models/account_move.py @@ -18,6 +18,76 @@ class AccountMove(models.Model): _inherit = "account.move" + _sql_constraints = [ + ( + "unique_name", + "", + "Another entry with the same name already exists.", + ), + ( + "unique_name_ve", + "", + "Another entry with the same name already exists.", + ), + ] + + def _auto_init(self): + res = super()._auto_init() + if not index_exists(self.env.cr, "account_move_unique_name_ve"): + drop_index(self.env.cr, "account_move_unique_name", self._table) + # Make all values of `name` different (naming them `name (1)`, `name (2)`...) so that + # we can add the following UNIQUE INDEX + self.env.cr.execute( + """ + WITH duplicated_sequence AS ( + SELECT name, partner_id, state, journal_id + FROM account_move + WHERE state = 'posted' + AND name != '/' + AND move_type IN ('in_invoice', 'in_refund', 'in_receipt') + GROUP BY partner_id, journal_id, name, state + HAVING COUNT(*) > 1 + ), + to_update AS ( + SELECT move.id, + move.name, + move.state, + move.date, + row_number() OVER(PARTITION BY move.name, move.partner_id, move.partner_id, move.date) AS row_seq + FROM duplicated_sequence + JOIN account_move move ON move.name = duplicated_sequence.name + AND move.partner_id = duplicated_sequence.partner_id + AND move.state = duplicated_sequence.state + AND move.journal_id = duplicated_sequence.journal_id + ), + new_vals AS ( + SELECT id, + name || ' (' || (row_seq-1)::text || ')' AS name + FROM to_update + WHERE row_seq > 1 + ) + UPDATE account_move + SET name = new_vals.name + FROM new_vals + WHERE account_move.id = new_vals.id; + """ + ) + + self.env.cr.execute( + """ + CREATE UNIQUE INDEX account_move_unique_name + ON account_move( + name, partner_id, company_id, journal_id + ) + WHERE state = 'posted' AND name != '/'; + CREATE UNIQUE INDEX account_move_unique_name_ve + ON account_move( + name, partner_id, company_id, journal_id + ) + WHERE state = 'posted' AND name != '/'; + """ + ) + return res def _get_fields_to_compute_lines(self): return ["invoice_line_ids", "line_ids", "foreign_inverse_rate", "foreign_rate"] @@ -304,19 +374,6 @@ def create(self, vals_list): Ensure that the foreign_rate and foreign_inverse_rate are computed and computes the foreign debit and foreign credit of the line_ids fields (journal entries) when the move is created. """ - for vals in vals_list: - - if 'name' in vals and vals['name'] != "/": - - domain = [ - ('name', '=', vals['name']), - ('partner_id', '=', vals.get('partner_id')) - ] - existing_record = self.search(domain, limit=1) - - if existing_record: - raise ValidationError(_("The operation cannot be completed: Another entry with the same name already exists.")) - moves = super().create(vals_list) for move in moves: diff --git a/l10n_ve_accountant/models/account_tax.py b/l10n_ve_accountant/models/account_tax.py index 33050ed0..8bd55f80 100644 --- a/l10n_ve_accountant/models/account_tax.py +++ b/l10n_ve_accountant/models/account_tax.py @@ -101,6 +101,7 @@ def _get_tax_totals_summary( currency_obj=ves_currency ) + # Foraneos res['formatted_base_amount_foreign_currency'] = formatLang( env=self.env, @@ -183,7 +184,6 @@ def _get_tax_totals_summary( value=res_tax_group.get('base_amount', 0.0), currency_obj=ves_currency ) - res_tax_group['formatted_tax_amount_currency_ves'] = formatLang( env=self.env, value=res_tax_group.get('tax_amount', 0.0), @@ -210,7 +210,6 @@ def _get_tax_totals_summary( value=res_tax_group.get('display_base_amount_foreign_currency', 0.0), currency_obj=foreign_currency_id ) - _logger.warning("TOTALES FINALES CON MONEDAS: %s", res) return res @api.model @@ -289,4 +288,12 @@ def load(field, fallback, from_base_line=False): if k.startswith('_') and k not in base_line: base_line[k] = v + manual_fields = ( + 'manual_total_excluded', + 'manual_total_excluded_currency', + 'manual_total_included', + 'manual_total_included_currency', + ) + for field in manual_fields: + base_line.setdefault(field, None) return base_line diff --git a/l10n_ve_accountant/models/purchase_order_line.py b/l10n_ve_accountant/models/purchase_order_line.py index 49fda1bc..71954744 100644 --- a/l10n_ve_accountant/models/purchase_order_line.py +++ b/l10n_ve_accountant/models/purchase_order_line.py @@ -17,7 +17,7 @@ def _prepare_foreign_base_line_for_taxes_computation(self): return self.env['account.tax']._prepare_foreign_base_line_for_taxes_computation( self, price_unit=self.foreign_price, - tax_ids=self.taxes_id, + tax_ids=self.tax_ids, quantity=self.product_qty, partner_id=self.order_id.partner_id, currency_id=self.order_id.foreign_currency_id, diff --git a/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.js b/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.js index a95a77c8..1688a51d 100644 --- a/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.js +++ b/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.js @@ -23,7 +23,6 @@ patch(TaxTotalsComponent.prototype, { for (let subtotal of totals.subtotals) { subtotal.formatted_base_amount_foreign_currency = formatMonetary(subtotal.base_amount_foreign_currency, foreignCurrencyFmtOpts); subtotal.formatted_base_amount_currency = formatMonetary(subtotal.base_amount_currency, currencyFmtOpts); - // Solo VEES subtotal.formatted_base_amount_currency_ves = totals.formatted_base_amount_currency_ves if (subtotal.tax_groups && Array.isArray(subtotal.tax_groups)) { diff --git a/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.xml b/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.xml index c1d4c76b..215f89dd 100644 --- a/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.xml +++ b/l10n_ve_accountant/static/src/components/tax_totals/tax_totals.xml @@ -1,4 +1,3 @@ - - + 0 and any( payment.get("account_payment_id", False) for payment in record.invoice_payments_widget.get("content", []) if payment.get("account_payment_id", False) ): - continue - if not record.invoice_payments_widget: - record.bi_igtf = 0 - continue - - payments = record.invoice_payments_widget.get("content", False) - amount = 0 + _logger.info('Tiene pagos relacionados') + advance_igtf = False + for payment in record.invoice_payments_widget.get("content", []): + move_id = payment.get('move_id') + _logger.info(f'move_id === {move_id}') + payment_id = self.env['account.move'].browse(move_id) + if not payment_id: + continue + is_igtf = payment_id.line_ids.filtered( + lambda l: l.account_id == self.env.company.customer_account_igtf_id or l.account_id == self.env.company.supplier_account_igtf_id + ) + if is_igtf: + advance_igtf = True # SI CONSIGUE LINEA IGTF EN EL PAGO + credits_for_payment[move_id] = payment_id.amount_total + _logger.warning('iterando') + bi_igtf = sum(credits_for_payment.values()) + if bi_igtf > record.amount_total and not initial_residual == 0: + bi_igtf = initial_residual + record.bi_igtf + record.bi_igtf = bi_igtf + return + if not advance_igtf: + record.bi_igtf = 0.00 + elif bi_igtf: + record.bi_igtf = bi_igtf + return if line_id: + _logger.info(f'tiene line_id === {line_id}') line = self.env["account.move.line"].browse([line_id]) + _logger.info(f'line.move_id === {line.read([])}') payment_id = line.move_id.payment_id if payment_id and payment_id.is_igtf_on_foreign_exchange: - payment_id = line.move_id.payment_id - bi_igtf = payment_id.get_bi_igtf() - if initial_residual < bi_igtf: + _logger.info(f'tiene payment_id igtf === {payment_id.id}') + # payment_id = line.move_id.payment_id + bi_igtf = payment_id.get_bi_igtf(move_id) + _logger.info(f'tiene payment_id igtf === {bi_igtf}') + if initial_residual <= bi_igtf and bi_igtf >= amount_to_pay: + record.bi_igtf = min(record.bi_igtf + bi_igtf,amount_to_pay) + _logger.warning(f'Hey 22222222222 {record.bi_igtf}') + bi_igtf = 0 + continue + elif initial_residual <= bi_igtf: record.bi_igtf = initial_residual + _logger.warning(f'Hey {record.bi_igtf}') continue - record.bi_igtf += bi_igtf - continue - - for payment in payments: - payment_id = payment.get("account_payment_id", False) - if not payment_id: + record.bi_igtf = min(record.bi_igtf + bi_igtf,record.amount_total) + _logger.warning(f'Hey 33333333333 {record.bi_igtf}') continue - - payment_id = record.env["account.payment"].browse([payment_id]) - if payment_id.is_igtf_on_foreign_exchange: - bi_igtf = payment_id.get_bi_igtf() - if initial_residual < bi_igtf: + else: + payment_id = line.move_id.payment_id + bi_igtf = initial_residual if initial_residual else record.amount_total + _logger.info(f'asignando el bi igtf === {bi_igtf}') + if initial_residual <= bi_igtf and bi_igtf >= record.amount_total: + record.bi_igtf = min(record.bi_igtf + bi_igtf, record.amount_total) + bi_igtf = 0 + continue + elif initial_residual <= bi_igtf: record.bi_igtf = initial_residual continue - amount += bi_igtf - - record.bi_igtf = amount + record.bi_igtf = min(record.bi_igtf + bi_igtf, record.amount_total) + continue + _logger.info(f'record.bi_igtf === {record.bi_igtf}') + # _logger.info(xd.xd) def remove_igtf_from_move(self, partial_id): """Remove IGTF from move @@ -102,7 +195,7 @@ def remove_igtf_from_move(self, partial_id): :param partial_id: id of the partial reconciliation to remove :type partial_id: int """ - + _logger.warning(f'Removing IGTF from move for partial {partial_id}') partial = self.env["account.partial.reconcile"].browse(partial_id) payment_credit = partial.credit_move_id.payment_id @@ -183,11 +276,23 @@ def remove_igtf_from_move(self, partial_id): def js_remove_outstanding_partial(self, partial_id): for move in self: move.remove_igtf_from_move(partial_id) + + # amount_residual = self.amount_residual + # self.recalculate_bi_igtf( + # partial_id, + # initial_residual=amount_residual + # if not self.currency_id.is_zero(amount_residual) + # else self.amount_residual, + + # ) res = super().js_remove_outstanding_partial(partial_id) return res def js_assign_outstanding_line(self, line_id): + _logger.info('entrando a l10 igtf') + # _logger.info(xd.xd) amount_residual = self.amount_residual + self = self.with_context(from_widget=True) res = super().js_assign_outstanding_line(line_id) self.recalculate_bi_igtf( line_id, @@ -214,6 +319,11 @@ def _compute_amount_residual_igtf(self): for record in self: record.amount_residual_igtf = record.amount_residual + record.amount_to_pay_igtf + if record.amount_residual and record.amount_to_pay_igtf: + record.amount_residual_igtf = record.amount_residual + record.amount_to_pay_igtf + else: + record.amount_residual_igtf = 0 + @api.depends( "bi_igtf", ) diff --git a/l10n_ve_igtf/models/account_payment.py b/l10n_ve_igtf/models/account_payment.py index bb2df00b..749cfd85 100644 --- a/l10n_ve_igtf/models/account_payment.py +++ b/l10n_ve_igtf/models/account_payment.py @@ -10,7 +10,9 @@ class AccountPaymentIgtf(models.Model): is_igtf_on_foreign_exchange = fields.Boolean( string="IGTF on Foreign Exchange?", - help="IGTF on Foreign Exchange?", + help="IGTF on Foreign Exchange", + compute="_compute_is_igtf", + store=True, ) igtf_percentage = fields.Float( @@ -31,17 +33,27 @@ class AccountPaymentIgtf(models.Model): string="Amount with IGTF", compute="_compute_amount_with_igtf", store=True ) + payment_from_wizard = fields.Boolean() + amount_residual_from_payment = fields.Float() + @api.depends("partner_id") def _compute_igtf_percentage(self): for payment in self: payment.igtf_percentage = payment.env.company.igtf_percentage - @api.depends("amount", "igtf_amount") + @api.depends("amount","igtf_amount") def _compute_amount_with_igtf(self): for payment in self: if not payment.amount_with_igtf: payment.amount_with_igtf = payment.amount + payment.igtf_amount + @api.depends("journal_id") + def _compute_is_igtf(self): + for payment in self: + payment.is_igtf_on_foreign_exchange = False + if payment.journal_id.is_igtf: + payment.is_igtf_on_foreign_exchange = True + @api.depends("amount") def _compute_igtf_amount(self): for payment in self: @@ -52,9 +64,7 @@ def _compute_igtf_amount(self): payment.igtf_percentage / 100 ) - def _prepare_move_line_default_vals( - self, write_off_line_vals=None, force_balance=None - ): + def _prepare_move_line_default_vals(self, write_off_line_vals=None, force_balance=None): """Prepare values to create a new account.move.line for a payment. this method adds the igtf in the move line values to be created depending on the payment type @@ -66,14 +76,33 @@ def _prepare_move_line_default_vals( """ vals = super(AccountPaymentIgtf, self)._prepare_move_line_default_vals( - write_off_line_vals, force_balance + write_off_line_vals, + force_balance ) - - if self.igtf_percentage: - self._create_igtf_moves_in_payments(vals) + if self.payment_from_wizard: + if self.igtf_percentage: + self._create_igtf_moves_in_payments(vals) return vals + def calculate_igtf_for_payment(self, invoice, payment_amount): + """ + Calcula IGTF solo sobre el monto que se aplica a la deuda principal + """ + currency = self.currency_id # O la moneda de la factura, si es diferente + + # ... (cálculo de principal_debt y principal_amount) ... + + principal_debt = invoice.amount_total - invoice.bi_igtf + principal_amount = min(payment_amount, principal_debt) + + igtf_unrounded = principal_amount * (self.env.company.igtf_percentage / 100) + + # APLICAR REDONDEO ODOO AL RESULTADO FINAL DEL IGTF + return currency.round(igtf_unrounded) + + #se comenta mientras se revisa nuevo flujo de gno considerando para adaptar a odoo venezuela tambien. + def _create_igtf_moves_in_payments(self, vals): """Prepare values to create a new account.move.line for a payment. this method adds the igtf in the move line values to be created depending on the payment type @@ -94,27 +123,23 @@ def _create_igtf_moves_in_payments(self, vals): return for payment in self: + move_id = ( + self.env.context.get("active_id", False) + ) + move = self.env["account.move"].browse(move_id) + if move: + payment.igtf_amount = payment.calculate_igtf_for_payment(move, payment.amount) if payment.igtf_amount and payment.is_igtf_on_foreign_exchange: if payment.payment_type == "inbound": - vals_igtf = [ - x for x in vals if x["account_id"] == igtf_account] + vals_igtf = [x for x in vals if x["account_id"] == igtf_account] if not vals_igtf: payment._prepare_inbound_move_line_igtf_vals(vals) - else: - raise UserError( - _("IGTF already exists in the move line values") - ) if payment.payment_type == "outbound": - vals_igtf = [ - x for x in vals if x["account_id"] == igtf_account] + vals_igtf = [x for x in vals if x["account_id"] == igtf_account] if not vals_igtf: payment._prepare_outbound_move_line_igtf_vals(vals) - else: - raise UserError( - _("IGTF already exists in the move line values") - ) def _create_inbound_move_line_igtf_vals(self, vals): """Create the igtf move line values for inbound payments @@ -178,43 +203,66 @@ def _create_outbound_move_line_igtf_vals(self, vals): def _prepare_inbound_move_line_igtf_vals(self, vals): """ - Prepare the igtf move line values for inbound payments - this method is called from the _prepare_move_line_default_vals method to add the igtf move line values to the vals list - and update the credit amount of the first move line to be created to be the amount of the payment minus the igtf amount - - Args: - vals (list): list of move line values + Prepare the igtf move line values for inbound payments and adjust the principal line + using Odoo's currency rounding to maintain balance. """ lines = [line for line in vals] if self.payment_type == "inbound": - credit_line = lines[1]["amount_currency"] + self.igtf_amount - credit_amount = -credit_line + currency = self.currency_id + + # 1. Calcular el monto en moneda extranjera sin redondear + # CREDIT_LINE (el monto aplicado al principal) = PAGO ORIGINAL + IGTF + # Nota: El IGTF se suma porque el 'credit' en la cuenta por cobrar es negativo en este contexto. + credit_line_unrounded = lines[1]["amount_currency"] + self.igtf_amount + + # 2. REDONDEAR el monto de la línea de la deuda principal + credit_line = currency.round(credit_line_unrounded) + + # 3. Calcular el monto en la moneda de la compañía (Moneda Base) + credit_amount = -credit_line # El débito o crédito es el negativo del amount_currency + + # Si la moneda de la compañía es VEF, aplicamos la tasa y redondeamos. if self.env.company.currency_id.id == self.env.ref("base.VEF").id: - credit_amount = -credit_line * self.foreign_rate - vals[1].update( - {"amount_currency": credit_line, "credit": credit_amount}) - + # Aplicamos la tasa de cambio y redondeamos el monto en moneda base + credit_amount = currency.round(-credit_line * self.foreign_rate) + + # 4. Actualizar la línea de la deuda principal (índice [1]) + vals[1].update({"amount_currency": credit_line, "credit": credit_amount}) + + # 5. Llamar al método para AGREGAR la línea de IGTF. + # Este método auxiliar también debe haber sido actualizado para usar el monto IGTF redondeado. self._create_inbound_move_line_igtf_vals(vals) def _prepare_outbound_move_line_igtf_vals(self, vals): """ - Prepare the igtf move line values for inbound payments - this method is called from the _prepare_move_line_default_vals method to add the igtf move line values to the vals list - and update the credit amount of the first move line to be created to be the amount of the payment minus the igtf amount - - Args: - vals (list): list of move line values + ... """ lines = [line for line in vals] if self.payment_type == "outbound": - debit_line = lines[1]["amount_currency"] - self.igtf_amount + currency = self.currency_id + + # 1. Calcular el monto en moneda extranjera que va al principal + # DEBIT_LINE = PAGO ORIGINAL - IGTF + debit_line_unrounded = lines[1]["amount_currency"] - self.igtf_amount + + # 2. REDONDEAR EL AJUSTE DEL PRINCIPAL USANDO LA DIVISA + debit_line = currency.round(debit_line_unrounded) + + # 3. La línea de IGTF debe ser calculada como el diferencial real + # Esto corrige cualquier micro-diferencia de redondeo + igtf_amount_adjusted = currency.round(self.igtf_amount) + debit_amount = debit_line if self.env.company.currency_id.id == self.env.ref("base.VEF").id: - debit_amount = debit_line * self.foreign_rate - vals[1].update( - {"amount_currency": debit_line, "debit": debit_amount}) - + # Opcional: Asegurar que el monto en VEF también se redondee después de la tasa + debit_amount = currency.round(debit_line * self.foreign_rate) + + vals[1].update({"amount_currency": debit_line, "debit": debit_amount}) + + # Llamamos a la función de creación de IGTF, usando el monto redondeado + # (Aunque internamente debería usar 'igtf_amount_adjusted', se usa self.igtf_amount + # asumiendo que ya fue redondeado en 'calculate_igtf_for_payment'). self._create_outbound_move_line_igtf_vals(vals) def action_draft(self): @@ -222,8 +270,7 @@ def action_draft(self): def get_payment_amount_invoice(self, invoice): self.ensure_one() if invoice.bi_igtf < self.amount: - payments = invoice.invoice_payments_widget.get( - "content", False) + payments = invoice.invoice_payments_widget.get("content", False) for payment in payments: payment_id = payment.get("account_payment_id", False) if not payment_id: @@ -267,10 +314,55 @@ def get_payment_amount_invoice(self, invoice): return super(AccountPaymentIgtf, self).action_draft() - def get_bi_igtf(self): - self.ensure_one() - amount_without_difference = self.amount_with_igtf - self.igtf_amount - if self.env.company.currency_id.id == self.env.ref("base.VEF").id: - amount_without_difference = amount_without_difference * self.foreign_rate - - return amount_without_difference + def get_bi_igtf(self, move_id=None): + for record in self: + amount_without_difference = record.amount_with_igtf - record.igtf_amount + if record.env.company.currency_id.id == record.env.ref("base.VEF").id: + amount_without_difference = amount_without_difference * record.foreign_rate + + amount = self.get_amount_residual_from_payment(move_id) + # amount = record.amount_residual_from_payment + + return amount + + def get_amount_residual_from_payment(self,move_id): + for record in self: + residual_amount = 0.00 + igtf_amount= record.igtf_amount + if record.reconciled_invoice_ids: + # payment_used_amount = record.get_used_payment_amount(payments) + payment_used_amount = record.get_used_payment_amount(record.reconciled_invoice_ids,move_id) + residual_amount = record.amount - payment_used_amount + else: + residual_amount = record.amount + + record.amount_residual_from_payment = residual_amount + return record.amount_residual_from_payment + + def get_used_payment_amount(self, reconciled_ids,move_id): + payment_data = [] # Lista de diccionarios con {id_factura, monto} + for invoice in reconciled_ids: + payments = invoice.invoice_payments_widget.get("content", False) + + for payment in payments: + payment_id = payment.get("account_payment_id", False) + if payment_id == self.id: + payment_amount = payment.get("amount", 0.0) + payment_data.append({ + "id": invoice.id, + "amount": payment_amount + }) + + # Sum amounts where the id is NOT in self.reconciled_invoice_ids + excluded_ids = move_id + total = sum(item["amount"] for item in payment_data if item["id"] != excluded_ids) + + return total + + @api.depends('journal_id') + def _compute_is_igtf_journal(self): + for record in self: + if record.journal_id.currency_id and record.journal_id.currency_id == self.env.ref("base.USD"): + record.is_igtf_on_foreign_exchange = True + else: + record.is_igtf_on_foreign_exchange = False diff --git a/l10n_ve_igtf/models/account_tax.py b/l10n_ve_igtf/models/account_tax.py index 9d6dfb04..8523993b 100644 --- a/l10n_ve_igtf/models/account_tax.py +++ b/l10n_ve_igtf/models/account_tax.py @@ -1,4 +1,4 @@ -from odoo import models, _ +from odoo import models, api, _ from odoo.tools.misc import formatLang from odoo.tools.float_utils import float_round, float_is_zero @@ -11,8 +11,9 @@ class AccountTax(models.Model): _inherit = "account.tax" - def _prepare_tax_totals( - self, base_lines, currency, tax_lines=None, igtf_base_amount=False, is_company_currency_requested=False + @api.model + def _get_tax_totals_summary( + self, base_lines, currency, company, cash_rounding=None ): """ This function add values and calculated of igtf on invoices @@ -34,7 +35,9 @@ def _prepare_tax_totals( - foreign_amount_total_igtf: float - formatted_foreign_amount_total_igtf: str """ - res = super()._prepare_tax_totals(base_lines, currency, tax_lines) + res = super()._get_tax_totals_summary( + base_lines, currency, company, cash_rounding + ) invoice = self.env["account.move"] order = False @@ -48,8 +51,10 @@ def _prepare_tax_totals( type_model = base_line["record"]._name if base_line["record"]._name == "account.move.line": invoice = base_line["record"].move_id + break if base_line["record"]._name == "sale.order.line": order = base_line["record"].order_id + break foreign_currency = self.env.company.foreign_currency_id rate = 0 @@ -69,22 +74,22 @@ def _prepare_tax_totals( and invoice.payment_state == "not_paid" ): is_igtf_suggested = True - base_igtf = res.get("amount_total", 0) - foreign_base_igtf = res.get("foreign_amount_total", 0) + base_igtf = res.get("foreign_amount_total", 0) + foreign_base_igtf = res.get("total_amount_foreign_currency", 0) if ( type_model == "sale.order.line" and self.env.company.show_igtf_suggested_sale_order ): is_igtf_suggested = True - base_igtf = res.get("amount_total", 0) - foreign_base_igtf = res.get("foreign_amount_total", 0) + base_igtf = res.get("foreign_amount_total", 0) + foreign_base_igtf = res.get("total_amount_foreign_currency", 0) if invoice.bi_igtf: is_igtf_suggested = False base_igtf = invoice.bi_igtf foreign_base_igtf = invoice.bi_igtf * rate - if invoice.bi_igtf == res.get("amount_total"): - foreign_base_igtf = res.get("foreign_amount_total") + if invoice.bi_igtf == res.get("total_amount_currency"): + foreign_base_igtf = res.get("total_amount_foreign_currency") igtf_base_amount = float_round( base_igtf or 0, precision_rounding=currency.rounding @@ -134,13 +139,14 @@ def _prepare_tax_totals( ) res["amount_total_igtf"] = float_round( - res["amount_total"] + igtf_amount, precision_rounding=currency.rounding + res.get("total_amount_currency", 0.0) + igtf_amount, + precision_rounding=currency.rounding, ) res["formatted_amount_total_igtf"] = formatLang( self.env, res["amount_total_igtf"], currency_obj=currency ) res["foreign_amount_total_igtf"] = float_round( - res["foreign_amount_total"] + foreign_igtf_amount, + res.get("total_amount_foreign_currency", 0.0) + foreign_igtf_amount, precision_rounding=foreign_currency.rounding, ) res["formatted_foreign_amount_total_igtf"] = formatLang( @@ -149,4 +155,3 @@ def _prepare_tax_totals( res["igtf"]["is_igtf_suggested"] = is_igtf_suggested return res - diff --git a/l10n_ve_igtf/report/invoice_free_form.xml b/l10n_ve_igtf/report/invoice_free_form.xml index c8471260..9073f52e 100644 --- a/l10n_ve_igtf/report/invoice_free_form.xml +++ b/l10n_ve_igtf/report/invoice_free_form.xml @@ -1,10 +1,12 @@ - + @@ -12,37 +14,37 @@ - + + + + + + + + + + + + + diff --git a/l10n_ve_igtf/static/src/components/tax_totals/tax_totals.xml b/l10n_ve_igtf/static/src/components/tax_totals/tax_totals.xml index 61ad347a..3ba733ce 100644 --- a/l10n_ve_igtf/static/src/components/tax_totals/tax_totals.xml +++ b/l10n_ve_igtf/static/src/components/tax_totals/tax_totals.xml @@ -2,7 +2,7 @@ diff --git a/l10n_ve_igtf/tests/__init__.py b/l10n_ve_igtf/tests/__init__.py index 3ddcf677..6064c21f 100644 --- a/l10n_ve_igtf/tests/__init__.py +++ b/l10n_ve_igtf/tests/__init__.py @@ -1,2 +1,7 @@ -from . import test_igtf -from . import igtf_common +#from . import test_igtf +#from . import igtf_common + +from . import new_igtf_common_partner +from . import test_igtf_partner +from . import new_igtf_common_providers +from . import test_igtf_providers diff --git a/l10n_ve_igtf/tests/igtf_common.py b/l10n_ve_igtf/tests/igtf_common.py index fa2e3175..dc1bdfbf 100644 --- a/l10n_ve_igtf/tests/igtf_common.py +++ b/l10n_ve_igtf/tests/igtf_common.py @@ -18,7 +18,7 @@ def setUp(self): self.company.write( { "currency_id": self.currency_usd.id, - "foreign_currency_id": self.currency_vef.id, + "currency_foreign_id": self.currency_vef.id, } ) @@ -95,12 +95,12 @@ def get_or_create(code, acc_type, name, reconcile=False): "13600", "asset_current", "Anticipo Proveedores", reconcile=True ) - self.company.write( - { - "advance_customer_account_id": self.advance_cust_acc.id, - "advance_supplier_account_id": self.advance_supp_acc.id, - } - ) + # self.company.write( + # { + # "advance_customer_account_id": self.advance_cust_acc.id, + # "advance_supplier_account_id": self.advance_supp_acc.id, + # } + # ) # -------- Método de pago manual inbound ----------------------- manual_in = self.env.ref("account.account_payment_method_manual_in") @@ -131,9 +131,16 @@ def get_or_create(code, acc_type, name, reconcile=False): "property_account_income_id": self.acc_income.id, } ) + self.tax_iva_exent = self.env['account.tax'].create({ + 'name': 'IVA exento', + 'amount': 0, + 'amount_type': 'percent', + 'type_tax_use': 'sale', + 'company_id': self.company.id, + }) self.invoice = self._create_invoice_usd(1000.0) - + # ------------------------------------------------------------------ # UTILITY: creates a customer invoice in USD for the given amount @@ -144,6 +151,7 @@ def _create_invoice_usd(self, amount): "product_id": self.product.id, "quantity": 1, "price_unit": amount, + "tax_ids": [(6, 0, [self.tax_iva_exent.id])], } ) inv = self.env["account.move"].create( @@ -157,7 +165,6 @@ def _create_invoice_usd(self, amount): "invoice_line_ids": [line], } ) - inv.action_post() return inv def _create_payment( diff --git a/l10n_ve_igtf/tests/new_igtf_common_partner.py b/l10n_ve_igtf/tests/new_igtf_common_partner.py new file mode 100644 index 00000000..4db122fe --- /dev/null +++ b/l10n_ve_igtf/tests/new_igtf_common_partner.py @@ -0,0 +1,304 @@ +from odoo.tests.common import TransactionCase +from odoo.tests.common import Form + +from odoo import fields, Command +import logging + +_logger = logging.getLogger(__name__) + +class IGTFTestCommon(TransactionCase): + + def setUp(self): + super().setUp() + self.Account = self.env["account.account"] + self.Journal = self.env["account.journal"] + self.company = self.env.ref("base.main_company") + + # 1. Configuración de Monedas + self.currency_usd = self.env.ref("base.USD") + self.currency_vef = self.env.ref("base.VEF") + + #self.company.currency_id = self.currency_vef + self.currency_usd.write({ + + 'active':True + }) + + # 💡 Establecer la tasa de cambio USD a VEF (Bolívares) al precio de HOY + self.rate = 201.47 # 1 USD = 36.50 VEF + self.currency_vef.write({ + 'rate_ids': [ + Command.create({ + 'rate': 1 / self.rate, # Tasa en Odoo: 1 / VEF por USD + 'name': fields.Date.today(), + }) + ], + 'active':True + }) + self.company.write( + { + "currency_id": self.currency_usd.id, + "currency_foreign_id": self.currency_vef.id, + } + ) + + # 2. Funciones Auxiliares (get_or_create_account) + def get_or_create_account(code, ttype, name, recon=False): + """Busca o crea una cuenta y asegura las propiedades requeridas. (Lógica corregida)""" + + account_record = self.Account.search( + [("code", "=", code), ("company_id", "=", self.company.id)], limit=1 + ) + + values = { + "name": name, + "code": code, + "account_type": ttype, + "reconcile": recon, + "company_id": self.company.id, + } + + # 📢 CORRECCIÓN: Si la cuenta existe, la retorna; sino, la crea. + if not account_record: + account_record = self.Account.create(values) + else: + account_record.write(values) # Asegura que las propiedades sean las correctas + + return account_record + + # 💡 Hacer la función auxiliar accesible en toda la clase + self.get_or_create_account = get_or_create_account + + # 3. Creación de Cuentas Necesarias + self.acc_receivable = self.get_or_create_account( + "1101", "asset_receivable", "Cuentas por Cobrar (Clientes)", recon=True + ) + self.acc_payable = self.get_or_create_account( + "2101", "liability_payable", "Cuentas por Pagar (Proveedores)", recon=True + ) + self.acc_income = self.get_or_create_account("4001", "income", "Ingresos") + + # Cuenta de IGTF (Gasto/Impuesto) + self.acc_igtf_cli = self.get_or_create_account("236IGTF", "expense", "IGTF Clientes") + + # Cuenta de Banco/Caja que usará el diario + # 📢 CORRECCIÓN DE NOMBRE: Usar self.account_bank para consistencia en la clase + self.account_bank = self.get_or_create_account("1001", "asset_cash", "Cuenta de Banco USD") + + self.advance_cust_acc = self.get_or_create_account( + "21600", "liability_current", "Anticipo Clientes", recon=True + ) + self.advance_supp_acc = self.get_or_create_account( + "13600", "asset_current", "Anticipo Proveedores", recon=True + ) + + # 4. Configuración de la Compañía (IGTF y Anticipos) + self.company.write( + { + # Configuración de IGTF + "igtf_percentage": 3.0, + "customer_account_igtf_id": self.acc_igtf_cli.id, + + } + ) + + # 6. Método de pago (MOVIDO ARRIBA DE LA SECCIÓN 5) + manual_in = self.env.ref("account.account_payment_method_manual_in") + manual_out = self.env.ref("account.account_payment_method_manual_out") + + # Creamos las líneas de método de pago. El journal_id es referencial. + self.pm_line_in_usd = self.env["account.payment.method.line"].create( + { + "name": "Manual Inbound USD", + # 📢 USAR self.account_bank + "payment_method_id": manual_in.id, + "payment_type": "inbound", + "payment_account_id": self.account_bank.id, + } + ) + + self.pm_line_out_usd = self.env["account.payment.method.line"].create( + { + "name": "Manual Outbound USD", + "payment_method_id": manual_out.id, + "payment_type": "outbound", + "payment_account_id": self.account_bank.id, + } + ) + + + # 📢 ADICIÓN: Líneas de método VEF + self.pm_line_in_vef = self.env["account.payment.method.line"].create( + { + "name": "Manual Inbound VEF", + "payment_method_id": manual_in.id, + "payment_type": "inbound", + "payment_account_id": self.account_bank.id, + } + ) + + + + # 5. Configuración del Diario (IGTF) (AHORA PUEDE REFERENCIAR LAS LÍNEAS) + self.bank_journal_usd = self.Journal.create( + { + "name": "Banco USD IGTF", + "code": "BNKUS", + "type": "bank", + "currency_id": self.currency_usd.id, + "company_id": self.company.id, + "is_igtf": True, + # 📢 USAR self.account_bank + "default_account_id": self.account_bank.id, + "inbound_payment_method_line_ids": [(6, 0, self.pm_line_in_usd.ids)], + "outbound_payment_method_line_ids": [(6, 0, self.pm_line_out_usd.ids)], + + } + ) + + # 📢 AJUSTE NECESARIO: Asignar el journal_id a las líneas de método creadas + # Esto es necesario para que las líneas de método estén correctamente asociadas. + self.pm_line_in_usd.journal_id = self.bank_journal_usd.id + self.pm_line_out_usd.journal_id = self.bank_journal_usd.id + + self.bank_journal_bs = self.Journal.create( + { + "name": "Banco VEF (Local)", + "code": "BVESL", + "type": "bank", + "company_id": self.company.id, + "currency_id": self.currency_vef.id, # Moneda Local VEF + "is_igtf": False, # Sin IGTF + "default_account_id": self.account_bank.id, + "inbound_payment_method_line_ids": [(6, 0, self.pm_line_in_vef.ids)], + } + ) + self.pm_line_in_vef.journal_id = self.bank_journal_bs.id + + # 7. Partner, Producto y Tax + self.partner = self.env["res.partner"].create( + {"name": "Cliente IGTF", "vat": "J123","property_account_receivable_id": self.acc_receivable.id, + "property_account_payable_id": self.acc_payable.id,} + ) + + self.tax_iva_exent = self.env['account.tax'].create({ + 'name': 'IVA exento', 'amount': 0, 'amount_type': 'percent', + 'type_tax_use': 'sale', 'company_id': self.company.id, + }) + + self.product = self.env["product.product"].create( + { + "name": "Servicio", + "list_price": 100, + "property_account_income_id": self.acc_income.id, + "taxes_id": [(6, 0, [self.tax_iva_exent.id])], + + } + ) + + # 8. Creación de la Factura de inicio + self.invoice = self._create_invoice_usd(1000.0) + + # UTILITY: creates a customer invoice in USD + def _create_invoice_usd(self, amount): + line = Command.create( + { + "product_id": self.product.id, + "quantity": 1, + "price_unit": amount, + "tax_ids": [(6, 0, [self.tax_iva_exent.id])], + "account_id": self.acc_income.id, + } + ) + + sale_journal = self.Journal.search([("type", "=", "sale")], limit=1) + if not sale_journal: + sale_journal = self.Journal.create({ + 'name': 'Diario Venta', 'type': 'sale', 'code': 'SALE', + 'company_id': self.company.id, 'currency_id': self.currency_usd.id, + }) + + inv = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.partner.id, + "currency_id": self.currency_usd.id, + "journal_id": sale_journal.id, + "invoice_line_ids": [line], + "invoice_date": fields.Date.today() + + } + ) + inv.action_post() + return inv + + # UTILITY: creates a payment (simplificado para el uso en el test) + def _create_payment( + self, amount, *, currency=None, journal=None, is_igtf_on_foreign_exchange=False, + fx_rate=None, fx_rate_inv=None, pm_line=None, is_advance_payment=False, + ): + # Simplificado para fines de la prueba unitaria + vals = { + "payment_type": "inbound", + "partner_type": "customer", + "partner_id": self.partner.id, + "amount": amount, + "currency_id": (currency or self.currency_usd).id, + "journal_id": (journal or self.bank_journal_usd).id, + "payment_method_line_id": (pm_line or self.pm_line_in_usd).id, + "is_igtf_on_foreign_exchange": is_igtf_on_foreign_exchange, + "date": fields.Date.today(), + } + + pay = self.env["account.payment"].create(vals) + pay.action_post() + return pay + + def _create_invoice_rate(self, amount, date=None): # 💡 ACEPTA FECHA + sale_journal = self.Journal.search([("type", "=", "sale")], limit=1) + if not sale_journal: + sale_journal = self.Journal.create({ + 'name': 'Diario Venta', 'type': 'sale', 'code': 'SALE', + 'company_id': self.company.id, 'currency_id': self.currency_usd.id, + }) + + + + + # 1. 📢 PRIMER PASO: CREAR Y GUARDAR ENCABEZADO (Simula guardar el borrador) + with Form(self.env["account.move"].with_context(default_move_type='out_invoice')) as inv_form: + #inv_form.move_type = "out_invoice" + inv_form.partner_id = self.partner + #inv_form.currency_id = self.currency_usd + inv_form.journal_id = sale_journal + # Configuramos ambas fechas para asegurar el uso de la tasa correcta + #inv_form.date = date or fields.Date.today() + inv_form.invoice_date = date or fields.Date.today() + + # Guarda el encabezado (Sale del primer Form context) + inv = inv_form.save() + expected_foreign_rate = self.rate # Tasa directa: 36.50 VEF por 1 USD + expected_foreign_inverse_rate = 1.0 / self.rate # Tasa inversa: 1 / 36.50 + + inv.write({ + 'foreign_rate': expected_foreign_rate, + 'foreign_inverse_rate': expected_foreign_inverse_rate, + }) + + + + # 2. 📢 SEGUNDO PASO: ABRIR LA FACTURA GUARDADA, AGREGAR LÍNEAS Y GUARDAR + with Form(inv) as inv_form_edit: + with inv_form_edit.invoice_line_ids.new() as line: + line.product_id = self.product + line.quantity = 1 + line.price_unit = amount + #line.tax_ids.add(self.tax_iva_exent) + # Opcional, forzar la cuenta de ingresos: + #line.account_id = self.acc_income + + # Guarda las líneas + inv = inv_form_edit.save() + + + return inv diff --git a/l10n_ve_igtf/tests/new_igtf_common_providers.py b/l10n_ve_igtf/tests/new_igtf_common_providers.py new file mode 100644 index 00000000..93bacafc --- /dev/null +++ b/l10n_ve_igtf/tests/new_igtf_common_providers.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase +from odoo.tests.common import Form + +from odoo import fields, Command +import logging + +_logger = logging.getLogger(__name__) + +class IGTFTestCommon(TransactionCase): + + def setUp(self): + super().setUp() + self.Account = self.env["account.account"] + self.Journal = self.env["account.journal"] + self.company = self.env.ref("base.main_company") + + # 1. Configuración de Monedas + self.currency_usd = self.env.ref("base.USD") + self.currency_vef = self.env.ref("base.VEF") + + # Configuración de la tasa de cambio + self.rate = 201.47 # 1 USD = 201.47 VEF + self.currency_vef.write({ + 'rate_ids': [ + Command.create({ + 'rate': 1 / self.rate, # Tasa en Odoo: 1 / VEF por USD + 'name': fields.Date.today(), + }) + ], + 'active':True + }) + self.company.write( + { + "currency_id": self.currency_usd.id, + "currency_foreign_id": self.currency_vef.id, + } + ) + + # 2. Funciones Auxiliares (get_or_create_account) + def get_or_create_account(code, ttype, name, recon=False): + """Busca o crea una cuenta y asegura las propiedades requeridas.""" + account_record = self.Account.search( + [("code", "=", code), ("company_id", "=", self.company.id)], limit=1 + ) + values = { + "name": name, + "code": code, + "account_type": ttype, + "reconcile": recon, + "company_id": self.company.id, + } + + if not account_record: + account_record = self.Account.create(values) + else: + account_record.write(values) + + return account_record + + self.get_or_create_account = get_or_create_account + + # 3. Creación de Cuentas Necesarias (AJUSTADO PARA PROVEEDORES) + self.acc_receivable = self.get_or_create_account( + "1101", "asset_receivable", "Cuentas por Cobrar (Clientes)", recon=True + ) + self.acc_payable = self.get_or_create_account( + "2101", "liability_payable", "Cuentas por Pagar (Proveedores)", recon=True + ) + self.acc_expense = self.get_or_create_account("5001", "asset_current", "Costo de Mercancía/Gasto") + + # Cuenta de IGTF (Gasto/Impuesto - Diferente para Proveedores) + # Usamos una cuenta de IGTF Gasto/Retención para pagos a proveedores + self.acc_igtf_cli = self.get_or_create_account("523IGTF", "expense", "IGTF Proveedores (Gasto)") + + # Cuenta de Banco/Caja que usará el diario + self.account_bank = self.get_or_create_account("1001", "asset_cash", "Cuenta de Banco USD") + + self.advance_cust_acc = self.get_or_create_account( + "21600", "liability_current", "Anticipo Clientes", recon=True + ) + self.advance_supp_acc = self.get_or_create_account( + "13600", "asset_current", "Anticipo Proveedores", recon=True + ) + + # 4. Configuración de la Compañía (IGTF y Anticipos) + self.company.write( + { + # Configuración de IGTF (AJUSTADO PARA PROVEEDORES) + "igtf_percentage": 3.0, + "supplier_account_igtf_id": self.acc_igtf_cli.id, # Usar la cuenta para proveedores + + } + ) + + # 5. Métodos de pago + manual_in = self.env.ref("account.account_payment_method_manual_in") + manual_out = self.env.ref("account.account_payment_method_manual_out") + + # Líneas de método USD (PRIORIZANDO OUTBOUND) + self.pm_line_in_usd = self.env["account.payment.method.line"].create( + { + "name": "Manual Inbound USD", + "payment_method_id": manual_in.id, + "payment_type": "inbound", + "payment_account_id": self.account_bank.id, + } + ) + + self.pm_line_out_usd = self.env["account.payment.method.line"].create( + { + "name": "Manual Outbound USD", + "payment_method_id": manual_out.id, + "payment_type": "outbound", + "payment_account_id": self.account_bank.id, + } + ) + + # Líneas de método VEF (PRIORIZANDO OUTBOUND) + self.pm_line_out_vef = self.env["account.payment.method.line"].create( + { + "name": "Manual Outbound VEF", + "payment_method_id": manual_out.id, + "payment_type": "outbound", + "payment_account_id": self.account_bank.id, + } + ) + + # 6. Configuración de Diarios (AJUSTADO PARA PROVEEDORES) + self.bank_journal_usd = self.Journal.create( + { + "name": "Banco USD IGTF", + "code": "BNKUS", + "type": "bank", + "currency_id": self.currency_usd.id, + "company_id": self.company.id, + "is_igtf": True, # IGTF aplica en este diario + "default_account_id": self.account_bank.id, + "inbound_payment_method_line_ids": [(6, 0, self.pm_line_in_usd.ids)], + "outbound_payment_method_line_ids": [(6, 0, self.pm_line_out_usd.ids)], # OUTBOUND + + } + ) + self.pm_line_in_usd.journal_id = self.bank_journal_usd.id + self.pm_line_out_usd.journal_id = self.bank_journal_usd.id + + self.bank_journal_bs = self.Journal.create( + { + "name": "Banco VEF (Local)", + "code": "BVESL", + "type": "bank", + "company_id": self.company.id, + "currency_id": self.currency_vef.id, + "is_igtf": False, + "default_account_id": self.account_bank.id, + "outbound_payment_method_line_ids": [(6, 0, self.pm_line_out_vef.ids)], # SOLO OUTBOUND + } + ) + self.pm_line_out_vef.journal_id = self.bank_journal_bs.id + + # 7. Partner, Producto y Tax (AJUSTADO PARA PROVEEDOR) + self.partner = self.env["res.partner"].create( + {"name": "Proveedor IGTF", + "vat": "J123", + "property_account_receivable_id": self.acc_receivable.id, + "property_account_payable_id": self.acc_payable.id, # Cuentas por Pagar + "supplier_rank": 1, # Asegurar que es un proveedor + "customer_rank": 0, + } + ) + + self.tax_iva_exent = self.env['account.tax'].create({ + 'name': 'IVA exento', 'amount': 0, 'amount_type': 'percent', + 'type_tax_use': 'purchase', # Usar para compra + 'company_id': self.company.id, + }) + + self.product = self.env["product.product"].create( + { + "name": "Servicio", + "list_price": 100, + "purchase_ok": True, # Asegurar que es para compra + "property_account_expense_id": self.acc_expense.id, # Usar cuenta de Gasto + "supplier_taxes_id": [(6, 0, [self.tax_iva_exent.id])], + + } + ) + + # 8. Creación de la Factura de proveedor (Ajuste en la utilidad) + # self.invoice = self._create_bill_usd(1000.0) # Usaremos _create_bill_rate + + + + def _create_invoice_usd(self, amount): + line = Command.create( + { + "product_id": self.product.id, + "quantity": 1, + "price_unit": amount, + "tax_ids": [(6, 0, [self.tax_iva_exent.id])], + + "account_id": self.acc_expense.id, + } + ) + + purchase_journal = self.Journal.search([("type", "=", "purchase")], limit=1) + if not purchase_journal: + purchase_journal = self.Journal.create({ + 'name': 'Diario Compra', 'type': 'purchase', 'code': 'PURC', + 'company_id': self.company.id, 'currency_id': self.currency_usd.id, + }) + + inv = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": self.partner.id, + "currency_id": self.currency_usd.id, + "journal_id": purchase_journal.id, + "invoice_line_ids": [line], + "invoice_date": fields.Date.today() + + } + ) + inv.action_post() + return inv + + # UTILITY: creates a payment (simplificado para el uso en el test) + def _create_payment( + self, amount, *, currency=None, journal=None, is_igtf_on_foreign_exchange=False, + fx_rate=None, fx_rate_inv=None, pm_line=None, is_advance_payment=False, + ): + # Ajuste para PAGO SALIENTE (OUTBOUND) + vals = { + "payment_type": "outbound", + "partner_type": "supplier", # Proveedor + "partner_id": self.partner.id, + "amount": amount, + "currency_id": (currency or self.currency_usd).id, + "journal_id": (journal or self.bank_journal_usd).id, + "payment_method_line_id": (pm_line or self.pm_line_out_usd).id, + "is_igtf_on_foreign_exchange": is_igtf_on_foreign_exchange, + "date": fields.Date.today(), + } + + pay = self.env["account.payment"].create(vals) + pay.action_post() + return pay + + def _create_invoice_rate(self, amount, date=None): # 💡 ACEPTA FECHA + purchase_journal = self.Journal.search([("type", "=", "purchase")], limit=1) + if not purchase_journal: + purchase_journal = self.Journal.create({ + 'name': 'Diario Compra', 'type': 'purchase', 'code': 'PURC', + 'company_id': self.company.id, 'currency_id': self.currency_usd.id, + }) + + + + # 1. 📢 PRIMER PASO: CREAR Y GUARDAR ENCABEZADO (Simula guardar el borrador) + with Form(self.env["account.move"].with_context(default_move_type='in_invoice')) as inv_form: + #inv_form.move_type = "out_invoice" + inv_form.correlative = "12345698741256" + inv_form.partner_id = self.partner + #inv_form.currency_id = self.currency_usd + inv_form.journal_id = purchase_journal + # Configuramos ambas fechas para asegurar el uso de la tasa correcta + #inv_form.date = date or fields.Date.today() + inv_form.invoice_date = date or fields.Date.today() + + # Guarda el encabezado (Sale del primer Form context) + inv = inv_form.save() + expected_foreign_rate = self.rate # Tasa directa: 36.50 VEF por 1 USD + expected_foreign_inverse_rate = 1.0 / self.rate # Tasa inversa: 1 / 36.50 + + inv.write({ + 'foreign_rate': expected_foreign_rate, + 'foreign_inverse_rate': expected_foreign_inverse_rate, + }) + + + + # 2. 📢 SEGUNDO PASO: ABRIR LA FACTURA GUARDADA, AGREGAR LÍNEAS Y GUARDAR + with Form(inv) as inv_form_edit: + with inv_form_edit.invoice_line_ids.new() as line: + line.product_id = self.product + line.quantity = 1 + line.price_unit = amount + #line.tax_ids.add(self.tax_iva_exent) + # Opcional, forzar la cuenta de ingresos: + #line.account_id = self.acc_income + + # Guarda las líneas + inv = inv_form_edit.save() + + + return inv diff --git a/l10n_ve_igtf/tests/test_igtf.py b/l10n_ve_igtf/tests/test_igtf.py index d02aeebb..8d2a9ae4 100644 --- a/l10n_ve_igtf/tests/test_igtf.py +++ b/l10n_ve_igtf/tests/test_igtf.py @@ -19,6 +19,8 @@ class TestIGTFBasic(IGTFTestCommon): def test01_basic_igtf_flow(self): invoice = self._create_invoice_usd(1000) + invoice.with_context(move_action_post_alert=True).action_post() + ig_tf = round(invoice.amount_total * self.company.igtf_percentage / 100, 2) pay1 = self._create_payment(amount=invoice.amount_total, is_igtf_on_foreign_exchange=True) @@ -26,11 +28,7 @@ def test01_basic_igtf_flow(self): line_to_match = pay1.move_id.line_ids.filtered( lambda l: l.account_id.account_type == "asset_receivable" ) - _logger.warning("line_to_match %s", line_to_match) - _logger.warning("state of line %s", line_to_match.parent_state) - _logger.warning("pay1 %s", pay1.state) invoice.js_assign_outstanding_line(line_to_match.id) - self.assertAlmostEqual(invoice.amount_residual, ig_tf, 2) usd_to_bsf = 35 @@ -57,6 +55,7 @@ def test_02_igtf_decimal_amount(self): # 1) Factura con decimales # ────────────────────────────── invoice = self._create_invoice_usd(1234.56) + invoice.with_context(move_action_post_alert=True).action_post() pct = self.company.igtf_percentage # 3 % ig_tf = round(invoice.amount_total * pct / 100, 2) # 37.04 USD @@ -178,6 +177,7 @@ def test_03_igtf_zero_amount(self): """ invoice = self._create_invoice_usd(500) + invoice.with_context(move_action_post_alert=True).action_post() pay_zero = self._create_payment(amount=0.0, is_igtf_on_foreign_exchange=True) self.assertEqual(pay_zero.igtf_amount, 0.0) @@ -230,6 +230,7 @@ def test_04_igtf_negative_amount(self): def test_05_multiple_partial_igtf_payments(self): """La factura se liquida con dos pagos parciales que incluyen IGTF.""" invoice = self._create_invoice_usd(1000.00) + invoice.with_context(move_action_post_alert=True).action_post() pct = self.company.igtf_percentage rate_factor = 1 - pct / 100 @@ -294,6 +295,7 @@ def test_06_remove_partial_igtf_conciliation(self): (bi_igtf) quede en cero. """ invoice = self._create_invoice_usd(1000) + invoice.with_context(move_action_post_alert=True).action_post() pay = self._create_payment(amount=500, is_igtf_on_foreign_exchange=True) @@ -338,13 +340,14 @@ def test_07_cancel_igtf_payment(self): """ invoice = self._create_invoice_usd(1000) + invoice.with_context(move_action_post_alert=True).action_post() + pay = self._create_payment(amount=1000, is_igtf_on_foreign_exchange=True) pay_line = pay.move_id.line_ids.filtered( lambda l: l.account_id.account_type == "asset_receivable" - ) + ) invoice.js_assign_outstanding_line(pay_line.id) - ig_tf = round(invoice.amount_total * self.company.igtf_percentage / 100, 2) self.assertAlmostEqual(invoice.amount_residual, ig_tf, 2) @@ -375,6 +378,7 @@ def test_07_cancel_igtf_payment(self): def test_08_two_usd_payments(self): """La factura recibe dos pagos en USD con IGTF.""" invoice = self._create_invoice_usd(1000.0) + invoice.with_context(move_action_post_alert=True).action_post() pct = self.company.igtf_percentage rate_factor = 1 - pct / 100 diff --git a/l10n_ve_igtf/tests/test_igtf_partner.py b/l10n_ve_igtf/tests/test_igtf_partner.py new file mode 100644 index 00000000..7bdf4862 --- /dev/null +++ b/l10n_ve_igtf/tests/test_igtf_partner.py @@ -0,0 +1,1106 @@ +import logging +from odoo.tests.common import Form +from odoo.tests import tagged +from odoo.exceptions import UserError, ValidationError +from odoo import Command, fields + + +from .new_igtf_common_partner import IGTFTestCommon + + +_logger = logging.getLogger(__name__) + + +@tagged("igtf", "igtf_run", "-at_install", "post_install") +class TestIGTFNEW(IGTFTestCommon): + + def _assert_move_lines_equal(self, move, expected_lines): + """ + Valida que el asiento contable tenga el número de líneas esperado y que + los valores de Débito, Crédito y Cuenta coincidan para cada línea. + """ + self.assertEqual(len(move.line_ids), len(expected_lines), + f"El asiento debe tener {len(expected_lines)} líneas, pero tiene {len(move.line_ids)}.") + + for expected_line in expected_lines: + expected_account = expected_line['account'] + expected_debit = expected_line['debit'] + expected_credit = expected_line['credit'] + + expected_foreign_debit = expected_line.get('foreign_debit', 0.0) + expected_foreign_credit = expected_line.get('foreign_credit', 0.0) + + + found_line = move.line_ids.filtered(lambda l: l.account_id.id == expected_account.id) + + + if not found_line: + _logger.error( + f"FALLA DE LÍNEA: Cuenta esperada NO encontrada: " + f"'{expected_account.code}' - '{expected_account.name}'. " + f"Líneas reales en el asiento: {[(l.account_id.code, l.account_id.name, l.debit, l.credit) for l in move.line_ids]}" + ) + else: + _logger.info( + f"LÍNEA ENCONTRADA: Cuenta '{found_line.account_id.code}' - '{found_line.account_id.name}'. " + f"Débito Real: {found_line.debit}, Crédito Real: {found_line.credit}" + ) + + + self.assertTrue(found_line, + f"Línea contable para la cuenta '{expected_account.code}' ({expected_account.name}) no encontrada.") + + + self.assertAlmostEqual(found_line.debit, expected_debit, 2, + f"Débito de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_debit}, Real: {found_line.debit}") + + + self.assertAlmostEqual(found_line.credit, expected_credit, 2, + f"Crédito de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_credit}, Real: {found_line.credit}") + + if expected_foreign_debit == 0.0 and expected_foreign_credit == 0.0: + continue + + self.assertAlmostEqual(found_line.foreign_debit, expected_foreign_debit, 2, + f"Débito foraneo de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_foreign_debit}, Real: {found_line.foreign_debit}") + + self.assertAlmostEqual(found_line.foreign_credit, expected_foreign_credit, 2, + f"Crédito foraneo de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_foreign_credit}, Real: {found_line.foreign_credit}") + + + total_debit = sum(line.debit for line in move.line_ids) + total_credit = sum(line.credit for line in move.line_ids) + + self.assertAlmostEqual(total_debit, total_credit, 2, + "El asiento no balancea (Débito != Crédito).") + + _logger.info("Validación detallada de líneas contables: OK.") + + def test01_payment_from_invoice_with_igtf_journal(self): + _logger.info("Iniciando test: test01_payment_from_invoice_with_igtf_journal") + + invoice_amount = 2681.20 + payment_amount = 2000.00 + + + invoice = self._create_invoice_usd(invoice_amount) + invoice.with_context(move_action_post_alert=True).action_post() + + + + pct = self.company.igtf_percentage + expected_igtf = round(payment_amount * pct / 100, 2) + cxc_credit_amount = payment_amount - expected_igtf + + expected_residual = invoice_amount - payment_amount + expected_igtf + + payment_register_wiz = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz.write({ + 'amount': payment_amount, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action = payment_register_wiz.action_create_payments() + payment = self.env['account.payment'].browse(action.get('res_id')) + payment_move = payment.move_id + + + self.assertTrue(payment_move, "Debe haberse creado el asiento de pago asociado al payment.") + self.assertAlmostEqual(payment.igtf_amount, expected_igtf, 2, "El IGTF calculado debe ser $60.00.") + + expected_lines = [ + { + 'account': self.account_bank, + 'debit': payment_amount, + 'credit': 0.0, + }, + + { + 'account': self.acc_receivable, + 'debit': 0.0, + 'credit': cxc_credit_amount, + }, + { + 'account': self.acc_igtf_cli, + 'debit': 0.0, + 'credit': expected_igtf, + }, + ] + + self._assert_move_lines_equal(payment_move, expected_lines) + + + + self.assertEqual( + invoice.payment_state, + 'partial', + f"La factura debe estar en estado 'partial' (parcialmente pagada), estado actual: {invoice.payment_state}" + ) + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual, + 2, + f"El monto residual de la factura debe ser ${expected_residual}, pero es ${invoice.amount_residual}" + ) + + _logger.info("test01_payment_from_invoice_with_igtf_journal superado.") + + + def test02_payment_from_invoice_with_igtf_journal(self): + _logger.info("Iniciando test: test02_payment_from_invoice_with_igtf_journal") + + invoice_amount = 2681.20 + payment_amount = 2000.00 + + + invoice = self._create_invoice_usd(invoice_amount) + invoice.with_context(move_action_post_alert=True).action_post() + + + + pct = self.company.igtf_percentage + expected_igtf = round(payment_amount * pct / 100, 2) + cxc_credit_amount = payment_amount - expected_igtf + + expected_residual = invoice_amount - payment_amount + expected_igtf + + amount_to_pay_2 = invoice_amount - cxc_credit_amount + + + payment_register_wiz = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz.write({ + 'amount': payment_amount, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action = payment_register_wiz.action_create_payments() + payment = self.env['account.payment'].browse(action.get('res_id')) + payment_move = payment.move_id + + + self.assertTrue(payment_move, "Debe haberse creado el asiento de pago asociado al payment.") + self.assertAlmostEqual(payment.igtf_amount, expected_igtf, 2, "El IGTF calculado debe ser $60.00.") + expected_lines = [ + { + 'account': self.account_bank, + 'debit': payment_amount, + 'credit': 0.0, + }, + + { + 'account': self.acc_receivable, + 'debit': 0.0, + 'credit': cxc_credit_amount, + }, + { + 'account': self.acc_igtf_cli, + 'debit': 0.0, + 'credit': expected_igtf, + }, + ] + + self._assert_move_lines_equal(payment_move, expected_lines) + self.assertEqual( + invoice.payment_state, + 'partial', + f"La factura debe estar en estado 'partial' (parcialmente pagada), estado actual: {invoice.payment_state}" + ) + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual, + 2, + f"El monto residual de la factura debe ser ${expected_residual}, pero es ${invoice.amount_residual}" + ) + + _logger.info("--- PRIMER PAGO SUPERADO. ---") + _logger.info("--- SEGUNDO PAGO . ---") + + payment_amount_2 = amount_to_pay_2 / (1 - pct / 100) + payment_amount_2 = round(payment_amount_2, 2) + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + cxc_credit_amount_2 = payment_amount_2 - expected_igtf_2 + + cxc_credit_amount_2 = invoice.amount_residual + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + + + payment_register_wiz_2 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_2.write({ + 'amount': payment_amount_2, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_2 = payment_register_wiz_2.action_create_payments() + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + self.assertTrue(payment_move_2, "Debe haberse creado el asiento de pago 2.") + self.assertAlmostEqual(payment_2.igtf_amount, expected_igtf_2, 2, "El IGTF calculado del pago 2 debe ser correcto.") + + + expected_lines_2 = [ + { + 'account': self.account_bank, + 'debit': payment_amount_2, + 'credit': 0.0, + }, + + { + 'account': self.acc_receivable, + 'debit': 0.0, + 'credit': cxc_credit_amount_2, + }, + { + 'account': self.acc_igtf_cli, + 'debit': 0.0, + 'credit': expected_igtf_2, + }, + ] + + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + _logger.info("--- SEGUNDO PAGO SUPERADO. ---") + + self.assertEqual( + invoice.payment_state, + 'paid', + f"La factura debe estar en estado 'paid' o 'in_payment', estado actual: {invoice.payment_state}" + ) + + self.assertAlmostEqual( + invoice.amount_residual, + 0.0, + 2, + f"El monto residual final de la factura debe ser $0.00, pero es ${invoice.amount_residual}" + ) + + _logger.info("test01_payment_from_invoice_with_igtf_journal completamente superado (pago parcial + pago final).") + + + def test03_payment_from_invoice_with_igtf_journal(self): + _logger.info("Iniciando test: test03_payment_from_invoice_with_igtf_journal - Flujo de Desconciliación") + + invoice_amount = 2681.20 + payment_amount_1 = 2000.00 + + invoice = self._create_invoice_usd(invoice_amount) + invoice.with_context(move_action_post_alert=True).action_post() + + pct = self.company.igtf_percentage + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 + + expected_residual_1 = invoice_amount - cxc_credit_amount_1 + amount_to_pay_2 = expected_residual_1 + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + + + self.assertEqual(invoice.payment_state, 'partial', "Estado incorrecto después del pago 1.") + self.assertAlmostEqual(invoice.amount_residual, expected_residual_1, 2, "Residual incorrecto después del pago 1.") + _logger.info("--- PRIMER PAGO SUPERADO. Estado: partial. ---") + + payment_amount_2 = amount_to_pay_2 / (1 - pct / 100) + payment_amount_2 = round(payment_amount_2, 2) + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + cxc_credit_amount_2 = amount_to_pay_2 + + + payment_register_wiz_2 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_2.write({ + 'amount': payment_amount_2, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_2 = payment_register_wiz_2.action_create_payments() + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + expected_lines_2 = [ + {'account': self.account_bank, 'debit': payment_amount_2, 'credit': 0.0}, + {'account': self.acc_receivable, 'debit': 0.0, 'credit': cxc_credit_amount_2}, + {'account': self.acc_igtf_cli, 'debit': 0.0, 'credit': expected_igtf_2}, + ] + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + + self.assertIn(invoice.payment_state, ('paid', 'in_payment'), "La factura no está pagada antes de desconciliar.") + self.assertAlmostEqual(invoice.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + _logger.info("--- SEGUNDO PAGO Y CONCILIACIÓN INICIAL SUPERADO. Estado: paid. ---") + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_2_receivable_line = payment_move_2.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.credit > 0 + ) + _logger.info(f"Línea CxC del Pago 2 encontrada: {payment_2_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_2_receivable_line), "No se encontró la línea CxC del pago 2.") + + partial_reconcile = payment_2_receivable_line.matched_debit_ids.filtered( + lambda p: p.debit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 2.") + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 2 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + self.assertEqual( + invoice.payment_state, + 'partial', + f"Tras la desconciliación, el estado debe volver a 'partial', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual_1, + 2, + f"Tras la desconciliación, el residual debe ser ${expected_residual_1}, pero es ${invoice.amount_residual}" + ) + + _logger.info(f"Estado post-desconciliación: {invoice.payment_state}, Residual post-desconciliación: {invoice.amount_residual},esperado: {expected_residual_1} ") + + _logger.info("test03_payment_from_invoice_with_igtf_journal (Flujo Desconciliación) superado.") + + def test04_payment_from_invoice_with_igtf_journal_currency_usd(self): + _logger.info("Iniciando test: test04_payment_from_invoice_with_igtf_journal_currency_usd - Flujo de Desconciliación ") + invoice_amount = 2681.20 + payment_amount_1 = 2000.00 + + invoice = self._create_invoice_usd(invoice_amount) + invoice.with_context(move_action_post_alert=True).action_post() + + pct = self.company.igtf_percentage + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 + expected_residual_1 = invoice_amount - cxc_credit_amount_1 + amount_to_pay_2 = expected_residual_1 + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + + + self.assertEqual(invoice.payment_state, 'partial', "Estado incorrecto después del pago 1.") + self.assertAlmostEqual(invoice.amount_residual, expected_residual_1, 2, "Residual incorrecto después del pago 1.") + _logger.info("--- PRIMER PAGO SUPERADO. Estado: partial. ---") + payment_amount_2 = amount_to_pay_2 / (1 - pct / 100) + payment_amount_2 = round(payment_amount_2, 2) + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + cxc_credit_amount_2 = amount_to_pay_2 + + + payment_register_wiz_2 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_2.write({ + 'amount': payment_amount_2, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_2 = payment_register_wiz_2.action_create_payments() + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + expected_lines_2 = [ + {'account': self.account_bank, 'debit': payment_amount_2, 'credit': 0.0}, + {'account': self.acc_receivable, 'debit': 0.0, 'credit': cxc_credit_amount_2}, + {'account': self.acc_igtf_cli, 'debit': 0.0, 'credit': expected_igtf_2}, + ] + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + + self.assertIn(invoice.payment_state, ('paid', 'in_payment'), "La factura no está pagada antes de desconciliar.") + self.assertAlmostEqual(invoice.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + _logger.info("--- SEGUNDO PAGO Y CONCILIACIÓN INICIAL SUPERADO. Estado: paid. ---") + + + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_2_receivable_line = payment_move_2.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.credit > 0 + ) + _logger.info(f"Línea CxC del Pago 2 encontrada: {payment_2_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_2_receivable_line), "No se encontró la línea CxC del pago 2.") + + + + partial_reconcile = payment_2_receivable_line.matched_debit_ids.filtered( + lambda p: p.debit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 2.") + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 2 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + + self.assertEqual( + invoice.payment_state, + 'partial', + f"Tras la desconciliación, el estado debe volver a 'partial', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual_1, + 2, + f"Tras la desconciliación, el residual debe ser ${expected_residual_1}, pero es ${invoice.amount_residual}" + ) + + _logger.info(f"Estado post-desconciliación: {invoice.payment_state}, Residual post-desconciliación: {invoice.amount_residual},esperado: {expected_residual_1} ") + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_1_receivable_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.credit > 0 + ) + _logger.info(f"Línea CxC del Pago 1 encontrada: {payment_1_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_1_receivable_line), "No se encontró la línea CxC del pago 1.") + + partial_reconcile = payment_1_receivable_line.matched_debit_ids.filtered( + lambda p: p.debit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 1.") + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 1 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + self.assertEqual( + invoice.payment_state, + 'not_paid', + f"Tras la desconciliación, el estado debe volver a 'not_paid', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + invoice.amount_residual, + 2, + f"Tras la desconciliación, el residual debe ser ${invoice.amount_residual} pero es ${invoice.amount_residual}" + ) + + + def test05_payment_from_invoice_with_igtf_journal_mixed_currency(self): + _logger.info("Iniciando test: test05_payment_from_invoice_with_igtf_journal_mixed_currency - Flujo de Desconciliación (Pago Final en VES)") + + invoice_amount = 2681.20 + payment_amount_1 = 2000.00 + rate = self.rate + + + invoice = self._create_invoice_rate(invoice_amount) + invoice.with_context(move_action_post_alert=True).action_post() + + pct = self.company.igtf_percentage + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 + + + expected_residual_1 = invoice_amount - cxc_credit_amount_1 + amount_to_pay_2_usd = expected_residual_1 + + + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + payment_register_wiz_1.write({ + 'amount': payment_amount_1, 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + _logger.info("Residual después del primer pago: " + str(invoice.amount_residual)) + self.assertAlmostEqual(payment_1.igtf_amount, expected_igtf_1, 2, "El IGTF calculado debe ser $60.00.") + + expected_lines = [ + { + 'account': self.account_bank, + 'debit': payment_amount_1, + 'credit': 0.0, + }, + + { + 'account': self.acc_receivable, + 'debit': 0.0, + 'credit': cxc_credit_amount_1, + }, + { + 'account': self.acc_igtf_cli, + 'debit': 0.0, + 'credit': expected_igtf_1, + }, + ] + + self._assert_move_lines_equal(payment_move_1, expected_lines) + + self.assertEqual(invoice.payment_state, 'partial') + _logger.info(f"Por pagar: {invoice.amount_residual}") + _logger.info("--- PRIMER PAGO (USD) SUPERADO. ---") + _logger.info("--- SEGUNDO PAGO (Para liquidar el residual, PAGO EN VES) ---") + + cxc_liquidation_ves = expected_residual_1 * rate + payment_amount_2_ves = cxc_liquidation_ves + payment_amount_2_ves = round(payment_amount_2_ves, 2) + + + invoice_1 = self.env['account.move'].browse(invoice.id) + + + with Form( + self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice_1.ids, + + ) + ) as pay_form: + + + pay_form.journal_id = self.bank_journal_bs + pay_form.currency_id = self.currency_vef + pay_form.payment_date = fields.Date.today() + + pay_form.foreign_currency_id = self.currency_vef + + pay_form.foreign_rateamount_to_pay_2_usd = invoice.foreign_rate + + pay_form.amount = payment_amount_2_ves + + + payment_register_wiz_2 = pay_form.record + + action_2 = payment_register_wiz_2.action_create_payments() + + _logger.info('SEGUNDO PAGO REALIZADO' ) + + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + _logger.info('SEGUNDO PAGO VALIDACION ASIENTO') + + expected_lines_2 = [ + { + 'account': self.account_bank, + 'debit': amount_to_pay_2_usd, + 'foreign_debit':payment_amount_2_ves, + 'credit': 0.0, + }, + + { + 'account': self.acc_receivable, + 'debit': 0.0, + 'credit': amount_to_pay_2_usd, + 'foreign_credit':payment_amount_2_ves, + + }, + + ] + + _logger.info(expected_lines_2) + + self.assertEqual( + payment_move_2.state, + 'posted', + f"el estado del pago debe estar en estado 'posted' , estado actual: {payment_move_2.state}" + ) + + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + _logger.info(invoice_1.payment_state) + _logger.info('SEGUNDO PAGO VALIDACION ASIENTO SUPERADA') + + + self.assertAlmostEqual(invoice_1.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + + self.assertIn( + invoice_1.payment_state, + ['paid', 'in_payment'], + f"La factura debe estar en estado 'paid' o 'in_payment', estado actual: {invoice_1.payment_state}" + ) + + + self.assertAlmostEqual(invoice_1.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + _logger.info("--- SEGUNDO PAGO (VES) Y CONCILIACIÓN INICIAL SUPERADO. Estado: paid. ---") + + + invoice_receivable_line = invoice_1.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_2_receivable_line = payment_move_2.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.credit > 0 + ) + _logger.info(f"Línea CxC del Pago 2 encontrada: {payment_2_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_2_receivable_line), "No se encontró la línea CxC del pago 2.") + + + + partial_reconcile = payment_2_receivable_line.matched_debit_ids.filtered( + lambda p: p.debit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 2.") + + + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 2 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + + self.assertEqual( + invoice.payment_state, + 'partial', + f"Tras la desconciliación, el estado debe volver a 'partial', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual_1, + 2, + f"Tras la desconciliación, el residual debe ser ${expected_residual_1}, pero es ${invoice.amount_residual}" + ) + + _logger.info(f"Estado post-desconciliación: {invoice.payment_state}, Residual post-desconciliación: {invoice.amount_residual},esperado: {expected_residual_1} ") + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_1_receivable_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.credit > 0 + ) + _logger.info(f"Línea CxC del Pago 1 encontrada: {payment_1_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_1_receivable_line), "No se encontró la línea CxC del pago 1.") + + partial_reconcile = payment_1_receivable_line.matched_debit_ids.filtered( + lambda p: p.debit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 1.") + + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 1 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + self.assertEqual( + invoice.payment_state, + 'not_paid', + f"Tras la desconciliación, el estado debe volver a 'not_paid', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + invoice.amount_residual, + 2, + f"Tras la desconciliación, el residual debe ser ${invoice.amount_residual} pero es ${invoice.amount_residual}" + ) + + _logger.info("test03_payment_from_invoice_with_igtf_journal_mixed_currency (Flujo Desconciliación - VES) superado.") + + def test06_payment_from_invoice_with_overpayment_and_reconciliation(self): + _logger.info("Iniciando test: Flujo de Sobrepago (4036.80) con Sobrante y Conciliación de Factura Secundaria (950.00)") + + # --- Variables y Configuración Inicial --- + invoice_amount_1 = 2681.20 # Monto de la Factura 1 original + payment_amount_1 = 4036.80 # Monto del Primer Pago (Sobrepago) + invoice_amount_2 = 950.00 # Monto de la Factura 2 + + # Asumimos que self.company.igtf_percentage es 3.0 + pct = self.company.igtf_percentage + + # --- Cálculos Esperados para el Sobrepago --- + # IGTF calculado sobre el monto total del pago + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) # 4036.80 * 0.03 = 121.10 + # Monto que realmente se aplica a la cuenta por cobrar/pagar + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 # 4036.80 - 121.10 = 3915.70 + + # Sobrante inicial después de liquidar la Factura 1 + sobrante_1 = cxc_credit_amount_1 - invoice_amount_1 # 3915.70 - 2681.20 = 1234.50 + + _logger.info(f"Factura 1 (Original): {invoice_amount_1}") + _logger.info(f"Pago 1 (Total): {payment_amount_1}, IGTF: {expected_igtf_1}, Crédito Aplicado (CxC/CxP): {cxc_credit_amount_1}") + _logger.info(f"Sobrante Inicial (Crédito Pendiente): {sobrante_1}") + + # --- 1. Creación y Registro de Factura 1 --- + invoice_1 = self._create_invoice_rate(invoice_amount_1) + invoice_1.with_context(move_action_post_alert=True).action_post() + + # --- 2. Primer Pago (Sobrepago) --- + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice_1.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + # --- 3. Verificación de Asientos del Primer Pago (Sobrepago) --- + _logger.info("Verificando asientos del Pago 1 (Sobrepago)...") + + # El asiento debe reflejar: Banco (Crédito), CxC/CxP (Débito, con sobrante), Gasto IGTF (Débito) + expected_lines_1 = [ + { + 'account': self.account_bank, + 'debit': payment_amount_1, + 'credit': 0.0, + }, + { + 'account': self.acc_receivable, # Línea que recibe el crédito aplicado y tendrá el sobrante + 'debit': 0.0, + 'credit': cxc_credit_amount_1, + }, + { + 'account': self.acc_igtf_cli, + 'debit': 0.0, + 'credit': expected_igtf_1, + }, + ] + self._assert_move_lines_equal(payment_move_1, expected_lines_1) + _logger.info("Asientos del Pago 1 verificados correctamente.") + + # Verificar que la Factura 1 quede totalmente pagada + self.assertEqual(invoice_1.payment_state, 'paid', "La Factura 1 debe quedar totalmente pagada ('paid') debido al sobrepago.") + self.assertAlmostEqual(invoice_1.amount_residual, 0.0, 2, "Residual de Factura 1 debe ser $0.00.") + _logger.info("Factura 1 pagada totalmente. Sobrante inicial de $" + str(sobrante_1) + " pendiente de conciliar.") + + # --- 4. Registro de Factura 2 --- + _logger.info(f"Registrando Factura 2 por: {invoice_amount_2}") + + invoice_2 = self._create_invoice_rate(invoice_amount_2) + invoice_2.with_context(move_action_post_alert=True).action_post() + + self.assertEqual(invoice_2.payment_state, 'not_paid', "La Factura 2 debe iniciar en estado 'not_paid'.") + + # --- 5. Conciliación y Aplicación del Sobrante desde el Widget (Segundo "Pago") --- + + # 5a. Encontrar la línea de crédito pendiente (sobrante) del Pago 1 + outstanding_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.credit > 0 + ) + + self.assertTrue(outstanding_line, "Error: No se encontró la línea contable del sobrante para conciliar.") + + # Usamos js_assign_outstanding_line para simular la acción del widget de conciliación + outstanding_line_id = outstanding_line.id + + _logger.info(f"Aplicando crédito pendiente (ID {outstanding_line_id}) a Factura 2 por {invoice_amount_2}") + invoice_2.js_assign_outstanding_line(outstanding_line_id) + _logger.info("Sobrante aplicado a Factura 2.") + + # Encontrar la línea CxC/CxP de la Factura 2 para las verificaciones + invoice_2_payable_line = invoice_2.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + self.assertTrue(invoice_2_payable_line, "Error: No se encontró la línea CxC/CxP (Crédito) de la Factura 2.") + + # 5b. VERIFICACIÓN DEL ASIENTO/REGISTRO DE CONCILIACIÓN (Segundo "Pago") + # Buscamos el registro account.partial.reconcile que confirma que el crédito se aplicó. + partial_reconcile = outstanding_line.matched_debit_ids.filtered( + lambda p: p.debit_move_id == invoice_2_payable_line + ) + + self.assertTrue(partial_reconcile, "Error: No se encontró el registro de conciliación parcial (asiento de aplicación del crédito).") + self.assertAlmostEqual(partial_reconcile.amount, invoice_amount_2, 2, + "El monto conciliado en el registro de conciliación es incorrecto.") + _logger.info("Asiento/Registro de Conciliación de Factura 2 (Aplicación de Crédito) verificado. Monto conciliado: " + str(partial_reconcile.amount)) + + # --- 6. Verificación Final (Factura 2 y Sobrante) --- + _logger.info("Verificando Factura 2 y Sobrante Final...") + + # Factura 2 debe quedar 'paid' + self.assertEqual(invoice_2.payment_state, 'paid', "La Factura 2 debe quedar totalmente pagada ('paid').") + self.assertAlmostEqual(invoice_2.amount_residual, 0.0, 2, "Residual de Factura 2 debe ser $0.00.") + + # Verificación de la línea contable de Factura 2 (residual debe ser 0.0) + self.assertAlmostEqual( + invoice_2_payable_line.amount_residual, + 0.0, + 2, + "La línea CxC/CxP de la Factura 2 no está completamente liquidada (residual 0.0)." + ) + _logger.info("Factura 2: Línea CxC/CxP totalmente liquidada (residual 0.0).") + + # Verificar el sobrante final + sobrante_final = sobrante_1 - invoice_amount_2 # 1234.50 - 950.00 = 284.50 + + # Recargar la línea de pago para ver el residual después de la conciliación + #outstanding_line.invalidate_cache() + + self.assertAlmostEqual( + outstanding_line.amount_residual, + -sobrante_final, + 2, + f"El sobrante final esperado es ${-sobrante_final}, pero el residual de la línea es ${outstanding_line.amount_residual}" + ) + + _logger.info(f"Sobrante final verificado en la línea de pago: ${-sobrante_final}.") + _logger.info("Test de Flujo de Sobrepago y Conciliación superado.") + + + def test07_payment_from_invoice_with_overpayment_and_reconciliation(self): + _logger.info("Iniciando test: Flujo de Sobrepago (4036.80) con Sobrante y Conciliación de Factura Secundaria (950.00) y posterior DESCONCILIACIÓN.") + + # --- Variables y Configuración Inicial --- + invoice_amount_1 = 2681.20 # Monto de la Factura 1 original + payment_amount_1 = 4036.80 # Monto del Primer Pago (Sobrepago) + invoice_amount_2 = 950.00 # Monto de la Factura 2 + + # Asumimos que self.company.igtf_percentage es 3.0 + pct = self.company.igtf_percentage + + # --- Cálculos Esperados para el Sobrepago --- + # IGTF calculado sobre el monto total del pago + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) # 4036.80 * 0.03 = 121.10 + # Monto que realmente se aplica a la cuenta por cobrar/pagar + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 # 4036.80 - 121.10 = 3915.70 + + # Sobrante inicial después de liquidar la Factura 1 + sobrante_1 = cxc_credit_amount_1 - invoice_amount_1 # 3915.70 - 2681.20 = 1234.50 + + _logger.info(f"Factura 1 (Original): {invoice_amount_1}") + _logger.info(f"Pago 1 (Total): {payment_amount_1}, IGTF: {expected_igtf_1}, Crédito Aplicado (CxC/CxP): {cxc_credit_amount_1}") + _logger.info(f"Sobrante Inicial (Crédito Pendiente): {sobrante_1}") + + # --- 1. Creación y Registro de Factura 1 --- + invoice_1 = self._create_invoice_rate(invoice_amount_1) + invoice_1.with_context(move_action_post_alert=True).action_post() + + # --- 2. Primer Pago (Sobrepago) --- + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice_1.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + # 🚩 INICIO: Validación del campo 'is_advance_payment' en account.payment + if 'is_advance_payment' in payment_1._fields: + self.assertFalse(payment_1.is_advance_payment, + "El pago de sobrepago NO debería estar marcado como 'is_advance_payment' True.") + _logger.info(f"VALIDACIÓN CAMPO: 'is_advance_payment' existe. Valor: {payment_1.is_advance_payment}") + else: + _logger.warning("VALIDACIÓN CAMPO: El campo 'is_advance_payment' no existe en el modelo 'account.payment'. Omitiendo validación de valor.") + # 🚩 FIN: Validación del campo 'is_advance_payment' + + # --- 3. Verificación de Asientos del Primer Pago (Sobrepago) --- + _logger.info("Verificando asientos del Pago 1 (Sobrepago)...") + + # VALIDACIÓN DEL PAGO 1: El asiento debe reflejar: Banco (Crédito), CxC/CxP (Débito, con sobrante), Gasto IGTF (Débito) + expected_lines_1 = [ + { + 'account': self.account_bank, + 'debit': payment_amount_1, + 'credit': 0.0, + }, + { + 'account': self.acc_receivable, # Línea que recibe el crédito aplicado y tendrá el sobrante + 'debit': 0.0, + 'credit': cxc_credit_amount_1, + }, + { + 'account': self.acc_igtf_cli, + 'debit': 0.0, + 'credit': expected_igtf_1, + }, + ] + self._assert_move_lines_equal(payment_move_1, expected_lines_1) + _logger.info("Asientos del Pago 1 verificados correctamente.") + + # Línea de débito del Pago 1 (línea de crédito pendiente/sobrante) + outstanding_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.credit > 0 + ) + + # Línea de crédito de la Factura 1 (deuda) + invoice_1_payable_line = invoice_1.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + + # --- 4. Registro de Factura 2 --- + _logger.info(f"Registrando Factura 2 por: {invoice_amount_2}") + + invoice_2 = self._create_invoice_rate(invoice_amount_2) + invoice_2.with_context(move_action_post_alert=True).action_post() + + self.assertEqual(invoice_2.payment_state, 'not_paid', "La Factura 2 debe iniciar en estado 'not_paid'.") + + # --- 5. Conciliación y Aplicación del Sobrante (Segundo "Pago") --- + + # 5a. Aplicación del sobrante a Factura 2 + outstanding_line_id = outstanding_line.id + + _logger.info(f"Aplicando crédito pendiente (ID {outstanding_line_id}) a Factura 2 por {invoice_amount_2}") + invoice_2.js_assign_outstanding_line(outstanding_line_id) + _logger.info("Sobrante aplicado a Factura 2. Ambas facturas en 'paid'.") + + # Encontrar la línea CxC/CxP de la Factura 2 para las verificaciones + invoice_2_payable_line = invoice_2.line_ids.filtered( + lambda l: l.account_id == self.acc_receivable and l.debit > 0 + ) + self.assertTrue(invoice_2_payable_line, "Error: No se encontró la línea CxC/CxP (Crédito) de la Factura 2.") + + # Verificar que ambas facturas están pagadas + invoice_1 = self.env['account.move'].browse(invoice_1.id) + invoice_2 = self.env['account.move'].browse(invoice_2.id) + self.assertEqual(invoice_1.payment_state, 'paid', "Factura 1 debe estar pagada.") + self.assertEqual(invoice_2.payment_state, 'paid', "Factura 2 debe estar pagada.") + + # --- 6. DESCONCILIACIÓN (Un-Reconcile) --- + + _logger.info("INICIANDO PROCESO DE DESCONCILIACIÓN (Orden: Factura 1, luego Factura 2).") + + # 6a. Desconciliar Factura 1 (Conciliación entre Pago 1 y Factura 1) + # partial_reconcile_1: Registro de la aplicación de $2681.20 + partial_reconcile_1 = outstanding_line.matched_debit_ids.filtered( + lambda p: p.debit_move_id == invoice_1_payable_line + ) + self.assertTrue(partial_reconcile_1, "Error: No se encontró el registro de conciliación parcial para Factura 1.") + + invoice_1.js_remove_outstanding_partial(partial_reconcile_1.id) + _logger.info("6a. Desconciliación de Factura 1 (Pago Inicial) realizada con éxito.") + + # Validar estado de Factura 1 + #invoice_1.refresh() + self.assertEqual(invoice_1.payment_state, 'not_paid', "La Factura 1 debe volver a 'not_paid' después de desconciliar.") + + + # 6b. Desconciliar Factura 2 (Conciliación entre Sobrante y Factura 2) + # partial_reconcile_2: Registro de la aplicación de $950.00 + partial_reconcile_2 = outstanding_line.matched_credit_ids.filtered( + lambda p: p.debit_move_id == invoice_2_payable_line + ) + self.assertTrue(partial_reconcile_2, "Error: No se encontró el registro de conciliación parcial para Factura 2.") + + invoice_2.js_remove_outstanding_partial(partial_reconcile_2.id) + _logger.info("6b. Desconciliación de Factura 2 (Uso de Sobrante) realizada con éxito.") + + # Validar estado de Factura 2 + #invoice_2.refresh() + self.assertEqual(invoice_2.payment_state, 'not_paid', "La Factura 2 debe volver a 'not_paid' después de desconciliar.") + + # --- 7. VALIDACIÓN FINAL DEL PAGO (Crédito Pendiente) --- + + # Recargar la línea de débito del Pago 1 para verificar el residual + #outstanding_line.invalidate_cache() + + _logger.info("Verificando estado final de las líneas contables.") + + # El residual de la línea de Pago 1 debe ser igual al monto total del crédito aplicado (cxc_credit_amount_1) + # Ya que ambas conciliaciones fueron deshechas, el monto completo es un sobrante/crédito pendiente. + self.assertAlmostEqual( + outstanding_line.amount_residual, + cxc_credit_amount_1, + 2, + f"El residual final de la línea de Pago 1 debe ser el monto original del crédito ({cxc_credit_amount_1}), pero es {outstanding_line.amount_residual}" + ) + + # La línea de pago no debe tener registros de conciliación asociados + self.assertFalse(outstanding_line.matched_debit_ids, "La línea de Pago 1 no debe tener registros de conciliación pendientes.") + + _logger.info(f"RESULTADO FINAL: Sobrante/Crédito pendiente: ${outstanding_line.amount_residual} (Original: ${cxc_credit_amount_1}).") + _logger.info("Test de Desconciliación superado.") \ No newline at end of file diff --git a/l10n_ve_igtf/tests/test_igtf_providers.py b/l10n_ve_igtf/tests/test_igtf_providers.py new file mode 100644 index 00000000..edaf2c48 --- /dev/null +++ b/l10n_ve_igtf/tests/test_igtf_providers.py @@ -0,0 +1,1117 @@ +import logging +from odoo.tests.common import Form +from odoo.tests import tagged +from odoo.exceptions import UserError, ValidationError +from odoo import Command, fields + +from .new_igtf_common_providers import IGTFTestCommon + +_logger = logging.getLogger(__name__) + + +@tagged("igtf_providers", "igtf_run", "-at_install", "post_install") +class TestIGTFNEW(IGTFTestCommon): + + def _assert_move_lines_equal(self, move, expected_lines): + """ + Valida que el asiento contable tenga el número de líneas esperado y que + los valores de Débito, Crédito y Cuenta coincidan para cada línea. + """ + self.assertEqual(len(move.line_ids), len(expected_lines), + f"El asiento debe tener {len(expected_lines)} líneas, pero tiene {len(move.line_ids)}.") + + for expected_line in expected_lines: + expected_account = expected_line['account'] + expected_debit = expected_line['debit'] + expected_credit = expected_line['credit'] + + expected_foreign_debit = expected_line.get('foreign_debit', 0.0) + expected_foreign_credit = expected_line.get('foreign_credit', 0.0) + + + found_line = move.line_ids.filtered(lambda l: l.account_id.id == expected_account.id) + + + if not found_line: + _logger.error( + f"FALLA DE LÍNEA: Cuenta esperada NO encontrada: " + f"'{expected_account.code}' - '{expected_account.name}'. " + f"Líneas reales en el asiento: {[(l.account_id.code, l.account_id.name, l.debit, l.credit) for l in move.line_ids]}" + ) + else: + _logger.info( + f"LÍNEA ENCONTRADA: Cuenta '{found_line.account_id.code}' - '{found_line.account_id.name}'. " + f"Débito Real: {found_line.debit}, Crédito Real: {found_line.credit}" + ) + + + self.assertTrue(found_line, + f"Línea contable para la cuenta '{expected_account.code}' ({expected_account.name}) no encontrada.") + + + self.assertAlmostEqual(found_line.debit, expected_debit, 2, + f"Débito de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_debit}, Real: {found_line.debit}") + + + self.assertAlmostEqual(found_line.credit, expected_credit, 2, + f"Crédito de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_credit}, Real: {found_line.credit}") + + if expected_foreign_debit == 0.0 and expected_foreign_credit == 0.0: + continue + + self.assertAlmostEqual(found_line.foreign_debit, expected_foreign_debit, 2, + f"Débito foraneo de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_foreign_debit}, Real: {found_line.foreign_debit}") + + self.assertAlmostEqual(found_line.foreign_credit, expected_foreign_credit, 2, + f"Crédito foraneo de la cuenta '{expected_account.code}' incorrecto. Esperado: {expected_foreign_credit}, Real: {found_line.foreign_credit}") + + + total_debit = sum(line.debit for line in move.line_ids) + total_credit = sum(line.credit for line in move.line_ids) + + self.assertAlmostEqual(total_debit, total_credit, 2, + "El asiento no balancea (Débito != Crédito).") + + _logger.info("Validación detallada de líneas contables: OK.") + + def test01_payment_from_invoice_with_igtf_journal(self): + _logger.info("Iniciando test: test01_payment_from_invoice_with_igtf_journal") + + invoice_amount = 2681.20 + payment_amount = 2000.00 + + + invoice = self._create_invoice_usd(invoice_amount) + #invoice.with_context(move_action_post_alert=True).action_post() + + + + pct = self.company.igtf_percentage + expected_igtf = round(payment_amount * pct / 100, 2) + cxc_credit_amount = payment_amount - expected_igtf + + expected_residual = invoice_amount - payment_amount + expected_igtf + + payment_register_wiz = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz.write({ + 'amount': payment_amount, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action = payment_register_wiz.action_create_payments() + payment = self.env['account.payment'].browse(action.get('res_id')) + payment_move = payment.move_id + + + self.assertTrue(payment_move, "Debe haberse creado el asiento de pago asociado al payment.") + self.assertAlmostEqual(payment.igtf_amount, expected_igtf, 2, "El IGTF calculado debe ser $60.00.") + + expected_lines = [ + { + 'account': self.account_bank, + 'debit': 0.0, + 'credit': payment_amount, + }, + + { + 'account': self.acc_payable, + 'debit': cxc_credit_amount, + 'credit': 0.0, + }, + { + 'account': self.acc_igtf_cli, + 'debit': expected_igtf, + 'credit': 0.0, + }, + ] + + self._assert_move_lines_equal(payment_move, expected_lines) + + + + self.assertEqual( + invoice.payment_state, + 'partial', + f"La factura debe estar en estado 'partial' (parcialmente pagada), estado actual: {invoice.payment_state}" + ) + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual, + 2, + f"El monto residual de la factura debe ser ${expected_residual}, pero es ${invoice.amount_residual}" + ) + + _logger.info("test01_payment_from_invoice_with_igtf_journal superado.") + + + def test02_payment_from_invoice_with_igtf_journal(self): + _logger.info("Iniciando test: test02_payment_from_invoice_with_igtf_journal") + + invoice_amount = 2681.20 + payment_amount = 2000.00 + + + invoice = self._create_invoice_usd(invoice_amount) + #invoice.with_context(move_action_post_alert=True).action_post() + + + pct = self.company.igtf_percentage + expected_igtf = round(payment_amount * pct / 100, 2) + cxc_credit_amount = payment_amount - expected_igtf + + expected_residual = invoice_amount - payment_amount + expected_igtf + + amount_to_pay_2 = invoice_amount - cxc_credit_amount + + + payment_register_wiz = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz.write({ + 'amount': payment_amount, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action = payment_register_wiz.action_create_payments() + payment = self.env['account.payment'].browse(action.get('res_id')) + payment_move = payment.move_id + + + self.assertTrue(payment_move, "Debe haberse creado el asiento de pago asociado al payment.") + self.assertAlmostEqual(payment.igtf_amount, expected_igtf, 2, "El IGTF calculado debe ser $60.00.") + expected_lines = [ + { + 'account': self.account_bank, + 'debit': 0.0, + 'credit': payment_amount, + }, + + { + 'account': self.acc_payable, + 'debit': cxc_credit_amount, + 'credit': 0.0, + }, + { + 'account': self.acc_igtf_cli, + 'debit': expected_igtf, + 'credit': 0.0, + }, + ] + + self._assert_move_lines_equal(payment_move, expected_lines) + self.assertEqual( + invoice.payment_state, + 'partial', + f"La factura debe estar en estado 'partial' (parcialmente pagada), estado actual: {invoice.payment_state}" + ) + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual, + 2, + f"El monto residual de la factura debe ser ${expected_residual}, pero es ${invoice.amount_residual}" + ) + + _logger.info("--- PRIMER PAGO SUPERADO. ---") + _logger.info("--- SEGUNDO PAGO . ---") + + payment_amount_2 = amount_to_pay_2 / (1 - pct / 100) + payment_amount_2 = round(payment_amount_2, 2) + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + cxc_credit_amount_2 = payment_amount_2 - expected_igtf_2 + + cxc_credit_amount_2 = invoice.amount_residual + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + + + payment_register_wiz_2 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_2.write({ + 'amount': payment_amount_2, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_2 = payment_register_wiz_2.action_create_payments() + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + self.assertTrue(payment_move_2, "Debe haberse creado el asiento de pago 2.") + self.assertAlmostEqual(payment_2.igtf_amount, expected_igtf_2, 2, "El IGTF calculado del pago 2 debe ser correcto.") + + + expected_lines_2 = [ + { + 'account': self.account_bank, + 'debit': 0.0, + 'credit': payment_amount_2, + }, + + { + 'account': self.acc_payable, + 'debit': cxc_credit_amount_2, + 'credit': 0.0, + }, + { + 'account': self.acc_igtf_cli, + 'debit': expected_igtf_2, + 'credit': 0.0, + }, + ] + + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + _logger.info("--- SEGUNDO PAGO SUPERADO. ---") + + self.assertEqual( + invoice.payment_state, + 'paid', + f"La factura debe estar en estado 'paid' o 'in_payment', estado actual: {invoice.payment_state}" + ) + + self.assertAlmostEqual( + invoice.amount_residual, + 0.0, + 2, + f"El monto residual final de la factura debe ser $0.00, pero es ${invoice.amount_residual}" + ) + + _logger.info("test01_payment_from_invoice_with_igtf_journal completamente superado (pago parcial + pago final).") + + + def test03_payment_from_invoice_with_igtf_journal(self): + _logger.info("Iniciando test: test03_payment_from_invoice_with_igtf_journal - Flujo de Desconciliación") + + invoice_amount = 2681.20 + payment_amount_1 = 2000.00 + + invoice = self._create_invoice_usd(invoice_amount) + #invoice.with_context(move_action_post_alert=True).action_post() + + pct = self.company.igtf_percentage + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 + + expected_residual_1 = invoice_amount - cxc_credit_amount_1 + amount_to_pay_2 = expected_residual_1 + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + + + self.assertEqual(invoice.payment_state, 'partial', "Estado incorrecto después del pago 1.") + self.assertAlmostEqual(invoice.amount_residual, expected_residual_1, 2, "Residual incorrecto después del pago 1.") + _logger.info("--- PRIMER PAGO SUPERADO. Estado: partial. ---") + + payment_amount_2 = amount_to_pay_2 / (1 - pct / 100) + payment_amount_2 = round(payment_amount_2, 2) + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + cxc_credit_amount_2 = amount_to_pay_2 + + + payment_register_wiz_2 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_2.write({ + 'amount': payment_amount_2, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_2 = payment_register_wiz_2.action_create_payments() + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + expected_lines_2 = [ + {'account': self.account_bank, 'debit': 0.0, 'credit': payment_amount_2}, + {'account': self.acc_payable, 'debit': cxc_credit_amount_2, 'credit': 0.0}, + {'account': self.acc_igtf_cli, 'debit': expected_igtf_2, 'credit': 0.0}, + ] + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + + self.assertIn(invoice.payment_state, ('paid', 'in_payment'), "La factura no está pagada antes de desconciliar.") + self.assertAlmostEqual(invoice.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + _logger.info("--- SEGUNDO PAGO Y CONCILIACIÓN INICIAL SUPERADO. Estado: paid. ---") + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + _logger.info(invoice_receivable_line.display_name) + + payment_2_receivable_line = payment_move_2.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.debit > 0 + ) + _logger.info(f"Línea CxC del Pago 2 encontrada: {payment_2_receivable_line.mapped(lambda l: (l.account_id.name, l.debit, l.credit))}") + self.assertTrue(payment_2_receivable_line, "No se encontró la línea CxC del pago 2.") + + partial_reconcile = payment_2_receivable_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.credit_move_id.id, p.debit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 2.") + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 2 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + self.assertEqual( + invoice.payment_state, + 'partial', + f"Tras la desconciliación, el estado debe volver a 'partial', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual_1, + 2, + f"Tras la desconciliación, el residual debe ser ${expected_residual_1}, pero es ${invoice.amount_residual}" + ) + + _logger.info(f"Estado post-desconciliación: {invoice.payment_state}, Residual post-desconciliación: {invoice.amount_residual},esperado: {expected_residual_1} ") + + _logger.info("test03_payment_from_invoice_with_igtf_journal (Flujo Desconciliación) superado.") + + def test04_payment_from_invoice_with_igtf_journal_currency_usd(self): + _logger.info("Iniciando test: test04_payment_from_invoice_with_igtf_journal_currency_usd - Flujo de Desconciliación ") + invoice_amount = 2681.20 + payment_amount_1 = 2000.00 + + invoice = self._create_invoice_usd(invoice_amount) + #invoice.with_context(move_action_post_alert=True).action_post() + + pct = self.company.igtf_percentage + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 + expected_residual_1 = invoice_amount - cxc_credit_amount_1 + amount_to_pay_2 = expected_residual_1 + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + + + self.assertEqual(invoice.payment_state, 'partial', "Estado incorrecto después del pago 1.") + self.assertAlmostEqual(invoice.amount_residual, expected_residual_1, 2, "Residual incorrecto después del pago 1.") + _logger.info("--- PRIMER PAGO SUPERADO. Estado: partial. ---") + payment_amount_2 = amount_to_pay_2 / (1 - pct / 100) + payment_amount_2 = round(payment_amount_2, 2) + + expected_igtf_2 = round(payment_amount_2 * pct / 100, 2) + cxc_credit_amount_2 = amount_to_pay_2 + + + payment_register_wiz_2 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_2.write({ + 'amount': payment_amount_2, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_2 = payment_register_wiz_2.action_create_payments() + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + expected_lines_2 = [ + {'account': self.account_bank, 'debit': 0.0, 'credit': payment_amount_2}, + {'account': self.acc_payable, 'debit': cxc_credit_amount_2, 'credit': 0.0}, + {'account': self.acc_igtf_cli, 'debit': expected_igtf_2, 'credit': 0.0}, + ] + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + + self.assertIn(invoice.payment_state, ('paid', 'in_payment'), "La factura no está pagada antes de desconciliar.") + self.assertAlmostEqual(invoice.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + _logger.info("--- SEGUNDO PAGO Y CONCILIACIÓN INICIAL SUPERADO. Estado: paid. ---") + + + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_2_receivable_line = payment_move_2.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.debit > 0 + ) + _logger.info(f"Línea CxC del Pago 2 encontrada: {payment_2_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_2_receivable_line), "No se encontró la línea CxC del pago 2.") + + + + partial_reconcile = payment_2_receivable_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_receivable_line + ) + + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 2.") + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 2 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + self.assertEqual( + invoice.payment_state, + 'partial', + f"Tras la desconciliación, el estado debe volver a 'partial', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual_1, + 2, + f"Tras la desconciliación, el residual debe ser ${expected_residual_1}, pero es ${invoice.amount_residual}" + ) + + _logger.info(f"Estado post-desconciliación: {invoice.payment_state}, Residual post-desconciliación: {invoice.amount_residual},esperado: {expected_residual_1} ") + + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_1_receivable_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.debit > 0 + ) + _logger.info(f"Línea CxC del Pago 1 encontrada: {payment_1_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_1_receivable_line), "No se encontró la línea CxC del pago 1.") + + partial_reconcile = payment_1_receivable_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 1.") + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 1 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + self.assertEqual( + invoice.payment_state, + 'not_paid', + f"Tras la desconciliación, el estado debe volver a 'not_paid', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + invoice_amount, + 2, + f"Tras la desconciliación, el residual debe ser ${invoice_amount} pero es ${invoice.amount_residual}" + ) + _logger.info("test04_payment_from_invoice_with_igtf_journal_currency_usd (Flujo Desconciliación Total USD) superado.") + + + def test05_payment_from_invoice_with_igtf_journal_mixed_currency(self): + _logger.info("Iniciando test: test05_payment_from_invoice_with_igtf_journal_mixed_currency - Flujo de Desconciliación (Pago Final en VES)") + + invoice_amount = 2681.20 + payment_amount_1 = 2000.00 + rate = self.rate + + + invoice = self._create_invoice_rate(invoice_amount) + invoice.with_context(move_action_post_alert=True).action_post() + + pct = self.company.igtf_percentage + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 + + + expected_residual_1 = invoice_amount - cxc_credit_amount_1 + amount_to_pay_2_usd = expected_residual_1 + + + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + _logger.info("Residual después del primer pago: " + str(invoice.amount_residual)) + self.assertAlmostEqual(payment_1.igtf_amount, expected_igtf_1, 2, "El IGTF calculado debe ser $60.00.") + + expected_lines = [ + { + 'account': self.account_bank, + 'debit': 0.0, + 'credit': payment_amount_1, + }, + + { + 'account': self.acc_payable, + 'debit': cxc_credit_amount_1, + 'credit': 0.0, + }, + { + 'account': self.acc_igtf_cli, + 'debit': expected_igtf_1, + 'credit': 0.0, + }, + ] + + self._assert_move_lines_equal(payment_move_1, expected_lines) + + self.assertEqual(invoice.payment_state, 'partial') + _logger.info(f"Por pagar: {invoice.amount_residual}") + _logger.info("--- PRIMER PAGO (USD) SUPERADO. ---") + _logger.info("--- SEGUNDO PAGO (Para liquidar el residual, PAGO EN VES) ---") + + cxc_liquidation_ves = expected_residual_1 * rate + payment_amount_2_ves = cxc_liquidation_ves + payment_amount_2_ves = round(payment_amount_2_ves, 2) + + + invoice_1 = self.env['account.move'].browse(invoice.id) + + + with Form( + self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice_1.ids, + + ) + ) as pay_form: + + + pay_form.journal_id = self.bank_journal_bs + pay_form.currency_id = self.currency_vef + pay_form.payment_date = fields.Date.today() + + pay_form.foreign_currency_id = self.currency_vef + + pay_form.foreign_rate = invoice.foreign_rate + + pay_form.amount = payment_amount_2_ves + + + payment_register_wiz_2 = pay_form.record + + action_2 = payment_register_wiz_2.action_create_payments() + + _logger.info('SEGUNDO PAGO REALIZADO' ) + + payment_2 = self.env['account.payment'].browse(action_2.get('res_id')) + payment_move_2 = payment_2.move_id + + + _logger.info('SEGUNDO PAGO VALIDACION ASIENTO') + + expected_lines_2 = [ + { + 'account': self.account_bank, + 'debit': 0.0, + 'foreign_credit':payment_amount_2_ves, + 'credit': amount_to_pay_2_usd, + }, + + { + 'account': self.acc_payable, + 'debit': amount_to_pay_2_usd, + 'credit': 0.0, + 'foreign_debit':payment_amount_2_ves, + + } + + ] + + _logger.info(expected_lines_2) + + self.assertEqual( + payment_move_2.state, + 'posted', + f"el estado del pago debe estar en estado 'posted' , estado actual: {payment_move_2.state}" + ) + + self._assert_move_lines_equal(payment_move_2, expected_lines_2) + + _logger.info(invoice_1.payment_state) + _logger.info('SEGUNDO PAGO VALIDACION ASIENTO SUPERADA') + + + self.assertAlmostEqual(invoice_1.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + + self.assertIn( + invoice_1.payment_state, + ['paid', 'in_payment'], + f"La factura debe estar en estado 'paid' o 'in_payment', estado actual: {invoice_1.payment_state}" + ) + + + self.assertAlmostEqual(invoice_1.amount_residual, 0.0, 2, "Residual no es $0.00 antes de desconciliar.") + _logger.info("--- SEGUNDO PAGO (VES) Y CONCILIACIÓN INICIAL SUPERADO. Estado: paid. ---") + + + invoice_receivable_line = invoice_1.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_2_receivable_line = payment_move_2.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.debit > 0 + ) + _logger.info(f"Línea CxC del Pago 2 encontrada: {payment_2_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_2_receivable_line), "No se encontró la línea CxC del pago 2.") + + + + partial_reconcile = payment_2_receivable_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 2.") + + + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 2 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + + self.assertEqual( + invoice.payment_state, + 'partial', + f"Tras la desconciliación, el estado debe volver a 'partial', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + expected_residual_1, + 2, + f"Tras la desconciliación, el residual debe ser ${expected_residual_1}, pero es ${invoice.amount_residual}" + ) + + _logger.info(f"Estado post-desconciliación: {invoice.payment_state}, Residual post-desconciliación: {invoice.amount_residual},esperado: {expected_residual_1} ") + + invoice_receivable_line = invoice.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + self.assertTrue(invoice_receivable_line, "No se encontró la línea CxC a desconciliar en la factura.") + + + payment_1_receivable_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.debit > 0 + ) + _logger.info(f"Línea CxC del Pago 1 encontrada: {payment_1_receivable_line.mapped(lambda l: (l.account_id.code, l.debit, l.credit))}") + self.assertTrue(bool(payment_1_receivable_line), "No se encontró la línea CxC del pago 1.") + + partial_reconcile = payment_1_receivable_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_receivable_line + ) + _logger.info(f"Partial Reconcile encontrado: {partial_reconcile.mapped(lambda p: (p.id, p.debit_move_id.id, p.credit_move_id.id))}") + self.assertTrue(partial_reconcile, "No se halló la conciliación parcial (account.partial.reconcile) a eliminar.") + self.assertEqual(len(partial_reconcile), 1, "Se esperaba exactamente una conciliación parcial para el pago 1.") + + invoice.js_remove_outstanding_partial(partial_reconcile.id) + + _logger.info(f"Desconciliación del Pago 1 realizada con éxito usando js_remove_outstanding_partial con Partial ID: {partial_reconcile.id}") + + self.assertEqual( + invoice.payment_state, + 'not_paid', + f"Tras la desconciliación total, el estado debe volver a 'not_paid', estado actual: {invoice.payment_state}" + ) + + + self.assertAlmostEqual( + invoice.amount_residual, + invoice_amount, + 2, + f"Tras la desconciliación total, el residual debe ser ${invoice_amount}, pero es ${invoice.amount_residual}" + ) + + _logger.info("test05_payment_from_invoice_with_igtf_journal_mixed_currency (Flujo Desconciliación Total Mixta) superado.") + + + def test06_payment_from_invoice_with_overpayment_and_reconciliation(self): + _logger.info("Iniciando test: Flujo de Sobrepago (4036.80) con Sobrante y Conciliación de Factura Secundaria (950.00)") + + # --- Variables y Configuración Inicial --- + invoice_amount_1 = 2681.20 # Monto de la Factura 1 original + payment_amount_1 = 4036.80 # Monto del Primer Pago (Sobrepago) + invoice_amount_2 = 950.00 # Monto de la Factura 2 + + # Asumimos que self.company.igtf_percentage es 3.0 + pct = self.company.igtf_percentage + + # --- Cálculos Esperados para el Sobrepago --- + # IGTF calculado sobre el monto total del pago + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) # 4036.80 * 0.03 = 121.10 + # Monto que realmente se aplica a la cuenta por cobrar/pagar + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 # 4036.80 - 121.10 = 3915.70 + + # Sobrante inicial después de liquidar la Factura 1 + sobrante_1 = cxc_credit_amount_1 - invoice_amount_1 # 3915.70 - 2681.20 = 1234.50 + + _logger.info(f"Factura 1 (Original): {invoice_amount_1}") + _logger.info(f"Pago 1 (Total): {payment_amount_1}, IGTF: {expected_igtf_1}, Crédito Aplicado (CxC/CxP): {cxc_credit_amount_1}") + _logger.info(f"Sobrante Inicial (Crédito Pendiente): {sobrante_1}") + + # --- 1. Creación y Registro de Factura 1 --- + invoice_1 = self._create_invoice_rate(invoice_amount_1) + invoice_1.with_context(move_action_post_alert=True).action_post() + + # --- 2. Primer Pago (Sobrepago) --- + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice_1.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + # --- 3. Verificación de Asientos del Primer Pago (Sobrepago) --- + if 'is_advance_payment' in payment_1._fields: + self.assertFalse(payment_1.is_advance_payment, + "El pago de sobrepago debería estar marcado como 'is_advance_payment' False.") + _logger.info(f"VALIDACIÓN CAMPO: 'is_advance_payment' existe. Valor: {payment_1.is_advance_payment}") + else: + _logger.warning("VALIDACIÓN CAMPO: El campo 'is_advance_payment' no existe en el modelo 'account.payment'. Omitiendo validación de valor.") + + _logger.info("Verificando asientos del Pago 1 (Sobrepago)...") + + # El asiento debe reflejar: Banco (Crédito), CxC/CxP (Débito, con sobrante), Gasto IGTF (Débito) + expected_lines_1 = [ + { + 'account': self.account_bank, + 'debit': 0.0, + 'credit': payment_amount_1, + }, + { + 'account': self.acc_payable, # Línea que recibe el crédito aplicado y tendrá el sobrante + 'debit': cxc_credit_amount_1, + 'credit': 0.0, + }, + { + 'account': self.acc_igtf_cli, + 'debit': expected_igtf_1, + 'credit': 0.0, + }, + ] + self._assert_move_lines_equal(payment_move_1, expected_lines_1) + _logger.info("Asientos del Pago 1 verificados correctamente.") + + # Verificar que la Factura 1 quede totalmente pagada + self.assertEqual(invoice_1.payment_state, 'paid', "La Factura 1 debe quedar totalmente pagada ('paid') debido al sobrepago.") + self.assertAlmostEqual(invoice_1.amount_residual, 0.0, 2, "Residual de Factura 1 debe ser $0.00.") + _logger.info("Factura 1 pagada totalmente. Sobrante inicial de $" + str(sobrante_1) + " pendiente de conciliar.") + + # --- 4. Registro de Factura 2 --- + _logger.info(f"Registrando Factura 2 por: {invoice_amount_2}") + + invoice_2 = self._create_invoice_rate(invoice_amount_2) + invoice_2.with_context(move_action_post_alert=True).action_post() + + self.assertEqual(invoice_2.payment_state, 'not_paid', "La Factura 2 debe iniciar en estado 'not_paid'.") + + # --- 5. Conciliación y Aplicación del Sobrante desde el Widget (Segundo "Pago") --- + + # 5a. Encontrar la línea de crédito pendiente (sobrante) del Pago 1 + outstanding_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.debit > 0 + ) + + self.assertTrue(outstanding_line, "Error: No se encontró la línea contable del sobrante para conciliar.") + + # Usamos js_assign_outstanding_line para simular la acción del widget de conciliación + outstanding_line_id = outstanding_line.id + + _logger.info(f"Aplicando crédito pendiente (ID {outstanding_line_id}) a Factura 2 por {invoice_amount_2}") + invoice_2.js_assign_outstanding_line(outstanding_line_id) + _logger.info("Sobrante aplicado a Factura 2.") + + # Encontrar la línea CxC/CxP de la Factura 2 para las verificaciones + invoice_2_payable_line = invoice_2.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + self.assertTrue(invoice_2_payable_line, "Error: No se encontró la línea CxC/CxP (Crédito) de la Factura 2.") + + # 5b. VERIFICACIÓN DEL ASIENTO/REGISTRO DE CONCILIACIÓN (Segundo "Pago") + # Buscamos el registro account.partial.reconcile que confirma que el crédito se aplicó. + partial_reconcile = outstanding_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_2_payable_line + ) + + self.assertTrue(partial_reconcile, "Error: No se encontró el registro de conciliación parcial (asiento de aplicación del crédito).") + self.assertAlmostEqual(partial_reconcile.amount, invoice_amount_2, 2, + "El monto conciliado en el registro de conciliación es incorrecto.") + + + _logger.info("Asiento/Registro de Conciliación de Factura 2 (Aplicación de Crédito) verificado. Monto conciliado: " + str(partial_reconcile.amount)) + + # --- 6. Verificación Final (Factura 2 y Sobrante) --- + _logger.info("Verificando Factura 2 y Sobrante Final...") + + # Factura 2 debe quedar 'paid' + self.assertEqual(invoice_2.payment_state, 'paid', "La Factura 2 debe quedar totalmente pagada ('paid').") + self.assertAlmostEqual(invoice_2.amount_residual, 0.0, 2, "Residual de Factura 2 debe ser $0.00.") + + # Verificación de la línea contable de Factura 2 (residual debe ser 0.0) + self.assertAlmostEqual( + invoice_2_payable_line.amount_residual, + 0.0, + 2, + "La línea CxC/CxP de la Factura 2 no está completamente liquidada (residual 0.0)." + ) + _logger.info("Factura 2: Línea CxC/CxP totalmente liquidada (residual 0.0).") + + # Verificar el sobrante final + sobrante_final = sobrante_1 - invoice_amount_2 # 1234.50 - 950.00 = 284.50 + + # Recargar la línea de pago para ver el residual después de la conciliación + #outstanding_line.invalidate_cache() + + self.assertAlmostEqual( + outstanding_line.amount_residual, + sobrante_final, + 2, + f"El sobrante final esperado es ${sobrante_final}, pero el residual de la línea es ${outstanding_line.amount_residual}" + ) + + _logger.info(f"Sobrante final verificado en la línea de pago: ${sobrante_final}.") + _logger.info("Test de Flujo de Sobrepago y Conciliación superado.") + + def test07_payment_from_invoice_with_overpayment_and_reconciliation(self): + _logger.info("Iniciando test: Flujo de Sobrepago (4036.80) con Sobrante y Conciliación de Factura Secundaria (950.00) y posterior DESCONCILIACIÓN.") + + # --- Variables y Configuración Inicial --- + invoice_amount_1 = 2681.20 # Monto de la Factura 1 original + payment_amount_1 = 4036.80 # Monto del Primer Pago (Sobrepago) + invoice_amount_2 = 950.00 # Monto de la Factura 2 + + # Asumimos que self.company.igtf_percentage es 3.0 + pct = self.company.igtf_percentage + + # --- Cálculos Esperados para el Sobrepago --- + # IGTF calculado sobre el monto total del pago + expected_igtf_1 = round(payment_amount_1 * pct / 100, 2) # 4036.80 * 0.03 = 121.10 + # Monto que realmente se aplica a la cuenta por cobrar/pagar + cxc_credit_amount_1 = payment_amount_1 - expected_igtf_1 # 4036.80 - 121.10 = 3915.70 + + # Sobrante inicial después de liquidar la Factura 1 + sobrante_1 = cxc_credit_amount_1 - invoice_amount_1 # 3915.70 - 2681.20 = 1234.50 + + _logger.info(f"Factura 1 (Original): {invoice_amount_1}") + _logger.info(f"Pago 1 (Total): {payment_amount_1}, IGTF: {expected_igtf_1}, Crédito Aplicado (CxC/CxP): {cxc_credit_amount_1}") + _logger.info(f"Sobrante Inicial (Crédito Pendiente): {sobrante_1}") + + # --- 1. Creación y Registro de Factura 1 --- + invoice_1 = self._create_invoice_rate(invoice_amount_1) + invoice_1.with_context(move_action_post_alert=True).action_post() + + # --- 2. Primer Pago (Sobrepago) --- + payment_register_wiz_1 = self.env['account.payment.register'].with_context( + active_model='account.move', active_ids=invoice_1.ids + ).create({}) + + payment_register_wiz_1.write({ + 'amount': payment_amount_1, + 'journal_id': self.bank_journal_usd.id, + 'is_igtf_on_foreign_exchange': True, + }) + + action_1 = payment_register_wiz_1.action_create_payments() + payment_1 = self.env['account.payment'].browse(action_1.get('res_id')) + payment_move_1 = payment_1.move_id + + # 🚩 INICIO: Validación del campo 'is_advance_payment' en account.payment + if 'is_advance_payment' in payment_1._fields: + self.assertFalse(payment_1.is_advance_payment, + "El pago de sobrepago NO debería estar marcado como 'is_advance_payment' True.") + _logger.info(f"VALIDACIÓN CAMPO: 'is_advance_payment' existe. Valor: {payment_1.is_advance_payment}") + else: + _logger.warning("VALIDACIÓN CAMPO: El campo 'is_advance_payment' no existe en el modelo 'account.payment'. Omitiendo validación de valor.") + # 🚩 FIN: Validación del campo 'is_advance_payment' + + # --- 3. Verificación de Asientos del Primer Pago (Sobrepago) --- + _logger.info("Verificando asientos del Pago 1 (Sobrepago)...") + + # VALIDACIÓN DEL PAGO 1: El asiento debe reflejar: Banco (Crédito), CxC/CxP (Débito, con sobrante), Gasto IGTF (Débito) + expected_lines_1 = [ + { + 'account': self.account_bank, + 'debit': 0.0, + 'credit': payment_amount_1, + }, + { + 'account': self.acc_payable, # Línea que recibe el crédito aplicado y tendrá el sobrante + 'debit': cxc_credit_amount_1, + 'credit': 0.0, + }, + { + 'account': self.acc_igtf_cli, + 'debit': expected_igtf_1, + 'credit': 0.0, + }, + ] + self._assert_move_lines_equal(payment_move_1, expected_lines_1) + _logger.info("Asientos del Pago 1 verificados correctamente.") + + # Línea de débito del Pago 1 (línea de crédito pendiente/sobrante) + outstanding_line = payment_move_1.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.debit > 0 + ) + + # Línea de crédito de la Factura 1 (deuda) + invoice_1_payable_line = invoice_1.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + + # --- 4. Registro de Factura 2 --- + _logger.info(f"Registrando Factura 2 por: {invoice_amount_2}") + + invoice_2 = self._create_invoice_rate(invoice_amount_2) + invoice_2.with_context(move_action_post_alert=True).action_post() + + self.assertEqual(invoice_2.payment_state, 'not_paid', "La Factura 2 debe iniciar en estado 'not_paid'.") + + # --- 5. Conciliación y Aplicación del Sobrante (Segundo "Pago") --- + + # 5a. Aplicación del sobrante a Factura 2 + outstanding_line_id = outstanding_line.id + + _logger.info(f"Aplicando crédito pendiente (ID {outstanding_line_id}) a Factura 2 por {invoice_amount_2}") + invoice_2.js_assign_outstanding_line(outstanding_line_id) + _logger.info("Sobrante aplicado a Factura 2. Ambas facturas en 'paid'.") + + # Encontrar la línea CxC/CxP de la Factura 2 para las verificaciones + invoice_2_payable_line = invoice_2.line_ids.filtered( + lambda l: l.account_id == self.acc_payable and l.credit > 0 + ) + self.assertTrue(invoice_2_payable_line, "Error: No se encontró la línea CxC/CxP (Crédito) de la Factura 2.") + + # Verificar que ambas facturas están pagadas + invoice_1 = self.env['account.move'].browse(invoice_1.id) + invoice_2 = self.env['account.move'].browse(invoice_2.id) + self.assertEqual(invoice_1.payment_state, 'paid', "Factura 1 debe estar pagada.") + self.assertEqual(invoice_2.payment_state, 'paid', "Factura 2 debe estar pagada.") + + # --- 6. DESCONCILIACIÓN (Un-Reconcile) --- + + _logger.info("INICIANDO PROCESO DE DESCONCILIACIÓN (Orden: Factura 1, luego Factura 2).") + + # 6a. Desconciliar Factura 1 (Conciliación entre Pago 1 y Factura 1) + # partial_reconcile_1: Registro de la aplicación de $2681.20 + partial_reconcile_1 = outstanding_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_1_payable_line + ) + self.assertTrue(partial_reconcile_1, "Error: No se encontró el registro de conciliación parcial para Factura 1.") + + invoice_1.js_remove_outstanding_partial(partial_reconcile_1.id) + _logger.info("6a. Desconciliación de Factura 1 (Pago Inicial) realizada con éxito.") + + # Validar estado de Factura 1 + #invoice_1.refresh() + self.assertEqual(invoice_1.payment_state, 'not_paid', "La Factura 1 debe volver a 'not_paid' después de desconciliar.") + + + # 6b. Desconciliar Factura 2 (Conciliación entre Sobrante y Factura 2) + # partial_reconcile_2: Registro de la aplicación de $950.00 + partial_reconcile_2 = outstanding_line.matched_credit_ids.filtered( + lambda p: p.credit_move_id == invoice_2_payable_line + ) + self.assertTrue(partial_reconcile_2, "Error: No se encontró el registro de conciliación parcial para Factura 2.") + + invoice_2.js_remove_outstanding_partial(partial_reconcile_2.id) + _logger.info("6b. Desconciliación de Factura 2 (Uso de Sobrante) realizada con éxito.") + + # Validar estado de Factura 2 + #invoice_2.refresh() + self.assertEqual(invoice_2.payment_state, 'not_paid', "La Factura 2 debe volver a 'not_paid' después de desconciliar.") + + # --- 7. VALIDACIÓN FINAL DEL PAGO (Crédito Pendiente) --- + + # Recargar la línea de débito del Pago 1 para verificar el residual + #outstanding_line.invalidate_cache() + + _logger.info("Verificando estado final de las líneas contables.") + + # El residual de la línea de Pago 1 debe ser igual al monto total del crédito aplicado (cxc_credit_amount_1) + # Ya que ambas conciliaciones fueron deshechas, el monto completo es un sobrante/crédito pendiente. + self.assertAlmostEqual( + outstanding_line.amount_residual, + cxc_credit_amount_1, + 2, + f"El residual final de la línea de Pago 1 debe ser el monto original del crédito ({cxc_credit_amount_1}), pero es {outstanding_line.amount_residual}" + ) + + # La línea de pago no debe tener registros de conciliación asociados + self.assertFalse(outstanding_line.matched_credit_ids, "La línea de Pago 1 no debe tener registros de conciliación pendientes.") + + _logger.info(f"RESULTADO FINAL: Sobrante/Crédito pendiente: ${outstanding_line.amount_residual} (Original: ${cxc_credit_amount_1}).") + _logger.info("Test de Desconciliación superado.") \ No newline at end of file diff --git a/l10n_ve_igtf/views/account_payment.xml b/l10n_ve_igtf/views/account_payment.xml index 564ac9b9..2c2a02ad 100644 --- a/l10n_ve_igtf/views/account_payment.xml +++ b/l10n_ve_igtf/views/account_payment.xml @@ -1,5 +1,5 @@ - + diff --git a/l10n_ve_igtf/views/res_config_settings.xml b/l10n_ve_igtf/views/res_config_settings.xml index 3f7d6359..b59d07f3 100644 --- a/l10n_ve_igtf/views/res_config_settings.xml +++ b/l10n_ve_igtf/views/res_config_settings.xml @@ -2,9 +2,9 @@ res.config.settings.l10n_ve_igtf res.config.settings - + - +
@@ -28,7 +28,7 @@
- +
@@ -53,4 +53,4 @@ - \ No newline at end of file + diff --git a/l10n_ve_igtf/wizard/account_payment_register.py b/l10n_ve_igtf/wizard/account_payment_register.py index 8e7f6088..b346b52f 100644 --- a/l10n_ve_igtf/wizard/account_payment_register.py +++ b/l10n_ve_igtf/wizard/account_payment_register.py @@ -1,5 +1,5 @@ from odoo import api, models, fields, _ -from odoo.tools.float_utils import float_is_zero +from odoo.exceptions import UserError import logging _logger = logging.getLogger(__name__) @@ -24,7 +24,9 @@ class AccountPaymentRegisterIgtf(models.TransientModel): is_igtf_on_foreign_exchange = fields.Boolean( string="IGTF on Foreign Exchange?", + default=False, help="IGTF on Foreign Exchange?", + compute="_compute_is_igtf_journal", store=True, ) @@ -36,6 +38,7 @@ class AccountPaymentRegisterIgtf(models.TransientModel): @api.depends("journal_id","currency_id") def _compute_check_igtf(self): + """ Check if the company is a ordinary contributor""" for payment in self: payment.is_igtf = False if payment.currency_id.id == self.env.ref("base.USD").id and payment.journal_id.currency_id.id == self.env.ref("base.USD").id: @@ -57,6 +60,7 @@ def _compute_check_igtf(self): @api.depends("is_igtf") def _compute_igtf_percentage(self): + """ Compute the igtf percetage defined in the company""" for payment in self: payment.igtf_percentage = payment.env.company.igtf_percentage @@ -69,47 +73,34 @@ def _compute_amount_without_difference(self): @api.depends("amount", "is_igtf", "igtf_amount") def _compute_amount_with_igtf(self): + """Compute the amount with igtf of the payment""" for payment in self: payment.amount_with_igtf = payment.amount + payment.igtf_amount - @api.onchange("journal_id", "is_igtf", "currency_id", "amount") + @api.onchange("journal_id", "is_igtf", "currency_id","amount") def _compute_is_igtf(self): + """Compute if the current payment apply igtf """ for payment in self: - payment.is_igtf_on_foreign_exchange = False - - if not (payment.journal_id.is_igtf and - payment.is_igtf and - payment.currency_id.id == self.env.ref("base.USD").id): - continue - - invoices = payment.line_ids.mapped('move_id') - if not invoices: - continue - - all_existing_payments = invoices._get_reconciled_payments() - usd_payments = all_existing_payments.filtered( - lambda p: p.currency_id.id == self.env.ref("base.USD").id and p.is_igtf_on_foreign_exchange - ) - - total_residual = sum(invoices.mapped('amount_residual')) - result = total_residual - payment.amount - - is_first_usd_payment = len(usd_payments) == 0 - if is_first_usd_payment: - payment.is_igtf_on_foreign_exchange = True - continue - - if result > 0: + amount_residual = payment.line_ids.mapped('move_id').amount_residual + result = amount_residual - payment.amount + if ( + payment.journal_id.is_igtf + and payment.is_igtf + and payment.currency_id.id == self.env.ref("base.USD").id + and abs(result) > 0.0001 + ): payment.is_igtf_on_foreign_exchange = True - continue - - if result == 0: + else: payment.is_igtf_on_foreign_exchange = False @api.depends("amount", "is_igtf", "is_igtf_on_foreign_exchange") def _compute_igtf_amount(self): + """Compute the igtf amount of the payment""" for payment in self: + id=self.env.context.get("active_id",False) + move_id=self.env['account.move'].browse(id) + _logger.warning("move_id %s",move_id) payment.igtf_amount = 0.0 if ( payment.journal_id.is_igtf @@ -118,8 +109,22 @@ def _compute_igtf_amount(self): ): payment_amount = payment.amount if payment.payment_difference < 0: + #raise UserError(payment.payment_difference) payment_amount = payment.amount + payment.payment_difference - payment.igtf_amount = payment_amount * (payment.igtf_percentage / 100) + payment.igtf_amount = payment.calculate_igtf_for_payment( + move_id, payment_amount, payment.igtf_percentage + ) + + def calculate_igtf_for_payment(self, invoice, payment_amount, igtf_percentage): + """ + Calcula IGTF solo sobre el monto que se aplica a la deuda principal + """ + # 1. Deuda principal pendiente (sin incluir IGTF) + principal_debt = invoice.amount_total - invoice.bi_igtf + + principal_amount = min(payment_amount, principal_debt) + igtf = principal_amount * (igtf_percentage / 100) + return max(igtf, 0.0) def _init_payments(self, to_process, edit_mode=False): """Create the payments from the wizard's values. @@ -130,6 +135,7 @@ def _init_payments(self, to_process, edit_mode=False): :return: A list of ids of the created payments. """ to_process[0]["create_vals"]["igtf_amount"] = self.igtf_amount + to_process[0]["create_vals"]["payment_from_wizard"] = True to_process[0]["create_vals"]["igtf_percentage"] = self.igtf_percentage to_process[0]["create_vals"][ "is_igtf_on_foreign_exchange" @@ -146,6 +152,7 @@ def _create_payments(self): Returns: Payment: The created payment. """ + res = super(AccountPaymentRegisterIgtf, self)._create_payments() for payment in res: if ( @@ -166,4 +173,11 @@ def _create_payments(self): if payment.reconciled_bill_ids: payment.reconciled_bill_ids.bi_igtf += self.amount_without_difference return res + + + @api.depends('journal_id') + def _compute_is_igtf_journal(self): + for record in self: + if record.journal_id.currency_id and record.journal_id.currency_id == self.env.ref("base.USD"): + record.is_igtf_on_foreign_exchange = True diff --git a/l10n_ve_igtf/wizard/account_payment_register.xml b/l10n_ve_igtf/wizard/account_payment_register.xml index 9c6248de..081cfa9f 100644 --- a/l10n_ve_igtf/wizard/account_payment_register.xml +++ b/l10n_ve_igtf/wizard/account_payment_register.xml @@ -5,7 +5,7 @@ account.payment.register - + res.partner - - -
+ + 1 + + +
diff --git a/l10n_ve_payment_extension/models/account_journal.py b/l10n_ve_payment_extension/models/account_journal.py index 2cb5d88e..e8c3baae 100644 --- a/l10n_ve_payment_extension/models/account_journal.py +++ b/l10n_ve_payment_extension/models/account_journal.py @@ -5,8 +5,9 @@ class AccountJournal(models.Model): _inherit = "account.journal" default_account_id = fields.Many2one( + # ('deprecated', '=', False), domain=( - "[('deprecated', '=', False), ('company_ids', 'in', company_id)," + "[('company_ids', 'in', company_id)," "'|',('account_type', '=', default_account_type)," "('account_type', 'in', ('income', 'income_other') if type == 'sale' else ('expense', 'expense_depreciation', 'expense_direct_cost') if type == 'purchase' else ('asset_current', 'liability_current'))]" ) diff --git a/l10n_ve_payment_extension/models/account_retention_line.py b/l10n_ve_payment_extension/models/account_retention_line.py index 9e4e779e..efa77495 100644 --- a/l10n_ve_payment_extension/models/account_retention_line.py +++ b/l10n_ve_payment_extension/models/account_retention_line.py @@ -160,7 +160,6 @@ def unlink(self): record.payment_id.unlink() return super().unlink() - @api.onchange("payment_concept_id") @api.depends("payment_concept_id", "move_id") def _compute_related_fields(self): """ @@ -310,12 +309,9 @@ def onchange_retention_amount(self): case when the retention amount and the invoice amount are shown on the retention line, because the amounts of the retention lines are always shown in VEF. """ - if self.env.context.get("noonchange", False): - return for line in self.filtered( lambda l: not l.retention_id or l.retention_id.type == "out_invoice" ): - self.env.context = self.with_context(noonchange=True).env.context if not line.retention_id or line.retention_id.type_retention in ("islr", "municipal"): line.update( { @@ -330,36 +326,44 @@ def onchange_retention_amount(self): } ) - @api.onchange("foreign_retention_amount", "foreign_invoice_amount") - def onchange_foreign_retention_amount(self): - """ - Making sure that the retention amount and the invoice amount are updated when the foreign - retention amount or the foreign invoice amount are changed on the retention line of the - customer retentions. - - This is made to be triggered only when the foreign currency is VEF, as this is the only - case when the foreign retention amount and the foreign iva amount are shown on the views of - the customer retentions, because the amounts of the retention lines are always shown in VEF. - """ - if self.env.context.get("noonchange", False): - return - for line in self.filtered( - lambda l: not l.retention_id or l.retention_id.type == "out_invoice" - ): - if not line.retention_id or line.retention_id.type_retention in ("islr", "municipal"): - line.update( - { - "invoice_amount": line.foreign_invoice_amount - * (1 / line.move_id.foreign_rate) - } - ) - self.env.context = self.with_context(noonchange=True).env.context - line.update( - { - "retention_amount": line.foreign_retention_amount - * (1 / line.move_id.foreign_rate) - } - ) + # @api.onchange("foreign_retention_amount", "foreign_invoice_amount") + # def onchange_foreign_retention_amount(self): + # """ + # Making sure that the retention amount and the invoice amount are updated when the foreign + # retention amount or the foreign invoice amount are changed on the retention line of the + # customer retentions. + + # This is made to be triggered only when the foreign currency is VEF, as this is the only + # case when the foreign retention amount and the foreign iva amount are shown on the views of + # the customer retentions, because the amounts of the retention lines are always shown in VEF. + # """ + # _logger.warning("noonchange context: %s", self.env.context) + # if self.env.context.get("noonchange", False): + # return + # for line in self.filtered( + # lambda l: not l.retention_id or l.retention_id.type == "out_invoice" + # ): + # if not line.move_id: + # continue + # line_with_ctx = line.with_context(noonchange=True) + # if not line.retention_id or line.retention_id.type_retention in ("islr", "municipal"): + # _logger.warning("rate: %s", line.move_id.foreign_rate) + # _logger.warning("inverse rate: %s", line.move_id.foreign_inverse_rate) + # _logger.warning("move id: %s", line.move_id) + # line_with_ctx.update( + # { + # "invoice_amount": line.foreign_invoice_amount + # * (1 / line.move_id.foreign_rate) + # } + # ) + # _logger.warning("rate: %s", line.move_id.foreign_rate) + # _logger.warning("inverse rate: %s", line.move_id.foreign_inverse_rate) + # line_with_ctx.update( + # { + # "retention_amount": line.foreign_retention_amount + # * (1 / line.move_id.foreign_rate) + # } + # ) @api.constrains( "retention_amount", "invoice_total", diff --git a/l10n_ve_payment_extension/views/account_retention_iva.xml b/l10n_ve_payment_extension/views/account_retention_iva.xml index 11c155bd..6f288763 100644 --- a/l10n_ve_payment_extension/views/account_retention_iva.xml +++ b/l10n_ve_payment_extension/views/account_retention_iva.xml @@ -224,4 +224,3 @@ [('type_retention', '=', 'iva'), ('type', '=', 'in_invoice')] - \ No newline at end of file diff --git a/l10n_ve_payment_extension/views/account_retention_municipal.xml b/l10n_ve_payment_extension/views/account_retention_municipal.xml index 90a727c7..b1b22dd8 100644 --- a/l10n_ve_payment_extension/views/account_retention_municipal.xml +++ b/l10n_ve_payment_extension/views/account_retention_municipal.xml @@ -212,4 +212,3 @@ [('type_retention', '=', 'municipal'), ('type', '=', 'in_invoice')] - \ No newline at end of file diff --git a/l10n_ve_pos/controllers/controller.py b/l10n_ve_pos/controllers/controller.py index 1766a694..3aeeae97 100644 --- a/l10n_ve_pos/controllers/controller.py +++ b/l10n_ve_pos/controllers/controller.py @@ -21,7 +21,7 @@ def validate_products_order(self, lines, qty, **kwargs): ) data = {"status": 200, "msg": "Success"} if ( - product_id.detailed_type == "product" + product_id.product_tmpl_id.detailed_type == "product" and product_id.qty_available < qty[product_qty_position] ): data.update( @@ -49,7 +49,7 @@ def validate_products_in_warehouse(self, product_ids, picking_type_id, qty,sell_ ) current_product = product_qty_position product_qty_position += 1 - if product_id and product_id.detailed_type in ['product',]: + if product_id and product_id.product_tmpl_id.detailed_type in ['product',]: stock_quant = request.env["stock.quant"].search( [ ("product_tmpl_id", "=", product_id.id), diff --git a/l10n_ve_sale/__manifest__.py b/l10n_ve_sale/__manifest__.py index acef3a8e..6ab3c6e8 100644 --- a/l10n_ve_sale/__manifest__.py +++ b/l10n_ve_sale/__manifest__.py @@ -7,7 +7,7 @@ "author": "binaural-dev", "website": "https://binauraldev.com/", "category": "Sales/Sales", - "version": "1.1", + "version": "1.2", # any module necessary for this one to work correctly "depends": [ "base", diff --git a/l10n_ve_sale/models/sale_order.py b/l10n_ve_sale/models/sale_order.py index 5dba3133..a8109e38 100644 --- a/l10n_ve_sale/models/sale_order.py +++ b/l10n_ve_sale/models/sale_order.py @@ -452,17 +452,16 @@ def _block_valid_confirm(self): ) def action_confirm(self): - for order in self: - if not order.order_line or all(line.display_type for line in order.order_line): - raise UserError(_("Before confirming an order, you need to add a product.")) skip_not_allow_sell_products_validation = self.env.context.get( "skip_not_allow_sell_products_validation", False ) - if ( - self.env.company.not_allow_sell_products - and not skip_not_allow_sell_products_validation - ): - for order in self: + for order in self: + # Validación de líneas de producto + if not order.order_line or all(line.display_type for line in order.order_line): + raise UserError(_("Before confirming an order, you need to add a product.")) + + # Validación de productos no permitidos para la venta y límite de crédito + if self.env.company.not_allow_sell_products and not skip_not_allow_sell_products_validation: for line in order.order_line: if ( line.product_id.qty_available < line.product_uom_qty @@ -492,7 +491,7 @@ def action_confirm(self): ) ) - order._block_valid_confirm() + order._block_valid_confirm() res = super().action_confirm() @@ -536,4 +535,3 @@ def _compute_amounts(self): for order in self: order.amount_untaxed = order.tax_totals['base_amount_currency'] order.amount_tax = order.tax_totals['tax_amount_currency'] - order.amount_total = order.tax_totals['total_amount_currency'] \ No newline at end of file diff --git a/l10n_ve_stock/models/stock_picking.py b/l10n_ve_stock/models/stock_picking.py index 02cc1a4e..6af79942 100644 --- a/l10n_ve_stock/models/stock_picking.py +++ b/l10n_ve_stock/models/stock_picking.py @@ -276,4 +276,3 @@ def _check_stock_availability_for_pickings(self): error_msg = _( "Insufficient stock:\n%s\n\nAdjust quantitys or request stock for this location." ) % "\n".join(stock_msg) - raise ValidationError(error_msg) \ No newline at end of file diff --git a/l10n_ve_stock/security/l10n_ve_stock_groups.xml b/l10n_ve_stock/security/l10n_ve_stock_groups.xml index 9348c4a6..9d1893a7 100644 --- a/l10n_ve_stock/security/l10n_ve_stock_groups.xml +++ b/l10n_ve_stock/security/l10n_ve_stock_groups.xml @@ -4,57 +4,57 @@ Group Product Available Quantity on Sale - + Show standard price on the product form view - + Hide Print Button for Inventory Transfers - + Hide Override Button for Inventory Transfers - + Hide Unlock/Lock Button for Inventory Transfers - + Hide Cancel Button from Inventory Transfers - + Hide Validate Inventory Transfers Button - + Hide Return Button from Inventory Transfers - + Hide Discard Button from Inventory Transfers - + Block Type Inventory Transfers Expeditions (Do not add a new product to the Dispatch) - + Block Liters Per Unit - + diff --git a/l10n_ve_stock_account/__manifest__.py b/l10n_ve_stock_account/__manifest__.py index 8cd792ad..d8845527 100644 --- a/l10n_ve_stock_account/__manifest__.py +++ b/l10n_ve_stock_account/__manifest__.py @@ -16,7 +16,6 @@ "web", ], "data": [ - "security/res_groups.xml", "security/ir.model.access.csv", "security/res_groups.xml", "data/dispatch_guide_paperformat.xml", diff --git a/l10n_ve_stock_account/models/stock_picking.py b/l10n_ve_stock_account/models/stock_picking.py index 2d05483d..7c34ddce 100644 --- a/l10n_ve_stock_account/models/stock_picking.py +++ b/l10n_ve_stock_account/models/stock_picking.py @@ -1123,8 +1123,8 @@ def get_foreign_currency_is_vef(self): @api.depends('is_consignment', 'is_dispatch_guide', 'transfer_reason_id') def _compute_partner_required(self): - consignment_reason = self.env.ref('l10n_ve_stock_account.transfer_reason_consignment') - for picking in self: + consignment_reason = self.env.ref('l10n_ve_stock_account.transfer_reason_consignment', raise_if_not_found=False) + for picking in self.filtered(lambda p: p.transfer_reason_id): picking.partner_required = ( picking.transfer_reason_id.id == consignment_reason.id and picking.is_dispatch_guide