Files
odoo_source/addons/account/models/account_partial_reconcile.py
T
oco-odoo 6576e1388e [IMP] account: introduce a new field on move lines to avoid inverting tax tags
The sign of tax tags sometimes could get inverted explicitly. It typically happened for misc operations, so :
    - manual entries
    - CABA entries
    - writeoffs created from the reconciliation widget

We had to do things this way in stable to fix those use cases, but it's not ideal, as the tags shown to the user were inverted, so it was a bit confusing, as one would have expected to see the exact same tags as on tax configuration.

We fix that by using a boolean field on account.move.line telling whether or not the sign must be inverted. This allows keeping the same tags as in the tax config, and simplifies the computation of the tax report's multiplicator, making the overall model more intuitive for both user and developper.

Task 2363287

closes odoo/odoo#62536

Related: odoo/enterprise#15031
Related: odoo/upgrade#1983
Signed-off-by: Laurent Smet <smetl@users.noreply.github.com>
2020-12-01 12:40:44 +00:00

599 lines
28 KiB
Python

# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from datetime import date
class AccountPartialReconcile(models.Model):
_name = "account.partial.reconcile"
_description = "Partial Reconcile"
_rec_name = "id"
# ==== Reconciliation fields ====
debit_move_id = fields.Many2one(
comodel_name='account.move.line',
index=True, required=True)
credit_move_id = fields.Many2one(
comodel_name='account.move.line',
index=True, required=True)
full_reconcile_id = fields.Many2one(
comodel_name='account.full.reconcile',
string="Full Reconcile", copy=False)
# ==== Currency fields ====
company_currency_id = fields.Many2one(
comodel_name='res.currency',
string="Company Currency",
related='company_id.currency_id',
help="Utility field to express amount currency")
debit_currency_id = fields.Many2one(
comodel_name='res.currency',
store=True,
compute='_compute_debit_currency_id',
string="Currency of the debit journal item.")
credit_currency_id = fields.Many2one(
comodel_name='res.currency',
store=True,
compute='_compute_credit_currency_id',
string="Currency of the credit journal item.")
# ==== Amount fields ====
amount = fields.Monetary(
currency_field='company_currency_id',
help="Always positive amount concerned by this matching expressed in the company currency.")
debit_amount_currency = fields.Monetary(
currency_field='debit_currency_id',
help="Always positive amount concerned by this matching expressed in the debit line foreign currency.")
credit_amount_currency = fields.Monetary(
currency_field='credit_currency_id',
help="Always positive amount concerned by this matching expressed in the credit line foreign currency.")
# ==== Other fields ====
company_id = fields.Many2one(
comodel_name='res.company',
string="Company", store=True, readonly=False,
related='debit_move_id.company_id')
max_date = fields.Date(
string="Max Date of Matched Lines", store=True,
compute='_compute_max_date',
help="Technical field used to determine at which date this reconciliation needs to be shown on the "
"aged receivable/payable reports.")
# -------------------------------------------------------------------------
# CONSTRAINT METHODS
# -------------------------------------------------------------------------
@api.constrains('debit_currency_id', 'credit_currency_id')
def _check_required_computed_currencies(self):
bad_partials = self.filtered(lambda partial: not partial.debit_currency_id or not partial.credit_currency_id)
if bad_partials:
raise ValidationError(_("Missing foreign currencies on partials having ids: %s", bad_partials.ids))
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('debit_move_id.date', 'credit_move_id.date')
def _compute_max_date(self):
for partial in self:
partial.max_date = max(
partial.debit_move_id.date,
partial.credit_move_id.date
)
@api.depends('debit_move_id')
def _compute_debit_currency_id(self):
for partial in self:
partial.debit_currency_id = partial.debit_move_id.currency_id \
or partial.debit_move_id.company_currency_id
@api.depends('credit_move_id')
def _compute_credit_currency_id(self):
for partial in self:
partial.credit_currency_id = partial.credit_move_id.currency_id \
or partial.credit_move_id.company_currency_id
# -------------------------------------------------------------------------
# LOW-LEVEL METHODS
# -------------------------------------------------------------------------
def unlink(self):
# OVERRIDE to unlink full reconcile linked to the current partials
# and reverse the tax cash basis journal entries.
# Avoid cyclic unlink calls when removing the partials that could remove some full reconcile
# and then, loop again and again.
if not self:
return True
# Reverse all exchange moves at once.
moves_to_reverse = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self.ids)])
today = fields.Date.context_today(self)
default_values_list = [{
'date': move.date if move.date > (move.company_id.period_lock_date or date.min) else today,
'ref': _('Reversal of: %s') % move.name,
} for move in moves_to_reverse]
moves_to_reverse._reverse_moves(default_values_list, cancel=True)
# Unlink partials then the full in this order to avoid a recursive call to the same partials.
full_to_unlink = self.full_reconcile_id
res = super().unlink()
full_to_unlink.unlink()
return res
# -------------------------------------------------------------------------
# RECONCILIATION METHODS
# -------------------------------------------------------------------------
def _collect_tax_cash_basis_values(self):
''' Collect all information needed to create the tax cash basis journal entries on the current partials.
:return: A dictionary mapping each move_id to the result of 'account_move._collect_tax_cash_basis_values'.
Also, add the 'partials' keys being a list of dictionary, one for each partial to process:
* partial: The account.partial.reconcile record.
* percentage: The reconciled percentage represented by the partial.
* payment_rate: The applied rate of this partial.
'''
tax_cash_basis_values_per_move = {}
if not self:
return {}
for partial in self:
for move in {partial.debit_move_id.move_id, partial.credit_move_id.move_id}:
# Collect data about cash basis.
if move.id not in tax_cash_basis_values_per_move:
tax_cash_basis_values_per_move[move.id] = move._collect_tax_cash_basis_values()
# Nothing to process on the move.
if not tax_cash_basis_values_per_move.get(move.id):
continue
move_values = tax_cash_basis_values_per_move[move.id]
# Check the cash basis configuration only when at least one cash basis tax entry need to be created.
journal = partial.company_id.tax_cash_basis_journal_id
if not journal:
raise UserError(_("There is no tax cash basis journal defined for the '%s' company.\n"
"Configure it in Accounting/Configuration/Settings") % partial.company_id.display_name)
partial_amount = 0.0
partial_amount_currency = 0.0
rate_amount = 0.0
rate_amount_currency = 0.0
if partial.debit_move_id.move_id == move:
partial_amount += partial.amount
partial_amount_currency += partial.debit_amount_currency
rate_amount -= partial.credit_move_id.balance
rate_amount_currency -= partial.credit_move_id.amount_currency
if partial.credit_move_id.move_id == move:
partial_amount += partial.amount
partial_amount_currency += partial.credit_amount_currency
rate_amount += partial.debit_move_id.balance
rate_amount_currency += partial.debit_move_id.amount_currency
if move_values['currency'] == move.company_id.currency_id:
# Percentage made on company's currency.
percentage = partial_amount / move_values['total_balance']
else:
# Percentage made on foreign currency.
percentage = partial_amount_currency / move_values['total_amount_currency']
if rate_amount:
payment_rate = rate_amount_currency / rate_amount
else:
payment_rate = 0.0
partial_vals = {
'partial': partial,
'percentage': percentage,
'payment_rate': payment_rate,
}
# Add partials.
move_values.setdefault('partials', [])
move_values['partials'].append(partial_vals)
# Clean-up moves having nothing to process.
return {k: v for k, v in tax_cash_basis_values_per_move.items() if v}
@api.model
def _prepare_cash_basis_base_line_vals(self, base_line, balance, amount_currency):
''' Prepare the values to be used to create the cash basis journal items for the tax base line
passed as parameter.
:param base_line: An account.move.line being the base of some taxes.
:param balance: The balance to consider for this line.
:param amount_currency: The balance in foreign currency to consider for this line.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
account = base_line.company_id.account_cash_basis_base_account_id or base_line.account_id
return {
'name': base_line.move_id.name,
'debit': balance if balance > 0.0 else 0.0,
'credit': -balance if balance < 0.0 else 0.0,
'amount_currency': amount_currency,
'currency_id': base_line.currency_id.id,
'partner_id': base_line.partner_id.id,
'account_id': account.id,
'tax_ids': [(6, 0, base_line.tax_ids.ids)],
'tax_tag_ids': [(6, 0, base_line.tax_tag_ids.ids)],
'tax_exigible': True,
'tax_tag_invert': base_line.tax_tag_invert,
}
@api.model
def _prepare_cash_basis_counterpart_base_line_vals(self, cb_base_line_vals):
''' Prepare the move line used as a counterpart of the line created by
_prepare_cash_basis_base_line_vals.
:param cb_base_line_vals: The line returned by _prepare_cash_basis_base_line_vals.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
return {
'name': cb_base_line_vals['name'],
'debit': cb_base_line_vals['credit'],
'credit': cb_base_line_vals['debit'],
'account_id': cb_base_line_vals['account_id'],
'amount_currency': -cb_base_line_vals['amount_currency'],
'currency_id': cb_base_line_vals['currency_id'],
'partner_id': cb_base_line_vals['partner_id'],
'tax_exigible': True,
}
@api.model
def _prepare_cash_basis_tax_line_vals(self, tax_line, balance, amount_currency):
''' Prepare the move line corresponding to a tax in the cash basis entry.
:param tax_line: An account.move.line record being a tax line.
:param balance: The balance to consider for this line.
:param amount_currency: The balance in foreign currency to consider for this line.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
return {
'name': tax_line.name,
'debit': balance if balance > 0.0 else 0.0,
'credit': -balance if balance < 0.0 else 0.0,
'tax_base_amount': tax_line.tax_base_amount,
'tax_repartition_line_id': tax_line.tax_repartition_line_id.id,
'tax_ids': [(6, 0, tax_line.tax_ids.ids)],
'tax_tag_ids': [(6, 0, tax_line.tax_tag_ids.ids)],
'account_id': tax_line.tax_repartition_line_id.account_id.id or tax_line.account_id.id,
'amount_currency': amount_currency,
'currency_id': tax_line.currency_id.id,
'partner_id': tax_line.partner_id.id,
'tax_exigible': True,
# No need to set tax_tag_invert as on the base line; it will be computed from the repartition line
}
@api.model
def _prepare_cash_basis_counterpart_tax_line_vals(self, tax_line, cb_tax_line_vals):
''' Prepare the move line used as a counterpart of the line created by
_prepare_cash_basis_tax_line_vals.
:param tax_line: An account.move.line record being a tax line.
:param cb_tax_line_vals: The result of _prepare_cash_basis_counterpart_tax_line_vals.
:return: A python dictionary that could be passed to the create method of
account.move.line.
'''
return {
'name': cb_tax_line_vals['name'],
'debit': cb_tax_line_vals['credit'],
'credit': cb_tax_line_vals['debit'],
'account_id': tax_line.account_id.id,
'amount_currency': -cb_tax_line_vals['amount_currency'],
'currency_id': cb_tax_line_vals['currency_id'],
'partner_id': cb_tax_line_vals['partner_id'],
'tax_exigible': True,
}
@api.model
def _get_cash_basis_base_line_grouping_key_from_vals(self, base_line_vals):
''' Get the grouping key of a cash basis base line that hasn't yet been created.
:param base_line_vals: The values to create a new account.move.line record.
:return: The grouping key as a tuple.
'''
return (
base_line_vals['currency_id'],
base_line_vals['partner_id'],
base_line_vals['account_id'],
tuple(base_line_vals['tax_ids'][0][2]), # Decode [(6, 0, [...])] command
tuple(base_line_vals['tax_tag_ids'][0][2]), # Decode [(6, 0, [...])] command
)
@api.model
def _get_cash_basis_base_line_grouping_key_from_record(self, base_line, account=None):
''' Get the grouping key of a journal item being a base line.
:param base_line: An account.move.line record.
:param account: Optional account to shadow the current base_line one.
:return: The grouping key as a tuple.
'''
return (
base_line.currency_id.id,
base_line.partner_id.id,
(account or base_line.account_id).id,
tuple(base_line.tax_ids.ids),
tuple(base_line.tax_tag_ids.ids),
)
@api.model
def _get_cash_basis_tax_line_grouping_key_from_vals(self, tax_line_vals):
''' Get the grouping key of a cash basis tax line that hasn't yet been created.
:param tax_line_vals: The values to create a new account.move.line record.
:return: The grouping key as a tuple.
'''
return (
tax_line_vals['currency_id'],
tax_line_vals['partner_id'],
tax_line_vals['account_id'],
tuple(tax_line_vals['tax_ids'][0][2]), # Decode [(6, 0, [...])] command
tuple(tax_line_vals['tax_tag_ids'][0][2]), # Decode [(6, 0, [...])] command
tax_line_vals['tax_repartition_line_id'],
)
@api.model
def _get_cash_basis_tax_line_grouping_key_from_record(self, tax_line, account=None):
''' Get the grouping key of a journal item being a tax line.
:param tax_line: An account.move.line record.
:param account: Optional account to shadow the current tax_line one.
:return: The grouping key as a tuple.
'''
return (
tax_line.currency_id.id,
tax_line.partner_id.id,
(account or tax_line.account_id).id,
tuple(tax_line.tax_ids.ids),
tuple(tax_line.tax_tag_ids.ids),
tax_line.tax_repartition_line_id.id,
)
@api.model
def _fix_cash_basis_full_balance_coverage(self, move_values, partial_values, pending_cash_basis_lines, partial_lines_to_create):
''' This method is used to ensure the full coverage of the current move when it becomes fully paid.
For example, suppose a line of 0.03 paid 50-50. Without this method, each cash basis entry will report
0.03 / 0.5 = 0.015 ~ 0.02 per cash entry on the tax report as base amount, for a total of 0.04.
This is wrong because we expect 0.03.on the tax report as base amount. This is wrong because we expect 0.03.
:param move_values: The collected values about cash basis for the current move.
:param partial_values: The collected values about cash basis for the current partial.
:param pending_cash_basis_lines: The previously generated lines during this reconciliation but not yet created.
:param partial_lines_to_create: The generated lines for the current and last partial making the move fully paid.
'''
residual_amount_per_group = {}
move = move_values['move']
# ==========================================================================
# Part 1:
# Add the balance of all journal items that are not tax exigible in order to
# ensure the exact balance will be report on the Tax Report.
# This part is needed when the move will be fully paid after the current
# reconciliation.
# ==========================================================================
for line in move_values['to_process_lines']:
if line.tax_repartition_line_id:
# Tax line.
grouping_key = self._get_cash_basis_tax_line_grouping_key_from_record(
line,
account=line.tax_repartition_line_id.account_id,
)
residual_amount_per_group.setdefault(grouping_key, 0.0)
residual_amount_per_group[grouping_key] += line['amount_currency']
elif line.tax_ids:
# Base line.
grouping_key = self._get_cash_basis_base_line_grouping_key_from_record(
line,
account=line.company_id.account_cash_basis_base_account_id,
)
residual_amount_per_group.setdefault(grouping_key, 0.0)
residual_amount_per_group[grouping_key] += line['amount_currency']
# ==========================================================================
# Part 2:
# Subtract all previously created cash basis journal items during previous
# reconciliation.
# ==========================================================================
previous_tax_cash_basis_moves = self.env['account.move'].search([
'|',
('tax_cash_basis_rec_id', 'in', self.ids),
('tax_cash_basis_move_id', '=', move.id),
])
for line in previous_tax_cash_basis_moves.line_ids:
if line.tax_repartition_line_id:
# Tax line.
grouping_key = self._get_cash_basis_tax_line_grouping_key_from_record(line)
elif line.tax_ids:
# Base line.
grouping_key = self._get_cash_basis_base_line_grouping_key_from_record(line)
else:
continue
if grouping_key not in residual_amount_per_group:
# The grouping_key is unknown regarding the current lines.
# Maybe this move has been created before migration and then,
# we are not able to ensure the full coverage of the balance.
return
residual_amount_per_group[grouping_key] -= line['amount_currency']
# ==========================================================================
# Part 3:
# Subtract all pending cash basis journal items that will be created during
# this reconciliation.
# ==========================================================================
for grouping_key, balance in pending_cash_basis_lines:
residual_amount_per_group[grouping_key] -= balance
# ==========================================================================
# Part 4:
# Fix the current cash basis journal items in progress by replacing the
# balance by the residual one.
# ==========================================================================
for grouping_key, aggregated_vals in partial_lines_to_create.items():
line_vals = aggregated_vals['vals']
amount_currency = residual_amount_per_group[grouping_key]
balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
line_vals.update({
'debit': balance if balance > 0.0 else 0.0,
'credit': -balance if balance < 0.0 else 0.0,
'amount_currency': amount_currency,
})
def _create_tax_cash_basis_moves(self):
''' Create the tax cash basis journal entries.
:return: The newly created journal entries.
'''
tax_cash_basis_values_per_move = self._collect_tax_cash_basis_values()
moves_to_create = []
to_reconcile_after = []
for move_values in tax_cash_basis_values_per_move.values():
move = move_values['move']
pending_cash_basis_lines = []
for i, partial_values in enumerate(move_values['partials']):
partial = partial_values['partial']
is_last_partial = i == len(move_values['partials']) - 1
# Init the journal entry.
move_vals = {
'move_type': 'entry',
'date': partial.max_date,
'ref': move.name,
'journal_id': partial.company_id.tax_cash_basis_journal_id.id,
'line_ids': [],
'tax_cash_basis_rec_id': partial.id,
'tax_cash_basis_move_id': move.id,
}
# Tracking of lines grouped all together.
# Used to reduce the number of generated lines and to avoid rounding issues.
partial_lines_to_create = {}
for line in move_values['to_process_lines']:
# ==========================================================================
# Compute the balance of the current line on the cash basis entry.
# This balance is a percentage representing the part of the journal entry
# that is actually paid by the current partial.
# ==========================================================================
# Percentage expressed in the foreign currency.
amount_currency = line.currency_id.round(line.amount_currency * partial_values['percentage'])
balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
# ==========================================================================
# Prepare the mirror cash basis journal item of the current line.
# Group them all together as much as possible to reduce the number of
# generated journal items.
# Also track the computed balance in order to avoid rounding issues when
# the journal entry will be fully paid. At that case, we expect the exact
# amount of each line has been covered by the cash basis journal entries
# and well reported in the Tax Report.
# ==========================================================================
if line.tax_repartition_line_id:
# Tax line.
cb_tax_line_vals = self._prepare_cash_basis_tax_line_vals(line, balance, amount_currency)
grouping_key = self._get_cash_basis_tax_line_grouping_key_from_vals(cb_tax_line_vals)
partial_lines_to_create[grouping_key] = {
'tax_line': line,
'vals': cb_tax_line_vals,
}
elif line.tax_ids:
# Base line.
cb_base_line_vals = self._prepare_cash_basis_base_line_vals(line, balance, amount_currency)
grouping_key = self._get_cash_basis_base_line_grouping_key_from_vals(cb_base_line_vals)
if grouping_key in partial_lines_to_create:
aggregated_vals = partial_lines_to_create[grouping_key]['vals']
balance = aggregated_vals['debit'] - aggregated_vals['credit']
balance += cb_base_line_vals['debit'] - cb_base_line_vals['credit']
aggregated_vals.update({
'debit': balance if balance > 0.0 else 0.0,
'credit': -balance if balance < 0.0 else 0.0,
})
aggregated_vals['amount_currency'] += cb_base_line_vals['amount_currency']
else:
partial_lines_to_create[grouping_key] = {
'vals': cb_base_line_vals,
}
# ==========================================================================
# Ensure the full coverage by replacing the balance of the journal items
# created by the last partial.
# ==========================================================================
if move_values['is_fully_paid'] and is_last_partial:
self._fix_cash_basis_full_balance_coverage(
move_values,
partial_values,
pending_cash_basis_lines,
partial_lines_to_create,
)
# ==========================================================================
# Create the counterpart journal items.
# ==========================================================================
# To be able to retrieve the correct matching between the tax lines to reconcile
# later, the lines will be created using a specific sequence.
sequence = 0
for grouping_key, aggregated_vals in partial_lines_to_create.items():
line_vals = aggregated_vals['vals']
line_vals['sequence'] = sequence
pending_cash_basis_lines.append((grouping_key, line_vals['amount_currency']))
if 'tax_repartition_line_id' in line_vals:
# Tax line.
tax_line = aggregated_vals['tax_line']
counterpart_line_vals = self._prepare_cash_basis_counterpart_tax_line_vals(tax_line, line_vals)
counterpart_line_vals['sequence'] = sequence + 1
if tax_line.account_id.reconcile:
move_index = len(moves_to_create)
to_reconcile_after.append((tax_line, move_index, counterpart_line_vals['sequence']))
else:
# Base line.
counterpart_line_vals = self._prepare_cash_basis_counterpart_base_line_vals(line_vals)
counterpart_line_vals['sequence'] = sequence + 1
sequence += 2
move_vals['line_ids'] += [(0, 0, counterpart_line_vals), (0, 0, line_vals)]
moves_to_create.append(move_vals)
moves = self.env['account.move'].create(moves_to_create)
moves._post(soft=False)
# Reconcile the tax lines being on a reconcile tax basis transfer account.
for line, move_index, sequence in to_reconcile_after:
counterpart_line = moves[move_index].line_ids.filtered(lambda line: line.sequence == sequence)
# When dealing with tiny amounts, the line could have a zero amount and then, be already reconciled.
if counterpart_line.reconciled:
continue
(line + counterpart_line).reconcile()
return moves