diff options
| author | Laurent Smet <las@odoo.com> | 2020-04-17 07:43:33 +0000 |
|---|---|---|
| committer | Laurent Smet <las@odoo.com> | 2020-06-15 08:36:04 +0000 |
| commit | beccf82e09d536255d9d9cb9bfe58ebae2559843 (patch) | |
| tree | e031e879cb607248f437acc351540324c2f27b31 | |
| parent | f074772d259656a08376bd0519e5648997e700ba (diff) | |
[REF] account: journal items reconciliation (part 1/2)
This commits refactors the code of the reconciliation, in order to facilitate the process of complex use cases, namely multi-currencies or cash-basis-taxes related (see details below). It also prepares the code for a second refactoring where we will save on each journal items the amount_currency and currency_id field (even in case of operation made in company currency), also in the sake of simplification.
1) Multi-currencies:
- the account.partial.reconcile model now will have dedicated columns to specify the amount of the partial reconciliation in the debit_line_id currency and the credit_line_id currency. That comes in handy when dealing with journal items having different secondary currencies, but also allows some simplification.
- residual_amount_currency computation changed accordingly
- moved models account.full.reconcile and account.partial.reconcile in their dedicated .py file
2) Cash basis taxes
- cash basis entries now handle correctly rounding errors to make sure the exact amount of gets reported when the reconciliation becomes full.
- the account for the base amount of cash basis entries has to be set, now, on the company instead of on each cash basis tax.
- that new 'property' field can be set at the CoA installation via the field property_cash_basis_base_account_id of account.chart.template, or going through the accounting settings.
Was task task: 2243420
Was PR #50308
Related: odoo/upgrade#1121
Related: odoo/enterprise#10252
Signed-off-by: Quentin De Paoli (qdp) <qdp@openerp.com>
24 files changed, 3262 insertions, 1568 deletions
diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py index 951796b3282..d9e2695b020 100644 --- a/addons/account/models/__init__.py +++ b/addons/account/models/__init__.py @@ -12,6 +12,8 @@ from . import account_reconcile_model from . import account_payment_term from . import account_move from . import account_analytic_default +from . import account_partial_reconcile +from . import account_full_reconcile from . import account_payment from . import account_bank_statement from . import chart_template diff --git a/addons/account/models/account_full_reconcile.py b/addons/account/models/account_full_reconcile.py new file mode 100644 index 00000000000..b8ef91ec1fa --- /dev/null +++ b/addons/account/models/account_full_reconcile.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +from odoo import api, fields, models, _ + + +class AccountFullReconcile(models.Model): + _name = "account.full.reconcile" + _description = "Full Reconcile" + + name = fields.Char(string='Number', required=True, copy=False, default=lambda self: self.env['ir.sequence'].next_by_code('account.reconcile')) + partial_reconcile_ids = fields.One2many('account.partial.reconcile', 'full_reconcile_id', string='Reconciliation Parts') + reconciled_line_ids = fields.One2many('account.move.line', 'full_reconcile_id', string='Matched Journal Items') + exchange_move_id = fields.Many2one('account.move') + + def unlink(self): + """ When removing a full reconciliation, we need to revert the eventual journal entries we created to book the + fluctuation of the foreign currency's exchange rate. + We need also to reconcile together the origin currency difference line and its reversal in order to completely + cancel the currency difference entry on the partner account (otherwise it will still appear on the aged balance + for example). + """ + # Avoid cyclic unlink calls when removing partials. + if not self: + return True + + moves_to_reverse = self.exchange_move_id + + res = super().unlink() + + # Reverse all exchange moves at once. + today = fields.Date.today() + default_values_list = [{ + 'date': today, + 'ref': _('Reversal of: %s') % move.name, + } for move in moves_to_reverse] + moves_to_reverse._reverse_moves(default_values_list, cancel=True) + + return res diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index ddd9a822dfb..e197d28d037 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -216,6 +216,10 @@ class AccountMove(models.Model): string='Tax Cash Basis Entry of', help="Technical field used to keep track of the tax cash basis reconciliation. " "This is needed when cancelling the source: it will post the inverse journal entry to cancel that part too.") + tax_cash_basis_move_id = fields.Many2one( + comodel_name='account.move', + string="Origin Tax Cash Basis Entry", + help="The journal entry from which this tax cash basis journal entry has been created.") # ==== Auto-post feature fields ==== auto_post = fields.Boolean(string='Post Automatically', default=False, @@ -266,10 +270,10 @@ class AccountMove(models.Model): # ==== Payment widget fields ==== invoice_outstanding_credits_debits_widget = fields.Text(groups="account.group_account_invoice,account.group_account_readonly", compute='_compute_payments_widget_to_reconcile_info') - invoice_payments_widget = fields.Text(groups="account.group_account_invoice,account.group_account_readonly", - compute='_compute_payments_widget_reconciled_info') invoice_has_outstanding = fields.Boolean(groups="account.group_account_invoice,account.group_account_readonly", compute='_compute_payments_widget_to_reconcile_info') + invoice_payments_widget = fields.Text(groups="account.group_account_invoice,account.group_account_readonly", + compute='_compute_payments_widget_reconciled_info') # ==== Vendor bill fields ==== invoice_vendor_bill_id = fields.Many2one('account.move', store=False, @@ -1289,71 +1293,82 @@ class AccountMove(models.Model): move.invoice_outstanding_credits_debits_widget = json.dumps(False) move.invoice_has_outstanding = False - if move.state != 'posted' or move.payment_state not in ('not_paid', 'partial') or not move.is_invoice(include_receipts=True): + if move.state != 'posted' \ + or move.payment_state not in ('not_paid', 'partial') \ + or not move.is_invoice(include_receipts=True): continue - pay_term_line_ids = move.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - domain = [('account_id', 'in', pay_term_line_ids.mapped('account_id').ids), - ('move_id.state', '=', 'posted'), - ('partner_id', '=', move.commercial_partner_id.id), - ('reconciled', '=', False), '|', ('amount_residual', '!=', 0.0), - ('amount_residual_currency', '!=', 0.0)] + pay_term_lines = move.line_ids\ + .filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + + domain = [ + ('account_id', 'in', pay_term_lines.account_id.ids), + ('move_id.state', '=', 'posted'), + ('partner_id', '=', move.commercial_partner_id.id), + ('reconciled', '=', False), + '|', ('amount_residual', '!=', 0.0), ('amount_residual_currency', '!=', 0.0), + ] + + payments_widget_vals = {'outstanding': True, 'content': [], 'move_id': move.id} if move.is_inbound(): - domain.extend([('credit', '>', 0), ('debit', '=', 0)]) - type_payment = _('Outstanding credits') + domain.append(('balance', '<', 0.0)) + payments_widget_vals['title'] = _('Outstanding credits') else: - domain.extend([('credit', '=', 0), ('debit', '>', 0)]) - type_payment = _('Outstanding debits') - info = {'title': '', 'outstanding': True, 'content': [], 'move_id': move.id} - lines = self.env['account.move.line'].search(domain) - currency_id = move.currency_id - if len(lines) != 0: - for line in lines: - # get the outstanding residual value in invoice currency - if line.currency_id and line.currency_id == move.currency_id: - amount_to_show = abs(line.amount_residual_currency) - else: - currency = line.company_id.currency_id - amount_to_show = currency._convert(abs(line.amount_residual), move.currency_id, move.company_id, - line.date or fields.Date.today()) - if float_is_zero(amount_to_show, precision_rounding=move.currency_id.rounding): - continue - info['content'].append({ - 'journal_name': line.ref or line.move_id.name, - 'amount': amount_to_show, - 'currency': currency_id.symbol, - 'id': line.id, - 'position': currency_id.position, - 'digits': [69, move.currency_id.decimal_places], - 'payment_date': fields.Date.to_string(line.date), - }) - info['title'] = type_payment - move.invoice_outstanding_credits_debits_widget = json.dumps(info) - move.invoice_has_outstanding = True + domain.append(('balance', '>', 0.0)) + payments_widget_vals['title'] = _('Outstanding debits') - def _get_reconciled_info_JSON_values(self): - self.ensure_one() - foreign_currency = self.currency_id if self.currency_id != self.company_id.currency_id else False - - reconciled_vals = [] - pay_term_line_ids = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - partials = pay_term_line_ids.mapped('matched_debit_ids') + pay_term_line_ids.mapped('matched_credit_ids') - for partial in partials: - counterpart_lines = partial.debit_move_id + partial.credit_move_id - counterpart_line = counterpart_lines.filtered(lambda line: line not in self.line_ids) - - if foreign_currency and partial.currency_id == foreign_currency: - amount = partial.amount_currency + if move.currency_id == move.company_currency_id: + currency = False else: - amount = partial.company_currency_id._convert(partial.amount, self.currency_id, self.company_id, self.date) + currency = move.currency_id + + for line in self.env['account.move.line'].search(domain): + + if currency and line.currency_id == currency: + # Same foreign currency. + amount = abs(line.amount_residual_currency) + elif currency: + # Different foreign currencies. + amount = move.company_currency_id._convert( + abs(line.amount_residual), + move.currency_id, + move.company_id, + line.date, + ) + else: + # Company's currency. + amount = abs(line.amount_residual) - if float_is_zero(amount, precision_rounding=self.currency_id.rounding): + if move.currency_id.is_zero(amount): + continue + + payments_widget_vals['content'].append({ + 'journal_name': line.ref or line.move_id.name, + 'amount': amount, + 'currency': move.currency_id.symbol, + 'id': line.id, + 'move_id': line.move_id.id, + 'position': move.currency_id.position, + 'digits': [69, move.currency_id.decimal_places], + 'payment_date': fields.Date.to_string(line.date), + }) + + if not payments_widget_vals['content']: continue - ref = counterpart_line.move_id.name + move.invoice_outstanding_credits_debits_widget = json.dumps(payments_widget_vals) + move.invoice_has_outstanding = True + + def _get_reconciled_info_JSON_values(self): + self.ensure_one() + + reconciled_vals = [] + for partial, amount, counterpart_line in self._get_reconciled_invoices_partials(): if counterpart_line.move_id.ref: - ref += ' (' + counterpart_line.move_id.ref + ')' + reconciliation_ref = '%s (%s)' % (counterpart_line.move_id.name, counterpart_line.move_id.ref) + else: + reconciliation_ref = counterpart_line.move_id.name reconciled_vals.append({ 'name': counterpart_line.name, @@ -1362,29 +1377,26 @@ class AccountMove(models.Model): 'currency': self.currency_id.symbol, 'digits': [69, self.currency_id.decimal_places], 'position': self.currency_id.position, - 'date': counterpart_line.payment_id.date or counterpart_line.date, + 'date': counterpart_line.date, 'payment_id': counterpart_line.id, + 'partial_id': partial.id, 'account_payment_id': counterpart_line.payment_id.id, 'payment_method_name': counterpart_line.payment_id.payment_method_id.name if counterpart_line.journal_id.type == 'bank' else None, 'move_id': counterpart_line.move_id.id, - 'ref': ref, + 'ref': reconciliation_ref, }) return reconciled_vals @api.depends('move_type', 'line_ids.amount_residual') def _compute_payments_widget_reconciled_info(self): for move in self: - if move.state != 'posted' or not move.is_invoice(include_receipts=True): - move.invoice_payments_widget = json.dumps(False) - continue - reconciled_vals = move._get_reconciled_info_JSON_values() - if reconciled_vals: - info = { - 'title': _('Less Payment'), - 'outstanding': False, - 'content': reconciled_vals, - } - move.invoice_payments_widget = json.dumps(info, default=date_utils.json_default) + payments_widget_vals = {'title': _('Less Payment'), 'outstanding': False, 'content': []} + + if move.state == 'posted' and move.is_invoice(include_receipts=True): + payments_widget_vals['content'] = move._get_reconciled_info_JSON_values() + + if payments_widget_vals['content']: + move.invoice_payments_widget = json.dumps(payments_widget_vals, default=date_utils.json_default) else: move.invoice_payments_widget = json.dumps(False) @@ -1822,6 +1834,78 @@ class AccountMove(models.Model): }[self.move_type] # ------------------------------------------------------------------------- + # RECONCILIATION METHODS + # ------------------------------------------------------------------------- + + def _collect_tax_cash_basis_values(self): + ''' Collect all information needed to create the tax cash basis journal entries: + - Determine if a tax cash basis journal entry is needed. + - Compute the lines to be processed and the amounts needed to compute a percentage. + :return: A dictionary: + * move: The current account.move record passed as parameter. + * to_process_lines: An account.move.line recordset being not exigible on the tax report. + * currency: The currency on which the percentage has been computed. + * total_balance: sum(payment_term_lines.mapped('balance'). + * total_residual: sum(payment_term_lines.mapped('amount_residual'). + * total_amount_currency: sum(payment_term_lines.mapped('amount_currency'). + * total_residual_currency: sum(payment_term_lines.mapped('amount_residual_currency'). + * is_fully_paid: A flag indicating the current move is now fully paid. + ''' + self.ensure_one() + + values = { + 'move': self, + 'to_process_lines': self.env['account.move.line'], + 'payment_term_lines': self.env['account.move.line'], + 'total_balance': 0.0, + 'total_residual': 0.0, + 'total_amount_currency': 0.0, + 'total_residual_currency': 0.0, + } + + currencies = set() + has_term_lines = False + for line in self.line_ids: + if line.account_internal_type in ('receivable', 'payable'): + sign = 1 if line.balance > 0.0 else -1 + + currencies.add(line.currency_id or line.company_currency_id) + has_term_lines = True + values['total_balance'] += sign * line.balance + values['total_residual'] += sign * line.amount_residual + values['total_amount_currency'] += sign * line.amount_currency + values['total_residual_currency'] += sign * line.amount_residual_currency + + elif not line.tax_exigible: + + values['to_process_lines'] += line + currencies.add(line.currency_id or line.company_currency_id) + + if not values['to_process_lines'] or not has_term_lines: + return None + + # Compute the currency on which made the percentage. + if len(currencies) == 1: + values['currency'] = list(currencies)[0] + else: + # Don't support the case where there is multiple involved currencies. + return None + + if len(values['payment_term_lines'].account_id) > 1: + # Don't support the case where there is multiple involved receivable/payable accounts. + # It could lead to some weird situation regarding the cash basis exchange difference + # journal items. + return None + + # Determine is the move is now fully paid. + if values['currency'] == self.company_id.currency_id: + values['is_fully_paid'] = values['currency'].is_zero(values['total_residual']) + else: + values['is_fully_paid'] = values['currency'].is_zero(values['total_residual_currency']) + + return values + + # ------------------------------------------------------------------------- # BUSINESS METHODS # ------------------------------------------------------------------------- @@ -1923,7 +2007,6 @@ class AccountMove(models.Model): else: raise UserError(_('The combination of reference model and reference type on the journal is not implemented')) - def _get_move_display_name(self, show_ref=False): ''' Helper to get the display name of an invoice depending of its type. :param show_ref: A flag indicating of the display name must include or not the journal entry reference. @@ -1954,41 +2037,6 @@ class AccountMove(models.Model): self.ensure_one() return self.partner_id.address_get(['delivery'])['delivery'] - def _get_cash_basis_matched_percentage(self): - """Compute the percentage to apply for cash basis method. This value is relevant only for moves that - involve journal items on receivable or payable accounts. - """ - self.ensure_one() - query = ''' - SELECT - ( - SELECT COALESCE(SUM(line.balance), 0.0) - FROM account_move_line line - JOIN account_account account ON account.id = line.account_id - JOIN account_account_type account_type ON account_type.id = account.user_type_id - WHERE line.move_id = %s AND account_type.type IN ('receivable', 'payable') - ) AS total_amount, - ( - SELECT COALESCE(SUM(partial.amount), 0.0) - FROM account_move_line line - JOIN account_account account ON account.id = line.account_id - JOIN account_account_type account_type ON account_type.id = account.user_type_id - LEFT JOIN account_partial_reconcile partial ON - partial.debit_move_id = line.id - OR - partial.credit_move_id = line.id - WHERE line.move_id = %s AND account_type.type IN ('receivable', 'payable') - ) AS total_reconciled - ''' - params = [self.id, self.id] - self._cr.execute(query, params) - total_amount, total_reconciled = self._cr.fetchone() - currency = self.company_id.currency_id - if float_is_zero(total_amount, precision_rounding=currency.rounding): - return 1.0 - else: - return abs(currency.round(total_reconciled) / currency.round(total_amount)) - def _get_reconciled_payments(self): """Helper used to retrieve the reconciled payments on this journal entry""" reconciled_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) @@ -2012,32 +2060,17 @@ class AccountMove(models.Model): def _get_reconciled_invoices_partials(self): ''' Helper to retrieve the details about reconciled invoices. - :return A list of tuple (amount, rate, invoice_line). + :return A list of tuple (partial, amount, invoice_line). ''' self.ensure_one() - - def compute_tuple(partial, payment_line, invoice_line): - if payment_line.currency_id == invoice_line.currency_id: - # Same currency. - exchange_rate = None - amount_paid = partial.amount_currency - elif invoice_line.currency_id: - # Different currency on the payment. - exchange_rate = payment_line.amount_currency / payment_line.balance - amount_paid = partial.company_currency_id._convert(partial.amount, invoice_line.currency_id, invoice_line.company_id, invoice_line.date) - else: - # No foreign currency on the payment, don't care about the invoice's currency. - exchange_rate = None - amount_paid = partial.amount - return (amount_paid, exchange_rate, invoice_line) - - receivable_lines = self.line_ids.filtered(lambda line: line.account_internal_type == 'receivable') + pay_term_lines = self.line_ids\ + .filtered(lambda line: line.account_internal_type in ('receivable', 'payable')) invoice_partials = [] - for partial in receivable_lines.matched_debit_ids: - invoice_partials.append(compute_tuple(partial, partial.credit_move_id, partial.debit_move_id)) - for partial in receivable_lines.matched_credit_ids: - invoice_partials.append(compute_tuple(partial, partial.debit_move_id, partial.credit_move_id)) + for partial in pay_term_lines.matched_debit_ids: + invoice_partials.append((partial, partial.credit_amount_currency, partial.debit_move_id)) + for partial in pay_term_lines.matched_credit_ids: + invoice_partials.append((partial, partial.debit_amount_currency, partial.credit_move_id)) return invoice_partials def _reverse_move_vals(self, default_values, cancel=True): @@ -2346,11 +2379,25 @@ class AccountMove(models.Model): return self.post() def js_assign_outstanding_line(self, line_id): + ''' Called by the 'payment' widget to reconcile a suggested journal item to the present + invoice. + + :param line_id: The id of the line to reconcile with the current invoice. + ''' self.ensure_one() lines = self.env['account.move.line'].browse(line_id) lines += self.line_ids.filtered(lambda line: line.account_id == lines[0].account_id and not line.reconciled) return lines.reconcile() + def js_remove_outstanding_partial(self, partial_id): + ''' Called by the 'payment' widget to remove a reconciled entry to the present invoice. + + :param partial_id: The id of an existing partial reconciled with the current invoice. + ''' + self.ensure_one() + partial = self.env['account.partial.reconcile'].browse(partial_id) + return partial.unlink() + def button_draft(self): AccountMoveLine = self.env['account.move.line'] excluded_move_ids = [] @@ -2688,7 +2735,7 @@ class AccountMoveLine(models.Model): currency_field='always_set_currency_id') price_total = fields.Monetary(string='Total', store=True, readonly=True, currency_field='always_set_currency_id') - reconciled = fields.Boolean(compute='_amount_residual', store=True) + reconciled = fields.Boolean(compute='_compute_amount_residual', store=True) blocked = fields.Boolean(string='No Follow-up', default=False, help="You can check this box to mark this journal item as a litigation with the associated partner") date_maturity = fields.Date(string='Due Date', index=True, tracking=True, @@ -2737,10 +2784,10 @@ class AccountMoveLine(models.Model): # ==== Reconciliation fields ==== amount_residual = fields.Monetary(string='Residual Amount', store=True, currency_field='company_currency_id', - compute='_amount_residual', + compute='_compute_amount_residual', help="The residual amount on a journal item expressed in the company currency.") amount_residual_currency = fields.Monetary(string='Residual Amount in Currency', store=True, - compute='_amount_residual', + compute='_compute_amount_residual', help="The residual amount on a journal item expressed in its currency (possibly not the company currency).") full_reconcile_id = fields.Many2one('account.full.reconcile', string="Matching", copy=False, index=True, readonly=True) matched_debit_ids = fields.One2many('account.partial.reconcile', 'credit_move_id', string='Matched Debits', @@ -3329,59 +3376,34 @@ class AccountMoveLine(models.Model): for record in self: record.cumulated_balance = result[record.id] - @api.depends('debit', 'credit', 'account_id', 'amount_currency', 'currency_id', 'matched_debit_ids', 'matched_credit_ids', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'move_id.state', 'company_id') - def _amount_residual(self): + @api.depends('debit', 'credit', 'amount_currency', 'account_id', 'currency_id', 'move_id.state', 'company_id', + 'matched_debit_ids', 'matched_credit_ids') + def _compute_amount_residual(self): """ Computes the residual amount of a move line from a reconcilable account in the company currency and the line's currency. This amount will be 0 for fully reconciled lines or lines from a non-reconcilable account, the original line amount for unreconciled lines, and something in-between for partially reconciled lines. """ for line in self: - if not line.account_id.reconcile and line.account_id.internal_type != 'liquidity': - line.reconciled = False - line.amount_residual = 0 - line.amount_residual_currency = 0 - continue - #amounts in the partial reconcile table aren't signed, so we need to use abs() - amount = abs(line.debit - line.credit) - amount_residual_currency = abs(line.amount_currency) or 0.0 - sign = 1 if (line.debit - line.credit) > 0 else -1 - if not line.debit and not line.credit and line.amount_currency and line.currency_id: - #residual for exchange rate entries - sign = 1 if float_compare(line.amount_currency, 0, precision_rounding=line.currency_id.rounding) == 1 else -1 - - for partial_line in (line.matched_debit_ids + line.matched_credit_ids): - # If line is a credit (sign = -1) we: - # - subtract matched_debit_ids (partial_line.credit_move_id == line) - # - add matched_credit_ids (partial_line.credit_move_id != line) - # If line is a debit (sign = 1), do the opposite. - sign_partial_line = sign if partial_line.credit_move_id == line else (-1 * sign) - - amount += sign_partial_line * partial_line.amount - #getting the date of the matched item to compute the amount_residual in currency - if line.currency_id and line.amount_currency: - if partial_line.currency_id and partial_line.currency_id == line.currency_id: - amount_residual_currency += sign_partial_line * partial_line.amount_currency - else: - if line.balance and line.amount_currency: - rate = line.amount_currency / line.balance - else: - date = partial_line.credit_move_id.date if partial_line.debit_move_id == line else partial_line.debit_move_id.date - rate = line.currency_id.with_context(date=date).rate - amount_residual_currency += sign_partial_line * line.currency_id.round(partial_line.amount * rate) - - #computing the `reconciled` field. - reconciled = False - digits_rounding_precision = line.move_id.company_id.currency_id.rounding - if float_is_zero(amount, precision_rounding=digits_rounding_precision): - if line.currency_id and line.amount_currency: - if float_is_zero(amount_residual_currency, precision_rounding=line.currency_id.rounding): - reconciled = True + if line.id and (line.account_id.reconcile or line.account_id.internal_type == 'liquidity'): + reconciled_balance = sum(line.matched_credit_ids.mapped('amount')) \ + - sum(line.matched_debit_ids.mapped('amount')) + reconciled_amount_currency = sum(line.matched_credit_ids.mapped('debit_amount_currency'))\ + - sum(line.matched_debit_ids.mapped('credit_amount_currency')) + + line.amount_residual = line.balance - reconciled_balance + + if line.currency_id: + line.amount_residual_currency = line.amount_currency - reconciled_amount_currency else: - reconciled = True - line.reconciled = reconciled + line.amount_residual_currency = 0.0 - line.amount_residual = line.move_id.company_id.currency_id.round(amount * sign) if line.move_id.company_id else amount * sign - line.amount_residual_currency = line.currency_id and line.currency_id.round(amount_residual_currency * sign) or 0.0 + line.reconciled = line.company_currency_id.is_zero(line.amount_residual) \ + and (not line.currency_id or line.currency_id.is_zero(line.amount_residual_currency)) + else: + # Must not have any reconciliation since the line is not eligible for that. + line.amount_residual = 0.0 + line.amount_residual_currency = 0.0 + line.reconciled = False @api.depends('tax_repartition_line_id.invoice_tax_id', 'tax_repartition_line_id.refund_tax_id') def _compute_tax_line_id(self): @@ -3847,270 +3869,505 @@ class AccountMoveLine(models.Model): # RECONCILIATION # ------------------------------------------------------------------------- - def check_full_reconcile(self): - """ - This method check if a move is totally reconciled and if we need to create exchange rate entries for the move. - In case exchange rate entries needs to be created, one will be created per currency present. - In case of full reconciliation, all moves belonging to the reconciliation will belong to the same account_full_reconcile object. - """ - # Get first all aml involved - todo = self.env['account.partial.reconcile'].search_read(['|', ('debit_move_id', 'in', self.ids), ('credit_move_id', 'in', self.ids)], ['debit_move_id', 'credit_move_id']) - amls = set(self.ids) - seen = set() - while todo: - aml_ids = [rec['debit_move_id'][0] for rec in todo if rec['debit_move_id']] + [rec['credit_move_id'][0] for rec in todo if rec['credit_move_id']] - amls |= set(aml_ids) - seen |= set([rec['id'] for rec in todo]) - todo = self.env['account.partial.reconcile'].search_read(['&', '|', ('credit_move_id', 'in', aml_ids), ('debit_move_id', 'in', aml_ids), '!', ('id', 'in', list(seen))], ['debit_move_id', 'credit_move_id']) - - partial_rec_ids = list(seen) - if not amls: - return - else: - amls = self.browse(list(amls)) - - # If we have multiple currency, we can only base ourselves on debit-credit to see if it is fully reconciled - currency = set([a.currency_id for a in amls if a.currency_id.id != False]) - multiple_currency = False - if len(currency) != 1: - currency = False - multiple_currency = True - else: - currency = list(currency)[0] - # Get the sum(debit, credit, amount_currency) of all amls involved - total_debit = 0 - total_credit = 0 - total_amount_currency = 0 - maxdate = date.min - to_balance = {} - cash_basis_partial = self.env['account.partial.reconcile'] - for aml in amls: - cash_basis_partial |= aml.move_id.tax_cash_basis_rec_id - total_debit += aml.debit - total_credit += aml.credit - maxdate = max(aml.date, maxdate) - total_amount_currency += aml.amount_currency - # Convert in currency if we only have one currency and no amount_currency - if not aml.amount_currency and currency: - multiple_currency = True - total_amount_currency += aml.company_id.currency_id._convert(aml.balance, currency, aml.company_id, aml.date) - # If we still have residual value, it means that this move might need to be balanced using an exchange rate entry - if aml.amount_residual != 0 or aml.amount_residual_currency != 0: - if not to_balance.get(aml.currency_id): - to_balance[aml.currency_id] = [self.env['account.move.line'], 0] - to_balance[aml.currency_id][0] += aml - to_balance[aml.currency_id][1] += aml.amount_residual != 0 and aml.amount_residual or aml.amount_residual_currency - - # Check if reconciliation is total - # To check if reconciliation is total we have 3 different use case: - # 1) There are multiple currency different than company currency, in that case we check using debit-credit - # 2) We only have one currency which is different than company currency, in that case we check using amount_currency - # 3) We have only one currency and some entries that don't have a secundary currency, in that case we check debit-credit - # or amount_currency. - # 4) Cash basis full reconciliation - # - either none of the moves are cash basis reconciled, and we proceed - # - or some moves are cash basis reconciled and we make sure they are all fully reconciled - - digits_rounding_precision = amls[0].company_id.currency_id.rounding - if ( - ( - not cash_basis_partial or (cash_basis_partial and all([p >= 1.0 for p in amls._get_matched_percentage().values()])) - ) and - ( - currency and float_is_zero(total_amount_currency, precision_rounding=currency.rounding) or - multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0 + def _prepare_reconciliation_partials(self): + ''' Prepare the partials on the current journal items to perform the reconciliation. + /!\ The order of records in self is important because the journal items will be reconciled using this order. + + :return: A recordset of account.partial.reconcile. + ''' + debit_lines = iter(self.filtered('debit')) + credit_lines = iter(self.filtered('credit')) + debit_line = None + credit_line = None + + debit_amount_residual = 0.0 + debit_amount_residual_currency = 0.0 + credit_amount_residual = 0.0 + credit_amount_residual_currency = 0.0 + debit_line_currency = None + credit_line_currency = None + + partials_vals_list = [] + + while True: + + # Move to the next available debit line. + if not debit_line: + debit_line = next(debit_lines, None) + if not debit_line: + break + debit_amount_residual = debit_line.amount_residual + + if debit_line.currency_id: + debit_amount_residual_currency = debit_line.amount_residual_currency + debit_line_currency = debit_line.currency_id + else: + debit_amount_residual_currency = debit_amount_residual + debit_line_currency = debit_line.company_currency_id + + # Move to the next available credit line. + if not credit_line: + credit_line = next(credit_lines, None) + if not credit_line: + break + credit_amount_residual = credit_line.amount_residual + + if credit_line.currency_id: + credit_amount_residual_currency = credit_line.amount_residual_currency + credit_line_currency = credit_line.currency_id + else: + credit_amount_residual_currency = credit_amount_residual + credit_line_currency = credit_line.company_currency_id + + min_amount_residual = min(debit_amount_residual, -credit_amount_residual) + + if debit_line_currency == credit_line_currency: + # Reconcile on the same currency. + + # The debit line is now fully reconciled. + if debit_line_currency.is_zero(debit_amount_residual_currency) or debit_amount_residual_currency < 0.0: + debit_line = None + continue + + # The credit line is now fully reconciled. + if credit_line_currency.is_zero(credit_amount_residual_currency) or credit_amount_residual_currency > 0.0: + credit_line = None + continue + + min_amount_residual_currency = min(debit_amount_residual_currency, -credit_amount_residual_currency) + min_debit_amount_residual_currency = min_amount_residual_currency + min_credit_amount_residual_currency = min_amount_residual_currency + + else: + # Reconcile on the company's currency. + + # The debit line is now fully reconciled. + if debit_line.company_currency_id.is_zero(debit_amount_residual) or debit_amount_residual < 0.0: + debit_line = None + continue + + # The credit line is now fully reconciled. + if credit_line.company_currency_id.is_zero(credit_amount_residual) or credit_amount_residual > 0.0: + credit_line = None + continue + + min_debit_amount_residual_currency = credit_line.company_currency_id._convert( + min_amount_residual, + debit_line.currency_id, + credit_line.company_id, + credit_line.date, ) - ): - - exchange_move_id = False - missing_exchange_difference = False - # Eventually create a journal entry to book the difference due to foreign currency's exchange rate that fluctuates - if to_balance and any([not float_is_zero(residual, precision_rounding=digits_rounding_precision) for aml, residual in to_balance.values()]): - if not self.env.context.get('no_exchange_difference'): - exchange_move_vals = self.env['account.full.reconcile']._prepare_exchange_diff_move( - move_date=maxdate, company=amls[0].company_id) - if len(amls.mapped('partner_id')) == 1 and amls[0].partner_id: - exchange_move_vals['partner_id'] = amls[0].partner_id.id - exchange_move = self.env['account.move'].with_context(default_move_type='entry').create(exchange_move_vals) - part_reconcile = self.env['account.partial.reconcile'] - for aml_to_balance, total in to_balance.values(): - if total: - rate_diff_amls, rate_diff_partial_rec = part_reconcile.create_exchange_rate_entry(aml_to_balance, exchange_move) - amls += rate_diff_amls - partial_rec_ids += rate_diff_partial_rec.ids - else: - aml_to_balance.reconcile() - exchange_move.post() - exchange_move_id = exchange_move.id + min_credit_amount_residual_currency = debit_line.company_currency_id._convert( + min_amount_residual, + credit_line.currency_id, + debit_line.company_id, + debit_line.date, + ) + + debit_amount_residual -= min_amount_residual + debit_amount_residual_currency -= min_debit_amount_residual_currency + credit_amount_residual += min_amount_residual + credit_amount_residual_currency += min_credit_amount_residual_currency + + partials_vals_list.append({ + 'amount': min_amount_residual, + 'debit_amount_currency': min_debit_amount_residual_currency, + 'credit_amount_currency': min_credit_amount_residual_currency, + 'debit_move_id': debit_line.id, + 'credit_move_id': credit_line.id, + }) + + return partials_vals_list + + def _create_exchange_difference_move(self): + ''' Create the exchange difference journal entry on the current journal items. + :return: An account.move record. + ''' + + def _add_lines_to_exchange_difference_vals(lines, exchange_diff_move_vals): + ''' Generate the exchange difference values used to create the journal items + in order to fix the residual amounts and add them into 'exchange_diff_move_vals'. + + 1) When reconciled on the same foreign currency, the journal items are + fully reconciled regarding this currency but it could be not the case + of the balance that is expressed using the company's currency. In that + case, we need to create exchange difference journal items to ensure this + residual amount reaches zero. + + 2) When reconciled on the company currency but having different foreign + currencies, the journal items are fully reconciled regarding the company + currency but it's not always the case for the foreign currencies. In that + case, the exchange difference journal items are created to ensure this + residual amount in foreign currency reaches zero. + + :param lines: The account.move.lines to which fix the residual amounts. + :param exchange_diff_move_vals: The current vals of the exchange difference journal entry. + :return: A list of pair <line, sequence> to perform the reconciliation + at the creation of the exchange difference move where 'line' + is the account.move.line to which the 'sequence'-th exchange + difference line will be reconciled with. + ''' + journal = self.env['account.journal'].browse(exchange_diff_move_vals['journal_id']) + to_reconcile = [] + + for line in lines: + + exchange_diff_move_vals['date'] = max(exchange_diff_move_vals['date'], line.date) + + if not line.company_currency_id.is_zero(line.amount_residual): + # amount_residual_currency == 0 and amount_residual has to be fixed. + + if line.amount_residual > 0.0: + exchange_line_account = journal.default_debit_account_id + else: + exchange_line_account = journal.default_credit_account_id + + elif line.currency_id and not line.currency_id.is_zero(line.amount_residual_currency): + # amount_residual == 0 and amount_residual_currency has to be fixed. + + if line.amount_residual_currency > 0.0: + exchange_line_account = journal.default_debit_account_id + else: + exchange_line_account = journal.default_credit_account_id + else: - missing_exchange_difference = True - if not missing_exchange_difference: - #mark the reference of the full reconciliation on the exchange rate entries and on the entries - self.env['account.full.reconcile'].create({ - 'partial_reconcile_ids': [(6, 0, partial_rec_ids)], - 'reconciled_line_ids': [(6, 0, amls.ids)], - 'exchange_move_id': exchange_move_id, - }) + continue - def _reconcile_lines(self, debit_moves, credit_moves, field): - """ This function loops on the 2 recordsets given as parameter as long as it - can find a debit and a credit to reconcile together. It returns the recordset of the - account move lines that were not reconciled during the process. - """ - (debit_moves + credit_moves).read([field]) - to_create = [] - cash_basis = debit_moves and debit_moves[0].account_id.internal_type in ('receivable', 'payable') or False - cash_basis_percentage_before_rec = {} - dc_vals ={} - while (debit_moves and credit_moves): - debit_move = debit_moves[0] - credit_move = credit_moves[0] - company_currency = debit_move.company_id.currency_id - # We need those temporary value otherwise the computation might be wrong below - temp_amount_residual = min(debit_move.amount_residual, -credit_move.amount_residual) - temp_amount_residual_currency = min(debit_move.amount_residual_currency, -credit_move.amount_residual_currency) - dc_vals[(debit_move.id, credit_move.id)] = (debit_move, credit_move, temp_amount_residual_currency) - amount_reconcile = min(debit_move[field], -credit_move[field]) - - #Remove from recordset the one(s) that will be totally reconciled - # For optimization purpose, the creation of the partial_reconcile are done at the end, - # therefore during the process of reconciling several move lines, there are actually no recompute performed by the orm - # and thus the amount_residual are not recomputed, hence we have to do it manually. - if amount_reconcile == debit_move[field]: - debit_moves -= debit_move + sequence = len(exchange_diff_move_vals['line_ids']) + exchange_diff_move_vals['line_ids'] += [ + (0, 0, { + 'name': _('Currency exchange rate difference'), + 'debit': -line.amount_residual if line.amount_residual < 0.0 else 0.0, + 'credit': line.amount_residual if line.amount_residual > 0.0 else 0.0, + 'amount_currency': -line.amount_residual_currency, + 'account_id': line.account_id.id, + 'currency_id': line.currency_id.id, + 'partner_id': line.partner_id.id, + 'sequence': sequence, + }), + (0, 0, { + 'name': _('Currency exchange rate difference'), + 'debit': line.amount_residual if line.amount_residual > 0.0 else 0.0, + 'credit': -line.amount_residual if line.amount_residual < 0.0 else 0.0, + 'amount_currency': line.amount_residual_currency, + 'account_id': exchange_line_account.id, + 'currency_id': line.currency_id.id, + 'partner_id': line.partner_id.id, + 'sequence': sequence + 1, + }), + ] + + to_reconcile.append((line, sequence)) + + return to_reconcile + + def _add_cash_basis_lines_to_exchange_difference_vals(lines, exchange_diff_move_vals): + ''' Generate the exchange difference values used to create the journal items + in order to fix the cash basis lines using the transfer account in a multi-currencies + environment when this account is not a reconcile one. + + When the tax cash basis journal entries are generated and all involved + transfer account set on taxes are all reconcilable, the account balance + will be reset to zero by the exchange difference journal items generated + above. However, this mechanism will not work if there is any transfer + accounts that are not reconcile and we are generating the cash basis + journal items in a foreign currency. In that specific case, we need to + generate extra journal items at the generation of the exchange difference + journal entry to ensure this balance is reset to zero and then, will not + appear on the tax report leading to erroneous tax base amount / tax amount. + + :param lines: The account.move.lines to which fix the residual amounts. + :param exchange_diff_move_vals: The current vals of the exchange difference journal entry. + ''' + for move in lines.move_id: + transfer_account_vals_to_fix = {} + + move_values = move._collect_tax_cash_basis_values() + + # The cash basis doesn't need to be handle for this move because there is another payment term + # line that is not yet fully paid. + if not move_values or not move_values['is_fully_paid']: + continue + + # The percentage of the tax cash basis entries are expressed using the company's currency and then, + # there is no exchange difference to make for such journal entry. + if move_values['currency'] == move.company_id.currency_id: + continue + + # ========================================================================== + # Add the balance of all tax lines of the current move in order in order + # to compute the residual amount for each of them. + # ========================================================================== + + is_exchange_diff_needed = False + for line in move_values['to_process_lines']: + + if not line.tax_repartition_line_id: + continue + + if not line.account_id.reconcile: + is_exchange_diff_needed = True + grouping_key = self.env['account.partial.reconcile']._get_cash_basis_tax_line_grouping_key_from_record(line) + transfer_account_vals_to_fix[grouping_key] = { + 'account_id': line.account_id.id, + 'currency_id': line.currency_id.id, + 'partner_id': line.partner_id.id, + 'tax_base_amount': line.tax_base_amount, + 'tax_repartition_line_id': line.tax_repartition_line_id.id, + 'tax_ids': [(6, 0, line.tax_ids.ids)], + 'tax_tag_ids': [(6, 0, line.tax_tag_ids.ids)], + 'debit': line.debit, + 'credit': line.credit, + } + + # No tax line on the current move. + if not is_exchange_diff_needed: + continue + + # ========================================================================== + # Subtract the balance of all previously generated cash basis journal entries + # in order to retrieve the residual balance of each involved transfer account. + # ========================================================================== + + cash_basis_moves = self.env['account.move'].search([('tax_cash_basis_move_id', '=', move.id)]) + for line in cash_basis_moves.line_ids: + if not line.tax_repartition_line_id: + continue + + grouping_key = self.env['account.partial.reconcile']._get_cash_basis_tax_line_grouping_key_from_record( + line, + account=line.tax_line_id.cash_basis_transition_account_id, + ) + + if grouping_key not in transfer_account_vals_to_fix: + continue + + transfer_account_vals_to_fix[grouping_key]['debit'] -= line.debit + transfer_account_vals_to_fix[grouping_key]['credit'] -= line.credit + + # ========================================================================== + # Generate the exchange difference journal items to reset the balance of + # all transfer account to zero. + # ========================================================================== + + for values in transfer_account_vals_to_fix.values(): + balance = values['debit'] - values['credit'] + values.update({ + 'debit': balance if balance > 0.0 else 0.0, + 'credit': -balance if balance < 0.0 else 0.0, + }) + + account = self.env['account.account'].browse(values['account_id']) + if account.company_id.currency_id.is_zero(balance): + continue + + journal = account.company_id.currency_exchange_journal_id + + if balance > 0.0: + exchange_line_account = journal.default_debit_account_id + else: + exchange_line_account = journal.default_credit_account_id + + sequence = len(exchange_diff_move_vals['line_ids']) + exchange_diff_move_vals['line_ids'] += [ + (0, 0, { + **values, + 'name': _('Currency exchange rate difference (cash basis)'), + 'debit': values['credit'], + 'credit': values['debit'], + 'amount_currency': 0.0, + 'sequence': sequence, + }), + (0, 0, { + **values, + 'name': _('Currency exchange rate difference (cash basis)'), + 'amount_currency': 0.0, + 'account_id': exchange_line_account.id, + 'sequence': sequence + 1, + }), + ] + + if not self: + return self.env['account.move'] + + company = self[0].company_id + journal = company.currency_exchange_journal_id + + # Check the configuration of the exchange difference journal. + if not journal: + raise UserError(_("You should configure the 'Exchange Rate Journal' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) + if not journal.default_debit_account_id.id: + raise UserError(_("You should configure the 'Loss Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) + if not journal.default_credit_account_id.id: + raise UserError(_("You should configure the 'Gain Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) + + exchange_diff_move_vals = { + 'move_type': 'entry', + 'date': date.min, + 'journal_id': journal.id, + 'line_ids': [], + } + + # Fix residual amounts. + to_reconcile = _add_lines_to_exchange_difference_vals(self, exchange_diff_move_vals) + + # Fix cash basis entries. + is_cash_basis_needed = self[0].account_internal_type in ('receivable', 'payable') + if is_cash_basis_needed: + _add_cash_basis_lines_to_exchange_difference_vals(self, exchange_diff_move_vals) + + # ========================================================================== + # Create move and reconcile. + # ========================================================================== + + if exchange_diff_move_vals['line_ids']: + exchange_diff_move_vals['date'] = max(exchange_diff_move_vals['date'], company._get_user_fiscal_lock_date()) + + exchange_move = self.env['account.move'].create(exchange_diff_move_vals) + else: + return None + + # Reconcile lines to the newly created exchange difference journal entry by creating more partials. + partials_vals_list = [] + for source_line, sequence in to_reconcile: + exchange_diff_line = exchange_move.line_ids[sequence] + + if source_line.company_currency_id.is_zero(source_line.amount_residual): + exchange_field = 'amount_residual_currency' else: - debit_moves[0].amount_residual -= temp_amount_residual - debit_moves[0].amount_residual_currency -= temp_amount_residual_currency + exchange_field = 'amount_residual' - if amount_reconcile == -credit_move[field]: - credit_moves -= credit_move + if exchange_diff_line[exchange_field] > 0.0: + debit_line = exchange_diff_line + credit_line = source_line else: - credit_moves[0].amount_residual += temp_amount_residual - credit_moves[0].amount_residual_currency += temp_amount_residual_currency - #Check for the currency and amount_currency we can set - currency = False - amount_reconcile_currency = 0 - if field == 'amount_residual_currency': - currency = credit_move.currency_id.id - amount_reconcile_currency = temp_amount_residual_currency - amount_reconcile = temp_amount_residual - elif bool(debit_move.currency_id) != bool(credit_move.currency_id): - # If only one of debit_move or credit_move has a secondary currency, also record the converted amount - # in that secondary currency in the partial reconciliation. That allows the exchange difference entry - # to be created, in case it is needed. It also allows to compute the amount residual in foreign currency. - currency = debit_move.currency_id or credit_move.currency_id - currency_date = debit_move.currency_id and credit_move.date or debit_move.date - amount_reconcile_currency = company_currency._convert(amount_reconcile, currency, debit_move.company_id, currency_date) - currency = currency.id - - if cash_basis: - tmp_set = debit_move | credit_move - cash_basis_percentage_before_rec.update(tmp_set._get_matched_percentage()) - - to_create.append({ - 'debit_move_id': debit_move.id, - 'credit_move_id': credit_move.id, - 'amount': amount_reconcile, - 'amount_currency': amount_reconcile_currency, - 'currency_id': currency, + debit_line = source_line + credit_line = exchange_diff_line + + partials_vals_list.append({ + 'amount': abs(source_line.amount_residual), + 'debit_amount_currency': abs(debit_line.amount_residual_currency), + 'credit_amount_currency': abs(credit_line.amount_residual_currency), + 'debit_move_id': debit_line.id, + 'credit_move_id': credit_line.id, }) - cash_basis_subjected = [] - part_rec = self.env['account.partial.reconcile'] - for partial_rec_dict in to_create: - debit_move, credit_move, amount_residual_currency = dc_vals[partial_rec_dict['debit_move_id'], partial_rec_dict['credit_move_id']] - # /!\ NOTE: Exchange rate differences shouldn't create cash basis entries - # i. e: we don't really receive/give money in a customer/provider fashion - # Since those are not subjected to cash basis computation we process them first - if not amount_residual_currency and debit_move.currency_id and credit_move.currency_id: - part_rec.create(partial_rec_dict) - else: - cash_basis_subjected.append(partial_rec_dict) - - for after_rec_dict in cash_basis_subjected: - new_rec = part_rec.create(after_rec_dict) - # if the pair belongs to move being reverted, do not create CABA entry - if cash_basis and not ( - new_rec.debit_move_id.move_id == new_rec.credit_move_id.move_id.reversed_entry_id - or - new_rec.credit_move_id.move_id == new_rec.debit_move_id.move_id.reversed_entry_id - ): - new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec) - return debit_moves+credit_moves - - def auto_reconcile_lines(self): - # Create list of debit and list of credit move ordered by date-currency - debit_moves = self.filtered(lambda r: r.debit != 0 or r.amount_currency > 0) - credit_moves = self.filtered(lambda r: r.credit != 0 or r.amount_currency < 0) - debit_moves = debit_moves.sorted(key=lambda a: (a.date_maturity or a.date, a.currency_id)) - credit_moves = credit_moves.sorted(key=lambda a: (a.date_maturity or a.date, a.currency_id)) - # Compute on which field reconciliation should be based upon: - if self[0].account_id.currency_id and self[0].account_id.currency_id != self[0].account_id.company_id.currency_id: - field = 'amount_residual_currency' - else: - field = 'amount_residual' - #if all lines share the same currency, use amount_residual_currency to avoid currency rounding error - if self[0].currency_id and all([x.amount_currency and x.currency_id == self[0].currency_id for x in self]): - field = 'amount_residual_currency' - # Reconcile lines - ret = self._reconcile_lines(debit_moves, credit_moves, field) - return ret - - def _check_reconcile_validity(self): - #Perform all checks on lines - company_ids = set() - all_accounts = [] + self.env['account.partial.reconcile'].create(partials_vals_list) + + return exchange_move + + def reconcile(self): + ''' Reconcile the current move lines all together. + :return: A dictionary representing a summary of what has been done during the reconciliation: + * partials: A recorset of all account.partial.reconcile created during the reconciliation. + * full_reconcile: An account.full.reconcile record created when there is nothing left to reconcile + in the involved lines. + * tax_cash_basis_moves: An account.move recordset representing the tax cash basis journal entries. + ''' + results = {} + + if not self: + return results + + # List unpaid invoices + not_paid_invoices = self.move_id.filtered( + lambda move: move.is_invoice(include_receipts=True) and move.payment_state not in ('paid', 'in_payment') + ) + + # ==== Check the lines can be reconciled together ==== + company = None + account = None for line in self: - company_ids.add(line.company_id.id) - all_accounts.append(line.account_id) if line.reconciled: - raise UserError(_('You are trying to reconcile some entries that are already reconciled.')) + raise UserError(_("You are trying to reconcile some entries that are already reconciled.")) + if not line.account_id.reconcile and line.account_id.internal_type != 'liquidity': + raise UserError(_("Account %s does not allow reconciliation. First change the configuration of this account to allow it.") + % line.account_id.display_name) if line.move_id.state != 'posted': raise UserError(_('You can only reconcile posted entries.')) - if len(company_ids) > 1: - raise UserError(_('To reconcile the entries company should be the same for all entries.')) - if len(set(all_accounts)) > 1: - raise UserError(_('Entries are not from the same account.')) - if not (all_accounts[0].reconcile or all_accounts[0].internal_type == 'liquidity'): - raise UserError(_('Account %s (%s) does not allow reconciliation. First change the configuration of this account to allow it.') % (all_accounts[0].name, all_accounts[0].code)) - - def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False): - # Empty self can happen if the user tries to reconcile entries which are already reconciled. - # The calling method might have filtered out reconciled lines. - if not self: - return + if company is None: + company = line.company_id + elif line.company_id != company: + raise UserError(_("Entries doesn't belong to the same company: %s != %s") + % (company.display_name, line.company_id.display_name)) + if account is None: + account = line.account_id + elif line.account_id != account: + raise UserError(_("Entries are not from the same account: %s != %s") + % (account.display_name, line.account_id.display_name)) + + sorted_lines = self.sorted(key=lambda line: (line.date_maturity or line.date, line.currency_id)) + + # ==== Collect all involved lines through the existing reconciliation ==== + + involved_lines = sorted_lines + involved_partials = self.env['account.partial.reconcile'] + current_lines = involved_lines + current_partials = involved_partials + while current_lines: + current_partials = (current_lines.matched_debit_ids + current_lines.matched_credit_ids) - current_partials + involved_partials += current_partials + current_lines = (current_partials.debit_move_id + current_partials.credit_move_id) - current_lines + involved_lines += current_lines + + # ==== Create partials ==== + + partials = self.env['account.partial.reconcile'].create(sorted_lines._prepare_reconciliation_partials()) + + # Track newly created partials. + results['partials'] = partials + involved_partials += partials + + # ==== Create entries for cash basis taxes ==== + + is_cash_basis_needed = account.user_type_id.type in ('receivable', 'payable') + if is_cash_basis_needed: + tax_cash_basis_moves = partials._create_tax_cash_basis_moves() + results['tax_cash_basis_moves'] = tax_cash_basis_moves + + # ==== Check if a full reconcile is needed ==== + + if involved_lines[0].currency_id and all(line.currency_id == involved_lines[0].currency_id for line in involved_lines): + is_full_needed = all(line.currency_id.is_zero(line.amount_residual_currency) for line in involved_lines) + else: + is_full_needed = all(line.company_currency_id.is_zero(line.amount_residual) for line in involved_lines) - # List unpaid invoices - not_paid_invoices = self.mapped('move_id').filtered( - lambda m: m.is_invoice(include_receipts=True) and m.payment_state not in ('paid', 'in_payment') - ) + if is_full_needed: - self._check_reconcile_validity() - #reconcile everything that can be - remaining_moves = self.auto_reconcile_lines() - - writeoff_to_reconcile = self.env['account.move.line'] - #if writeoff_acc_id specified, then create write-off move with value the remaining amount from move in self - if writeoff_acc_id and writeoff_journal_id and remaining_moves: - all_aml_share_same_currency = all([x.currency_id == self[0].currency_id for x in self]) - writeoff_vals = { - 'account_id': writeoff_acc_id.id, - 'journal_id': writeoff_journal_id.id - } - if not all_aml_share_same_currency: - writeoff_vals['amount_currency'] = False - writeoff_to_reconcile = remaining_moves._create_writeoff([writeoff_vals]) - #add writeoff line to reconcile algorithm and finish the reconciliation - remaining_moves = (remaining_moves + writeoff_to_reconcile).auto_reconcile_lines() - # Check if reconciliation is total or needs an exchange rate entry to be created - (self + writeoff_to_reconcile).check_full_reconcile() + # ==== Create the exchange difference move ==== + + if self._context.get('no_exchange_difference'): + exchange_move = None + else: + exchange_move = involved_lines._create_exchange_difference_move() + if exchange_move: + exchange_move_lines = exchange_move.line_ids.filtered(lambda line: line.account_id == account) + + # Track newly created lines. + involved_lines += exchange_move_lines + + # Track newly created partials. + exchange_diff_partials = exchange_move_lines.matched_debit_ids \ + + exchange_move_lines.matched_credit_ids + involved_partials += exchange_diff_partials + results['partials'] += exchange_diff_partials + + exchange_move.post() + + # ==== Create the full reconcile ==== + + results['full_reconcile'] = self.env['account.full.reconcile'].create({ + 'exchange_move_id': exchange_move and exchange_move.id, + 'partial_reconcile_ids': [(6, 0, involved_partials.ids)], + 'reconciled_line_ids': [(6, 0, involved_lines.ids)], + }) # Trigger action for paid invoices - not_paid_invoices.filtered( - lambda m: m.payment_state in ('paid', 'in_payment') - ).action_invoice_paid() + not_paid_invoices\ + .filtered(lambda move: move.payment_state in ('paid', 'in_payment'))\ + .action_invoice_paid() - return True + return results def _create_writeoff(self, writeoff_vals): """ Create a writeoff move per journal for the account.move.lines in self. If debit/credit is not specified in vals, @@ -4227,47 +4484,6 @@ class AccountMoveLine(models.Model): # MISC # ------------------------------------------------------------------------- - def _get_matched_percentage(self): - """ This function returns a dictionary giving for each move_id of self, the percentage to consider as cash basis factor. - This is actually computing the same as the matched_percentage field of account.move, except in case of multi-currencies - where we recompute the matched percentage based on the amount_currency fields. - Note that this function is used only by the tax cash basis module since we want to consider the matched_percentage only - based on the company currency amounts in reports. - """ - matched_percentage_per_move = {} - for line in self: - if not matched_percentage_per_move.get(line.move_id.id, False): - lines_to_consider = line.move_id.line_ids.filtered(lambda x: x.account_id.internal_type in ('receivable', 'payable')) - total_amount_currency = 0.0 - total_reconciled_currency = 0.0 - all_same_currency = False - #if all receivable/payable aml and their payments have the same currency, we can safely consider - #the amount_currency fields to avoid including the exchange rate difference in the matched_percentage - if lines_to_consider and all([x.currency_id.id == lines_to_consider[0].currency_id.id for x in lines_to_consider]): - all_same_currency = lines_to_consider[0].currency_id.id - for line in lines_to_consider: - if all_same_currency: - total_amount_currency += abs(line.amount_currency) - for partial_line in (line.matched_debit_ids + line.matched_credit_ids): - if partial_line.currency_id and partial_line.currency_id.id == all_same_currency: - total_reconciled_currency += partial_line.amount_currency - else: - all_same_currency = False - break - if not all_same_currency: - #we cannot rely on amount_currency fields as it is not present on all partial reconciliation - matched_percentage_per_move[line.move_id.id] = line.move_id._get_cash_basis_matched_percentage() - else: - #we can rely on amount_currency fields, which allow us to post a tax cash basis move at the initial rate - #to avoid currency rate difference issues. - if total_amount_currency == 0.0: - matched_percentage_per_move[line.move_id.id] = 1.0 - else: - # lines_to_consider is always non-empty when total_amount_currency is 0 - currency = lines_to_consider[0].currency_id or lines_to_consider[0].company_id.currency_id - matched_percentage_per_move[line.move_id.id] = currency.round(total_reconciled_currency) / currency.round(total_amount_currency) - return matched_percentage_per_move - def _get_analytic_tag_ids(self): self.ensure_one() return self.analytic_tag_ids.filtered(lambda r: not r.active_analytic_distribution).ids @@ -4463,331 +4679,3 @@ class AccountMoveLine(models.Model): if self.payment_id: domains.append([('res_model', '=', 'account.payment'), ('res_id', '=', self.payment_id.id)]) return domains - -class AccountPartialReconcile(models.Model): - _name = "account.partial.reconcile" - _description = "Partial Reconcile" - - debit_move_id = fields.Many2one('account.move.line', index=True, required=True) - credit_move_id = fields.Many2one('account.move.line', index=True, required=True) - amount = fields.Monetary(currency_field='company_currency_id', help="Amount concerned by this matching. Assumed to be always positive") - amount_currency = fields.Monetary(string="Amount in Currency") - currency_id = fields.Many2one('res.currency', string='Currency') - company_currency_id = fields.Many2one('res.currency', string="Company Currency", related='company_id.currency_id', readonly=True, - help='Utility field to express amount currency') - company_id = fields.Many2one('res.company', related='debit_move_id.company_id', store=True, string='Company', readonly=False) - full_reconcile_id = fields.Many2one('account.full.reconcile', string="Full Reconcile", copy=False) - max_date = fields.Date(string='Max Date of Matched Lines', compute='_compute_max_date', - readonly=True, copy=False, store=True, - help='Technical field used to determine at which date this reconciliation needs to be shown on the aged receivable/payable reports.') - - @api.depends('debit_move_id.date', 'credit_move_id.date') - def _compute_max_date(self): - for rec in self: - rec.max_date = max( - rec.debit_move_id.date, - rec.credit_move_id.date - ) - - @api.model - def _prepare_exchange_diff_partial_reconcile(self, aml, line_to_reconcile, currency): - """ - Prepares the values for the partial reconciliation between an account.move.line - that needs to be fixed by an exchange rate entry and the account.move.line that fixes it - - @param {account.move.line} aml: - The line that needs fixing with exchange difference entry - (e.g. a receivable/payable from an invoice) - @param {account.move.line} line_to_reconcile: - The line that fixes the aml. it is the receivable/payable line - of the exchange difference entry move - @param {res.currency} currency - - @return {dict} values of account.partial.reconcile; ready for create() - """ - - # the exhange rate difference that will be fixed may be of opposite direction - # than the original move line (i.e. the exchange difference may be negative whereas - # the move line on which it applies may be a debit -- positive) - # So we need to register both the move line and the exchange line - # to either debit_move or credit_move as a function of whether the direction (debit VS credit) - # of the exchange loss/gain is the same (or not) as the direction of the line that is fixed here - if aml.currency_id: - residual_same_sign = aml.amount_currency * aml.amount_residual_currency >= 0 - else: - residual_same_sign = aml.balance * aml.amount_residual >= 0 - - if residual_same_sign: - debit_move_id = line_to_reconcile.id if aml.credit else aml.id - credit_move_id = line_to_reconcile.id if aml.debit else aml.id - else: - debit_move_id = aml.id if aml.credit else line_to_reconcile.id - credit_move_id = aml.id if aml.debit else line_to_reconcile.id - - return { - 'debit_move_id': debit_move_id, - 'credit_move_id': credit_move_id, - 'amount': abs(aml.amount_residual), - 'amount_currency': abs(aml.amount_residual_currency), - 'currency_id': currency and currency.id or False, - } - - @api.model - def create_exchange_rate_entry(self, aml_to_fix, move): - """ - Automatically create a journal items to book the exchange rate - differences that can occur in multi-currencies environment. That - new journal item will be made into the given `move` in the company - `currency_exchange_journal_id`, and one of its journal items is - matched with the other lines to balance the full reconciliation. - :param aml_to_fix: recordset of account.move.line (possible several - but sharing the same currency) - :param move: account.move - :return: tuple. - [0]: account.move.line created to balance the `aml_to_fix` - [1]: recordset of account.partial.reconcile created between the - tuple first element and the `aml_to_fix` - """ - partial_rec = self.env['account.partial.reconcile'] - aml_model = self.env['account.move.line'] - - created_lines = self.env['account.move.line'] - for aml in aml_to_fix: - #create the line that will compensate all the aml_to_fix - line_to_rec = aml_model.with_context(check_move_validity=False).create({ - 'name': _('Currency exchange rate difference'), - 'debit': aml.amount_residual < 0 and -aml.amount_residual or 0.0, - 'credit': aml.amount_residual > 0 and aml.amount_residual or 0.0, - 'account_id': aml.account_id.id, - 'move_id': move.id, - 'currency_id': aml.currency_id.id, - 'amount_currency': aml.amount_residual_currency and -aml.amount_residual_currency or 0.0, - 'partner_id': aml.partner_id.id, - }) - #create the counterpart on exchange gain/loss account - exchange_journal = move.company_id.currency_exchange_journal_id - aml_model.with_context(check_move_validity=False).create({ - 'name': _('Currency exchange rate difference'), - 'debit': aml.amount_residual > 0 and aml.amount_residual or 0.0, - 'credit': aml.amount_residual < 0 and -aml.amount_residual or 0.0, - 'account_id': aml.amount_residual > 0 and exchange_journal.default_debit_account_id.id or exchange_journal.default_credit_account_id.id, - 'move_id': move.id, - 'currency_id': aml.currency_id.id, - 'amount_currency': aml.amount_residual_currency and aml.amount_residual_currency or 0.0, - 'partner_id': aml.partner_id.id, - }) - - #reconcile all aml_to_fix - partial_rec |= self.create( - self._prepare_exchange_diff_partial_reconcile( - aml=aml, - line_to_reconcile=line_to_rec, - currency=aml.currency_id or False) - ) - created_lines |= line_to_rec - return created_lines, partial_rec - - def _get_tax_cash_basis_base_account(self, line, tax): - ''' Get the account of lines that will contain the base amount of taxes. - :param line: An account.move.line record - :param tax: An account.tax record - :return: An account record - ''' - return tax.cash_basis_base_account_id or line.account_id - - def _get_amount_tax_cash_basis(self, amount, line): - return line.company_id.currency_id.round(amount) - - def _set_tax_cash_basis_entry_date(self, move_date, newly_created_move): - if move_date > (self.company_id.period_lock_date or date.min) and newly_created_move.date != move_date: - # The move date should be the maximum date between payment and invoice (in case - # of payment in advance). However, we should make sure the move date is not - # recorded before the period lock date as the tax statement for this period is - # probably already sent to the estate. - newly_created_move.write({'date': move_date}) - newly_created_move.recompute(['name']) - - def create_tax_cash_basis_entry(self, percentage_before_rec): - self.ensure_one() - move_date = self.debit_move_id.date - newly_created_move = self.env['account.move'] - to_reconcile = [] - # We use a set here in case the reconciled lines belong to the same move (it happens with POS) - for move in {self.debit_move_id.move_id, self.credit_move_id.move_id}: - #move_date is the max of the 2 reconciled items - if move_date < move.date: - move_date = move.date - percentage_before = percentage_before_rec[move.id] - percentage_after = move.line_ids[0]._get_matched_percentage()[move.id] - # update the percentage before as the move can be part of - # multiple partial reconciliations - percentage_before_rec[move.id] = percentage_after - - for line in move.line_ids: - if not line.tax_exigible: - #amount is the current cash_basis amount minus the one before the reconciliation - amount = line.balance * percentage_after - line.balance * percentage_before - rounded_amt = self._get_amount_tax_cash_basis(amount, line) - if float_is_zero(rounded_amt, precision_rounding=line.company_id.currency_id.rounding): - continue - if line.tax_line_id and line.tax_line_id.tax_exigibility == 'on_payment': - if not newly_created_move: - newly_created_move = self._create_tax_basis_move() - #create cash basis entry for the tax line - to_clear_aml = self.env['account.move.line'].with_context(check_move_validity=False).create({ - 'name': line.move_id.name, - 'debit': abs(rounded_amt) if rounded_amt < 0 else 0.0, - 'credit': rounded_amt if rounded_amt > 0 else 0.0, - 'account_id': line.account_id.id, - 'analytic_account_id': line.analytic_account_id.id, - 'analytic_tag_ids': line.analytic_tag_ids.ids, - 'tax_exigible': True, - 'amount_currency': line.amount_currency and line.currency_id.round(-line.amount_currency * amount / line.balance) or 0.0, - 'currency_id': line.currency_id.id, - 'move_id': newly_created_move.id, - 'partner_id': line.partner_id.id, - 'journal_id': newly_created_move.journal_id.id, - }) - # Group by cash basis account and tax - self.env['account.move.line'].with_context(check_move_validity=False).create({ - 'name': line.name, - 'debit': rounded_amt if rounded_amt > 0 else 0.0, - 'credit': abs(rounded_amt) if rounded_amt < 0 else 0.0, - 'account_id': line.tax_repartition_line_id.account_id.id or line.account_id.id, - 'analytic_account_id': line.analytic_account_id.id, - 'analytic_tag_ids': line.analytic_tag_ids.ids, - 'tax_exigible': True, - 'amount_currency': line.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0, - 'currency_id': line.currency_id.id, - 'move_id': newly_created_move.id, - 'partner_id': line.partner_id.id, - 'journal_id': newly_created_move.journal_id.id, - 'tax_repartition_line_id': line.tax_repartition_line_id.id, - 'tax_base_amount': line.tax_base_amount, - 'tax_tag_ids': [(6, 0, line.tax_tag_ids.ids)], - }) - if line.account_id.reconcile and not line.reconciled: - to_reconcile.append(to_clear_aml + line) - - taxes_payment_exigible = line.tax_ids.flatten_taxes_hierarchy().filtered(lambda tax: tax.tax_exigibility == 'on_payment') - if taxes_payment_exigible: - if not newly_created_move: - newly_created_move = self._create_tax_basis_move() - #create cash basis entry for the base - for tax in taxes_payment_exigible: - account_id = self._get_tax_cash_basis_base_account(line, tax) - self.env['account.move.line'].with_context(check_move_validity=False).create({ - 'name': line.name, - 'debit': rounded_amt > 0 and rounded_amt or 0.0, - 'credit': rounded_amt < 0 and abs(rounded_amt) or 0.0, - 'account_id': account_id.id, - 'tax_exigible': True, - 'tax_ids': [(6, 0, [tax.id])], - 'move_id': newly_created_move.id, - 'currency_id': line.currency_id.id, - 'amount_currency': line.currency_id.round(line.amount_currency * amount / line.balance) if line.currency_id and line.balance else 0.0, - 'partner_id': line.partner_id.id, - 'journal_id': newly_created_move.journal_id.id, - 'tax_repartition_line_id': line.tax_repartition_line_id.id, - 'tax_base_amount': line.tax_base_amount, - 'tax_tag_ids': [(6, 0, line.tax_tag_ids.ids)], - }) - self.env['account.move.line'].with_context(check_move_validity=False).create({ - 'name': line.name, - 'credit': rounded_amt > 0 and rounded_amt or 0.0, - 'debit': rounded_amt < 0 and abs(rounded_amt) or 0.0, - 'account_id': account_id.id, - 'tax_exigible': True, - 'move_id': newly_created_move.id, - 'currency_id': line.currency_id.id, - 'amount_currency': line.currency_id.round(-line.amount_currency * amount / line.balance) if line.currency_id and line.balance else 0.0, - 'partner_id': line.partner_id.id, - 'journal_id': newly_created_move.journal_id.id, - }) - if newly_created_move: - self._set_tax_cash_basis_entry_date(move_date, newly_created_move) - # post move - newly_created_move.post() - for lines in to_reconcile: - lines.reconcile() - - def _create_tax_basis_move(self): - # Check if company_journal for cash basis is set if not, raise exception - if not self.company_id.tax_cash_basis_journal_id: - raise UserError(_('There is no tax cash basis journal defined ' - 'for this company: "%s" \nConfigure it in Accounting/Configuration/Settings') % - (self.company_id.name)) - move_vals = { - 'journal_id': self.company_id.tax_cash_basis_journal_id.id, - 'tax_cash_basis_rec_id': self.id, - 'ref': self.credit_move_id.move_id.name if self.credit_move_id.payment_id else self.debit_move_id.move_id.name, - } - return self.env['account.move'].create(move_vals) - - def unlink(self): - """ When removing a partial reconciliation, also unlink its full reconciliation if it exists """ - full_to_unlink = self.env['account.full.reconcile'] - for rec in self: - if rec.full_reconcile_id: - full_to_unlink |= rec.full_reconcile_id - #reverse the tax basis move created at the reconciliation time - for move in self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self._ids)]): - if move.date > (move.company_id.period_lock_date or date.min): - move._reverse_moves([{'ref': _('Reversal of %s') % move.name}], cancel=True) - else: - move._reverse_moves([{'date': fields.Date.today(), 'ref': _('Reversal of %s') % move.name}], cancel=True) - - # Unlink partials then the full in this order to avoid a recursive call to the same partials. - res = super(AccountPartialReconcile, self).unlink() - - if full_to_unlink: - full_to_unlink.unlink() - - return res - - -class AccountFullReconcile(models.Model): - _name = "account.full.reconcile" - _description = "Full Reconcile" - - name = fields.Char(string='Number', required=True, copy=False, default=lambda self: self.env['ir.sequence'].next_by_code('account.reconcile')) - partial_reconcile_ids = fields.One2many('account.partial.reconcile', 'full_reconcile_id', string='Reconciliation Parts') - reconciled_line_ids = fields.One2many('account.move.line', 'full_reconcile_id', string='Matched Journal Items') - exchange_move_id = fields.Many2one('account.move') - - def unlink(self): - """ When removing a full reconciliation, we need to revert the eventual journal entries we created to book the - fluctuation of the foreign currency's exchange rate. - We need also to reconcile together the origin currency difference line and its reversal in order to completely - cancel the currency difference entry on the partner account (otherwise it will still appear on the aged balance - for example). - """ - moves_to_reverse = self.exchange_move_id - - res = super(AccountFullReconcile, self).unlink() - - # Reverse all exchange moves at once. - today = fields.Date.today() - default_values_list = [{ - 'date': today, - 'ref': _('Reversal of: %s') % move.name, - } for move in moves_to_reverse] - moves_to_reverse._reverse_moves(default_values_list, cancel=True) - - return res - - @api.model - def _prepare_exchange_diff_move(self, move_date, company): - if not company.currency_exchange_journal_id: - raise UserError(_("You should configure the 'Exchange Rate Journal' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) - if not company.income_currency_exchange_account_id.id: - raise UserError(_("You should configure the 'Gain Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) - if not company.expense_currency_exchange_account_id.id: - raise UserError(_("You should configure the 'Loss Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) - res = {'journal_id': company.currency_exchange_journal_id.id} - # The move date should be the maximum date between payment and invoice - # (in case of payment in advance). However, we should make sure the - # move date is not recorded after the end of year closing. - if move_date > company._get_user_fiscal_lock_date(): - res['date'] = move_date - return res diff --git a/addons/account/models/account_partial_reconcile.py b/addons/account/models/account_partial_reconcile.py new file mode 100644 index 00000000000..6e445c20c85 --- /dev/null +++ b/addons/account/models/account_partial_reconcile.py @@ -0,0 +1,612 @@ +# -*- 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" + + # ==== 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.today() + 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, + } + + @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, + } + + @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. + # ========================================================================== + + balance_field = 'balance' if move_values['currency'] == move.company_id.currency_id else 'amount_currency' + + 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[balance_field] + + 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[balance_field] + + # ========================================================================== + # 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[balance_field] + + # ========================================================================== + # 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'] + + if move_values['currency'] == move.company_id.currency_id: + balance = residual_amount_per_group[grouping_key] + line_vals.update({ + 'debit': balance if balance > 0.0 else 0.0, + 'credit': -balance if balance < 0.0 else 0.0, + }) + else: + 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. + # ========================================================================== + + if move_values['currency'] == move.company_id.currency_id: + # Percentage expressed in the company's currency. + balance = line.company_currency_id.round(line.balance * partial_values['percentage']) + amount_currency = 0.0 + else: + # 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 + + if move_values['currency'] == move.company_id.currency_id: + pending_cash_basis_lines.append((grouping_key, line_vals['debit'] - line_vals['credit'])) + else: + 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() + + # 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 diff --git a/addons/account/models/account_tax.py b/addons/account/models/account_tax.py index b56e6168dab..e4cd2e88325 100644 --- a/addons/account/models/account_tax.py +++ b/addons/account/models/account_tax.py @@ -88,12 +88,6 @@ class AccountTax(models.Model): domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", comodel_name='account.account', help="Account used to transition the tax amount for cash basis taxes. It will contain the tax amount as long as the original invoice has not been reconciled ; at reconciliation, this amount cancelled on this account and put on the regular tax account.") - cash_basis_base_account_id = fields.Many2one( - 'account.account', - check_company=True, - domain="[('deprecated', '=', False), ('company_id', '=', company_id)]", - string='Base Tax Received Account', - help='Account that will be set on lines created in cash basis journal entry and used to keep track of the tax base amount.') invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line", inverse_name="invoice_tax_id", copy=True, help="Repartition when the tax is used on an invoice") refund_repartition_line_ids = fields.One2many(string="Repartition for Refund Invoices", comodel_name="account.tax.repartition.line", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund") country_id = fields.Many2one(string='Country', comodel_name='res.country', related='company_id.country_id', help="Technical field used to restrict the domain of account tags for tax repartition lines created for this tax.") diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 6f12b955724..c99be6bae59 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -121,6 +121,12 @@ class AccountChartTemplate(models.Model): property_tax_payable_account_id = fields.Many2one('account.account.template', string="Tax current account (payable)") property_tax_receivable_account_id = fields.Many2one('account.account.template', string="Tax current account (receivable)") property_advance_tax_payment_account_id = fields.Many2one('account.account.template', string="Advance tax payment account") + property_cash_basis_base_account_id = fields.Many2one( + comodel_name='account.account.template', + domain=[('deprecated', '=', False)], + string="Base Tax Received Account", + help="Account that will be set on lines created in cash basis journal entry and used to keep track of the " + "tax base amount.") @api.model def _prepare_transfer_account_template(self, prefix=None): @@ -249,6 +255,7 @@ class AccountChartTemplate(models.Model): 'default_cash_difference_income_account_id': acc_template_ref.get(self.default_cash_difference_income_account_id.id, False), 'default_cash_difference_expense_account_id': acc_template_ref.get(self.default_cash_difference_expense_account_id.id, False), 'account_journal_suspense_account_id': acc_template_ref.get(self.account_journal_suspense_account_id.id), + 'account_cash_basis_base_account_id': acc_template_ref.get(self.property_cash_basis_base_account_id.id), }) if not company.account_journal_suspense_account_id: @@ -555,10 +562,9 @@ class AccountChartTemplate(models.Model): # writing account values after creation of accounts for key, value in generated_tax_res['account_dict']['account.tax'].items(): - if value['cash_basis_transition_account_id'] or value['cash_basis_base_account_id']: + if value['cash_basis_transition_account_id']: AccountTaxObj.browse(key).write({ 'cash_basis_transition_account_id': account_ref.get(value['cash_basis_transition_account_id'], False), - 'cash_basis_base_account_id': account_ref.get(value['cash_basis_base_account_id'], False), }) AccountTaxRepartitionLineObj = self.env['account.tax.repartition.line'] @@ -855,11 +861,6 @@ class AccountTaxTemplate(models.Model): string="Cash Basis Transition Account", domain=[('deprecated', '=', False)], help="Account used to transition the tax amount for cash basis taxes. It will contain the tax amount as long as the original invoice has not been reconciled ; at reconciliation, this amount cancelled on this account and put on the regular tax account.") - cash_basis_base_account_id = fields.Many2one( - 'account.account.template', - domain=[('deprecated', '=', False)], - string='Base Tax Received Account', - help='Account that will be set on lines created in cash basis journal entry and used to keep track of the tax base amount.') _sql_constraints = [ ('name_company_uniq', 'unique(name, type_tax_use, chart_template_id)', 'Tax names must be unique !'), @@ -946,7 +947,6 @@ class AccountTaxTemplate(models.Model): # Since the accounts have not been created yet, we have to wait before filling these fields todo_dict['account.tax'][tax.id] = { 'cash_basis_transition_account_id': template.cash_basis_transition_account_id.id, - 'cash_basis_base_account_id': template.cash_basis_base_account_id.id, } # We also have to delay the assignation of accounts to repartition lines diff --git a/addons/account/models/company.py b/addons/account/models/company.py index 11e02db2baa..853b2321252 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -55,7 +55,6 @@ class ResCompany(models.Model): transfer_account_code_prefix = fields.Char(string='Prefix of the transfer accounts') account_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax") account_purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax") - tax_cash_basis_journal_id = fields.Many2one('account.journal', string="Cash Basis Journal") tax_calculation_rounding_method = fields.Selection([ ('round_per_line', 'Round per Line'), ('round_globally', 'Round Globally'), @@ -113,6 +112,17 @@ class ResCompany(models.Model): # Technical field to hide country specific fields in company form view country_code = fields.Char(related='country_id.code') + # Cash basis taxes + tax_cash_basis_journal_id = fields.Many2one( + comodel_name='account.journal', + string="Cash Basis Journal") + account_cash_basis_base_account_id = fields.Many2one( + comodel_name='account.account', + domain=[('deprecated', '=', False)], + string="Base Tax Received Account", + help="Account that will be set on lines created in cash basis journal entry and used to keep track of the " + "tax base amount.") + @api.constrains('account_opening_move_id', 'fiscalyear_last_day', 'fiscalyear_last_month') def _check_fiscalyear_last_day(self): # if the user explicitly chooses the 29th of February we allow it: diff --git a/addons/account/models/res_config_settings.py b/addons/account/models/res_config_settings.py index eb05b59737f..95166b32454 100644 --- a/addons/account/models/res_config_settings.py +++ b/addons/account/models/res_config_settings.py @@ -73,6 +73,12 @@ class ResConfigSettings(models.TransientModel): module_snailmail_account = fields.Boolean(string="Snailmail") tax_exigibility = fields.Boolean(string='Cash Basis', related='company_id.tax_exigibility', readonly=False) tax_cash_basis_journal_id = fields.Many2one('account.journal', related='company_id.tax_cash_basis_journal_id', string="Tax Cash Basis Journal", readonly=False) + account_cash_basis_base_account_id = fields.Many2one( + comodel_name='account.account', + string="Base Tax Received Account", + readonly=False, + related='company_id.account_cash_basis_base_account_id', + domain=[('deprecated', '=', False)]) qr_code = fields.Boolean(string='Display SEPA QR-code', related='company_id.qr_code', readonly=False) invoice_is_print = fields.Boolean(string='Print', related='company_id.invoice_is_print', readonly=False) diff --git a/addons/account/static/src/js/account_payment_field.js b/addons/account/static/src/js/account_payment_field.js index 71ed1dfd272..fa56d1a1858 100644 --- a/addons/account/static/src/js/account_payment_field.js +++ b/addons/account/static/src/js/account_payment_field.js @@ -59,19 +59,7 @@ var ShowPaymentLineWidget = AbstractField.extend({ var content = info.content[v]; var options = { content: function () { - var $content = $(QWeb.render('PaymentPopOver', { - name: content.name, - journal_name: content.journal_name, - date: content.date, - amount: content.amount, - currency: content.currency, - position: content.position, - payment_id: content.payment_id, - payment_method_name: content.payment_method_name, - move_id: content.move_id, - ref: content.ref, - account_payment_id: content.account_payment_id, - })); + var $content = $(QWeb.render('PaymentPopOver', content)); var unreconcile_button = $content.filter('.js_unreconcile_payment').on('click', self._onRemoveMoveReconcile.bind(self)); $content.filter('.js_open_payment').on('click', self._onOpenPayment.bind(self)); @@ -145,13 +133,13 @@ var ShowPaymentLineWidget = AbstractField.extend({ */ _onRemoveMoveReconcile: function (event) { var self = this; - var paymentId = parseInt($(event.target).attr('payment-id')); - if (paymentId !== undefined && !isNaN(paymentId)){ + var moveId = parseInt($(event.target).attr('move-id')); + var partialId = parseInt($(event.target).attr('partial-id')); + if (partialId !== undefined && !isNaN(partialId)){ this._rpc({ - model: 'account.move.line', - method: 'remove_move_reconcile', - args: [paymentId], - context: {'move_id': this.res_id}, + model: 'account.move', + method: 'js_remove_outstanding_partial', + args: [moveId, partialId], }).then(function () { self.trigger_up('reload'); }); diff --git a/addons/account/static/src/xml/account_payment.xml b/addons/account/static/src/xml/account_payment.xml index 8d6f484cb6c..c752f838123 100644 --- a/addons/account/static/src/xml/account_payment.xml +++ b/addons/account/static/src/xml/account_payment.xml @@ -78,7 +78,7 @@ </tr> </table> </div> - <button class="btn btn-sm btn-primary js_unreconcile_payment float-left" t-att-payment-id="payment_id" style="margin-top:5px; margin-bottom:5px;" groups="account.group_account_invoice">Unreconcile</button> + <button class="btn btn-sm btn-primary js_unreconcile_payment float-left" t-att-partial-id="partial_id" t-att-payment-id="payment_id" t-att-move-id="move_id" style="margin-top:5px; margin-bottom:5px;" groups="account.group_account_invoice">Unreconcile</button> <button class="btn btn-sm btn-secondary js_open_payment float-right" t-att-payment-id="account_payment_id" t-att-move-id="move_id" style="margin-top:5px; margin-bottom:5px;">View</button> </t> diff --git a/addons/account/static/tests/account_payment_field_tests.js b/addons/account/static/tests/account_payment_field_tests.js index 379f099d29c..48609728edd 100644 --- a/addons/account/static/tests/account_payment_field_tests.js +++ b/addons/account/static/tests/account_payment_field_tests.js @@ -16,7 +16,7 @@ QUnit.module('account', { }, records: [{ id: 1, - payments_widget: '{"content": [{"digits": [69, 2], "currency": "$", "amount": 555.0, "name": "Customer Payment: INV/2017/0004", "date": "2017-04-25", "position": "before", "ref": "BNK1/2017/0003 (INV/2017/0004)", "payment_id": 22, "move_id": 10, "journal_name": "Bank"}], "outstanding": false, "title": "Less Payment"}', + payments_widget: '{"content": [{"digits": [69, 2], "currency": "$", "amount": 555.0, "name": "Customer Payment: INV/2017/0004", "date": "2017-04-25", "position": "before", "ref": "BNK1/2017/0003 (INV/2017/0004)", "payment_id": 22, "move_id": 10, "partial_id": 38, "journal_name": "Bank"}], "outstanding": false, "title": "Less Payment"}', outstanding_credits_debits_widget: '{"content": [{"digits": [69, 2], "currency": "$", "amount": 100.0, "journal_name": "INV/2017/0004", "position": "before", "id": 20}], "move_id": 4, "outstanding": true, "title": "Outstanding credits"}', }] }, @@ -26,7 +26,7 @@ QUnit.module('account', { QUnit.module('Reconciliation'); QUnit.test('Reconciliation form field', async function (assert) { - assert.expect(6); + assert.expect(5); var form = await createView({ View: FormView, @@ -38,9 +38,8 @@ QUnit.module('account', { '</form>', res_id: 1, mockRPC: function (route, args) { - if (args.method === 'remove_move_reconcile') { - assert.deepEqual(args.args, [22], "should call remove_move_reconcile {warning: required focus}"); - assert.deepEqual(args.kwargs, {context: {"move_id": 1}}, "should call remove_move_reconcile {warning: required focus}"); + if (args.method === 'js_remove_outstanding_partial') { + assert.deepEqual(args.args, [10, 38], "should call js_remove_outstanding_partial {warning: required focus}"); return Promise.resolve(); } if (args.method === 'js_assign_outstanding_line') { diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index b6e8b294a63..552b40ccb12 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from . import test_account_move_reconcile +from . import test_account_move_payments_widget from . import test_account_move_out_invoice from . import test_account_move_out_refund from . import test_account_move_in_invoice diff --git a/addons/account/tests/common.py b/addons/account/tests/common.py index 15e2982c1d5..b28d51d18db 100644 --- a/addons/account/tests/common.py +++ b/addons/account/tests/common.py @@ -374,6 +374,24 @@ class AccountTestNoChartCommon(SavepointCaseWithUserDemo): 'code': 'GJT0', }) + # Exchange journal. + cls.exchange_journal0 = cls.env['account.journal'].create({ + 'name': 'Exchange Difference - (test)', + 'type': 'general', + 'code': 'EXCH', + 'default_debit_account_id': cls.env['account.account'].create({ + 'name': 'Foreign Exchange Gain - (test)', + 'code': 'NC4410', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + }).id, + 'default_credit_account_id': cls.env['account.account'].create({ + 'name': 'Foreign Exchange Loss - (test)', + 'code': 'NC6410', + 'user_type_id': cls.env.ref('account.data_account_type_expenses').id, + }).id, + }) + cls.env.user.company_id.currency_exchange_journal_id = cls.exchange_journal0 + @classmethod def setUpAdditionalAccounts(cls): """ Set up some addionnal accounts: expenses, revenue, ... """ @@ -504,6 +522,27 @@ class AccountTestNoChartCommonMultiCompany(AccountTestNoChartCommon): 'company_id': cls.company_B.id }) + # Exchange journal. + cls.exchange_journal0 = cls.env['account.journal'].create({ + 'name': 'Exchange Difference - (test)', + 'type': 'general', + 'code': 'EXCH', + 'company_id': cls.company_B.id, + 'default_debit_account_id': cls.env['account.account'].create({ + 'name': 'Foreign Exchange Gain - (test)', + 'code': 'NC4410', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_B.id + }).id, + 'default_credit_account_id': cls.env['account.account'].create({ + 'name': 'Foreign Exchange Loss - (test)', + 'code': 'NC6410', + 'user_type_id': cls.env.ref('account.data_account_type_expenses').id, + 'company_id': cls.company_B.id + }).id, + }) + cls.company_B.currency_exchange_journal_id = cls.exchange_journal0 + @classmethod def setUpAdditionalAccounts(cls): """ Set up some addionnal accounts: expenses, revenue, ... """ @@ -1072,6 +1111,8 @@ class TestAccountReconciliationCommon(AccountTestCommon): 'user_type_id': cls.env.ref('account.data_account_type_current_assets').id, 'company_id': cls.company.id, }) + cls.company.account_cash_basis_base_account_id = cls.tax_base_amount_account.id + # Journals cls.purchase_journal = cls.env['account.journal'].create({ @@ -1100,7 +1141,6 @@ class TestAccountReconciliationCommon(AccountTestCommon): 'amount': 20, 'tax_exigibility': 'on_payment', 'cash_basis_transition_account_id': cls.tax_waiting_account.id, - 'cash_basis_base_account_id': cls.tax_base_amount_account.id, 'invoice_repartition_line_ids': [ (0,0, { 'factor_percent': 100, diff --git a/addons/account/tests/test_account_move_payments_widget.py b/addons/account/tests/test_account_move_payments_widget.py new file mode 100644 index 00000000000..9f3ba3f4674 --- /dev/null +++ b/addons/account/tests/test_account_move_payments_widget.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + +import json + + +@tagged('post_install', '-at_install') +class TestAccountMovePaymentsWidget(AccountTestInvoicingCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.receivable_account = cls.company_data['default_account_receivable'] + cls.payable_account = cls.company_data['default_account_payable'] + + cls.currency_data_2 = cls.setup_multi_currency_data(default_values={ + 'name': 'Stars', + 'symbol': '☆', + 'currency_unit_label': 'Stars', + 'currency_subunit_label': 'Little Stars', + }, rate2016=6.0, rate2017=4.0) + + cls.curr_1 = cls.company_data['currency'] + cls.curr_2 = cls.currency_data['currency'] + cls.curr_3 = cls.currency_data_2['currency'] + + cls.payment_2016_curr_1 = cls.env['account.move'].create({ + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': 0.0, 'currency_id': False, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': False, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2016_curr_1.post() + + cls.payment_2016_curr_2 = cls.env['account.move'].create({ + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -1550.0, 'currency_id': cls.curr_2.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 1550.0, 'currency_id': cls.curr_2.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2016_curr_2.post() + + cls.payment_2017_curr_2 = cls.env['account.move'].create({ + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -950.0, 'currency_id': cls.curr_2.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 950.0, 'currency_id': cls.curr_2.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2017_curr_2.post() + + cls.payment_2016_curr_3 = cls.env['account.move'].create({ + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -3050.0, 'currency_id': cls.curr_3.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 3050.0, 'currency_id': cls.curr_3.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2016_curr_3.post() + + cls.payment_2017_curr_3 = cls.env['account.move'].create({ + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 500.0, 'amount_currency': -1950.0, 'currency_id': cls.curr_3.id, 'account_id': cls.receivable_account.id, 'partner_id': cls.partner_a.id}), + (0, 0, {'debit': 500.0, 'credit': 0.0, 'amount_currency': 1950.0, 'currency_id': cls.curr_3.id, 'account_id': cls.payable_account.id, 'partner_id': cls.partner_a.id}), + ], + }) + cls.payment_2017_curr_3.post() + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def _test_all_outstanding_payments(self, invoice, expected_amounts): + ''' Check the outstanding payments widget before/after the reconciliation. + :param invoice: An account.move record. + :param expected_amounts: A map <move_id> -> <amount> + ''' + + # Check suggested outstanding payments. + to_reconcile_payments_widget_vals = json.loads(invoice.invoice_outstanding_credits_debits_widget) + + self.assertTrue(to_reconcile_payments_widget_vals) + + current_amounts = {vals['move_id']: vals['amount'] for vals in to_reconcile_payments_widget_vals['content']} + self.assertDictEqual(current_amounts, expected_amounts) + + # Reconcile + pay_term_lines = invoice.line_ids\ + .filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + to_reconcile = self.env['account.move'].browse(list(current_amounts.keys()))\ + .line_ids\ + .filtered(lambda line: line.account_id == pay_term_lines.account_id) + (pay_term_lines + to_reconcile).reconcile() + + # Check payments after reconciliation. + reconciled_payments_widget_vals = json.loads(invoice.invoice_payments_widget) + + self.assertTrue(reconciled_payments_widget_vals) + + current_amounts = {vals['move_id']: vals['amount'] for vals in reconciled_payments_widget_vals['content']} + self.assertDictEqual(current_amounts, expected_amounts) + + # ------------------------------------------------------------------------- + # TESTS + # ------------------------------------------------------------------------- + + def test_outstanding_payments_single_currency(self): + ''' Test the outstanding payments widget on invoices having the same currency + as the company one. + ''' + + # Customer invoice of 2500.0 in curr_1. + out_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_1.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 2500.0})], + }) + out_invoice.post() + + # Vendor bill of 2500.0 in curr_1. + in_invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_1.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 2500.0})], + }) + in_invoice.post() + + expected_amounts = { + self.payment_2016_curr_1.id: 500.0, + self.payment_2016_curr_2.id: 500.0, + self.payment_2017_curr_2.id: 500.0, + self.payment_2016_curr_3.id: 500.0, + self.payment_2017_curr_3.id: 500.0, + } + + self._test_all_outstanding_payments(out_invoice, expected_amounts) + self._test_all_outstanding_payments(in_invoice, expected_amounts) + + def test_outstanding_payments_foreign_currency(self): + ''' Test the outstanding payments widget on invoices having a foreign currency. ''' + + # Customer invoice of 2500.0 in curr_1. + out_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_2.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 7500.0})], + }) + out_invoice.post() + + # Vendor bill of 2500.0 in curr_1. + in_invoice = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'date': '2017-01-01', + 'invoice_date': '2017-01-01', + 'partner_id': self.partner_a.id, + 'currency_id': self.curr_2.id, + 'invoice_line_ids': [(0, 0, {'name': '/', 'price_unit': 7500.0})], + }) + in_invoice.post() + + expected_amounts = { + self.payment_2016_curr_1.id: 1500.0, + self.payment_2016_curr_2.id: 1550.0, + self.payment_2017_curr_2.id: 950.0, + self.payment_2016_curr_3.id: 1500.0, + self.payment_2017_curr_3.id: 1000.0, + } + + self._test_all_outstanding_payments(out_invoice, expected_amounts) + self._test_all_outstanding_payments(in_invoice, expected_amounts) diff --git a/addons/account/tests/test_account_move_reconcile.py b/addons/account/tests/test_account_move_reconcile.py new file mode 100644 index 00000000000..76b5b51cb17 --- /dev/null +++ b/addons/account/tests/test_account_move_reconcile.py @@ -0,0 +1,1635 @@ +# -*- coding: utf-8 -*- +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountMoveReconcile(AccountTestInvoicingCommon): + ''' Tests about the account.partial.reconcile model, not the reconciliation itself but mainly the computation of + the residual amounts on account.move.line. + ''' + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.extra_receivable_account_1 = cls.copy_account(cls.company_data['default_account_receivable']) + cls.extra_receivable_account_2 = cls.copy_account(cls.company_data['default_account_receivable']) + cls.extra_payable_account_1 = cls.copy_account(cls.company_data['default_account_payable']) + cls.extra_payable_account_2 = cls.copy_account(cls.company_data['default_account_payable']) + + # ==== Multi-currency setup ==== + + cls.currency_data_2 = cls.setup_multi_currency_data(default_values={ + 'name': 'Diamond', + 'symbol': '💎', + 'currency_unit_label': 'Diamond', + 'currency_subunit_label': 'Carbon', + }, rate2016=6.0, rate2017=4.0) + + # ==== Cash Basis Taxes setup ==== + + cls.cash_basis_base_account = cls.env['account.account'].create({ + 'code': 'cash_basis_base_account', + 'name': 'cash_basis_base_account', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + cls.company_data['company'].account_cash_basis_base_account_id = cls.cash_basis_base_account + + cls.cash_basis_transfer_account = cls.env['account.account'].create({ + 'code': 'cash_basis_transfer_account', + 'name': 'cash_basis_transfer_account', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + + cls.tax_account_1 = cls.env['account.account'].create({ + 'code': 'tax_account_1', + 'name': 'tax_account_1', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + + cls.tax_account_2 = cls.env['account.account'].create({ + 'code': 'tax_account_2', + 'name': 'tax_account_2', + 'user_type_id': cls.env.ref('account.data_account_type_revenue').id, + 'company_id': cls.company_data['company'].id, + }) + + cls.fake_country = cls.env['res.country'].create({ + 'name': "The Island of the Fly", + 'code': 'YY', + }) + + cls.tax_tags = cls.env['account.account.tag'].create({ + 'name': 'tax_tag_%s' % str(i), + 'applicability': 'taxes', + 'country_id': cls.fake_country.id, + } for i in range(8)) + + cls.cash_basis_tax_a_third_amount = cls.env['account.tax'].create({ + 'name': 'tax_1', + 'amount': 33.3333, + 'company_id': cls.company_data['company'].id, + 'cash_basis_transition_account_id': cls.cash_basis_transfer_account.id, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[0].ids)], + }), + + (0, 0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_1.id, + 'tag_ids': [(6, 0, cls.tax_tags[1].ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[2].ids)], + }), + + (0, 0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_1.id, + 'tag_ids': [(6, 0, cls.tax_tags[3].ids)], + }), + ], + }) + + cls.cash_basis_tax_tiny_amount = cls.env['account.tax'].create({ + 'name': 'cash_basis_tax_tiny_amount', + 'amount': 0.0001, + 'company_id': cls.company_data['company'].id, + 'cash_basis_transition_account_id': cls.cash_basis_transfer_account.id, + 'invoice_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[4].ids)], + }), + + (0, 0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_2.id, + 'tag_ids': [(6, 0, cls.tax_tags[5].ids)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'base', + 'tag_ids': [(6, 0, cls.tax_tags[6].ids)], + }), + + (0, 0, { + 'factor_percent': 10, + 'repartition_type': 'tax', + 'account_id': cls.tax_account_2.id, + 'tag_ids': [(6, 0, cls.tax_tags[7].ids)], + }), + ], + }) + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def assertFullReconcile(self, full_reconcile, lines): + exchange_difference_move = full_reconcile.exchange_move_id + partials = lines.mapped('matched_debit_ids') + lines.mapped('matched_credit_ids') + + if full_reconcile.exchange_move_id: + lines += exchange_difference_move.line_ids.filtered(lambda line: line.account_id == lines[0].account_id) + + # Use sets to not depend of the order. + self.assertEqual(set(full_reconcile.partial_reconcile_ids), set(partials)) + self.assertEqual(set(full_reconcile.reconciled_line_ids), set(lines)) + + # Ensure there is no residual amount left. + self.assertRecordValues(lines, [{ + 'amount_residual': 0.0, + 'amount_residual_currency': 0.0, + 'reconciled': bool(line.account_id.reconcile), + } for line in lines]) + + def assertPartialReconcile(self, partials, expected_vals_list): + partials = partials.sorted(lambda part: ( + part.amount, + part.debit_amount_currency, + part.credit_amount_currency, + )) + self.assertRecordValues(partials, expected_vals_list) + + def assertAmountsGroupByAccount(self, amount_per_account): + expected_values = {account.id: (account, balance, amount_currency) for account, balance, amount_currency in amount_per_account} + + if not expected_values: + return + + self.cr.execute(''' + SELECT + line.account_id, + COALESCE(SUM(line.balance), 0.0) AS total_balance, + COALESCE(SUM(line.amount_currency), 0.0) AS total_amount_currency + FROM account_move_line line + WHERE line.account_id IN %s + GROUP BY line.account_id + ''', [tuple(expected_values.keys())]) + for account_id, total_balance, total_amount_currency in self.cr.fetchall(): + account, expected_balance, expected_amount_currency = expected_values[account_id] + self.assertEqual( + total_balance, + expected_balance, + "Balance of %s is incorrect" % account.name, + ) + self.assertEqual( + total_amount_currency, + expected_amount_currency, + "Amount currency of %s is incorrect" % account.name, + ) + + def assertTaxGridAmounts(self, amount_per_tag): + expected_values = {tag.id: (tag, balance) for tag, balance in amount_per_tag} + + if not expected_values: + return + + self.cr.execute(''' + SELECT + rel.account_account_tag_id, + SUM(line.balance) + FROM account_account_tag_account_move_line_rel rel + JOIN account_move_line line ON line.id = rel.account_move_line_id + WHERE line.tax_exigible IS TRUE + GROUP BY rel.account_account_tag_id + ''') + + for tag_id, total_balance in self.cr.fetchall(): + tag, expected_balance = expected_values[tag_id] + self.assertEqual( + total_balance, + expected_balance, + "Balance of %s is incorrect" % tag.name, + ) + + # ------------------------------------------------------------------------- + # Test creation of account.partial.reconcile/account.full.reconcile + # during the reconciliation. + # ------------------------------------------------------------------------- + + def test_reconcile_single_currency(self): + account_id = self.company_data['default_account_receivable'].id + + move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 1000.0, 'credit': 0.0, 'account_id': account_id}), + (0, 0, {'debit': 200.0, 'credit': 0.0, 'account_id': account_id}), + (0, 0, {'debit': 0.0, 'credit': 300.0, 'account_id': account_id}), + (0, 0, {'debit': 0.0, 'credit': 400.0, 'account_id': account_id}), + (0, 0, {'debit': 0.0, 'credit': 500.0, 'account_id': account_id}), + ] + }) + move.post() + + line_1 = move.line_ids.filtered(lambda line: line.debit == 1000.0) + line_2 = move.line_ids.filtered(lambda line: line.debit == 200.0) + line_3 = move.line_ids.filtered(lambda line: line.credit == 300.0) + line_4 = move.line_ids.filtered(lambda line: line.credit == 400.0) + line_5 = move.line_ids.filtered(lambda line: line.credit == 500.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4 + line_5, [ + {'amount_residual': 1000.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + {'amount_residual': 200.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + {'amount_residual': -300.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + {'amount_residual': -400.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + {'amount_residual': -500.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + ]) + + res = (line_1 + line_3).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 300.0, + 'debit_amount_currency': 300.0, + 'credit_amount_currency': 300.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }]) + + self.assertRecordValues(line_1 + line_3, [ + {'amount_residual': 700.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + res = (line_1 + line_4).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 400.0, + 'debit_amount_currency': 400.0, + 'credit_amount_currency': 400.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_4.id, + }]) + + self.assertRecordValues(line_1 + line_4, [ + {'amount_residual': 300.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + res = (line_1 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 300.0, + 'debit_amount_currency': 300.0, + 'credit_amount_currency': 300.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(line_1 + line_5, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': -200.0, 'amount_residual_currency': 0.0, 'reconciled': False}, + ]) + + res = (line_2 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 200.0, + 'debit_amount_currency': 200.0, + 'credit_amount_currency': 200.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(res['full_reconcile'], [{'exchange_move_id': False}]) + self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5) + + def test_reconcile_same_foreign_currency(self): + account_id = self.company_data['default_account_receivable'].id + + # Rate is 3.0 in 2016, 2.0 in 2017. + currency_id = self.currency_data['currency'].id + + moves = self.env['account.move'].create([ + { + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 1200.0, 'credit': 0.0, 'amount_currency': 3600.0, 'account_id': account_id, 'currency_id': currency_id}), + (0, 0, {'debit': 120.0, 'credit': 0.0, 'amount_currency': 360.0, 'account_id': account_id, 'currency_id': currency_id}), + + (0, 0, {'debit': 0.0, 'credit': 1320.0, 'account_id': account_id}), + ] + }, + { + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 240.0, 'amount_currency': -480.0, 'account_id': account_id, 'currency_id': currency_id}), + (0, 0, {'debit': 0.0, 'credit': 720.0, 'amount_currency': -1440.0, 'account_id': account_id, 'currency_id': currency_id}), + (0, 0, {'debit': 0.0, 'credit': 1020.0, 'amount_currency': -2040.0, 'account_id': account_id, 'currency_id': currency_id}), + + (0, 0, {'debit': 1980.0, 'credit': 0.0, 'account_id': account_id}), + ] + } + ]) + + moves.post() + + line_1 = moves.line_ids.filtered(lambda line: line.debit == 1200.0) + line_2 = moves.line_ids.filtered(lambda line: line.debit == 120.0) + line_3 = moves.line_ids.filtered(lambda line: line.credit == 240.0) + line_4 = moves.line_ids.filtered(lambda line: line.credit == 720.0) + line_5 = moves.line_ids.filtered(lambda line: line.credit == 1020.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4 + line_5, [ + {'amount_residual': 1200.0, 'amount_residual_currency': 3600.0, 'reconciled': False}, + {'amount_residual': 120.0, 'amount_residual_currency': 360.0, 'reconciled': False}, + {'amount_residual': -240.0, 'amount_residual_currency': -480.0, 'reconciled': False}, + {'amount_residual': -720.0, 'amount_residual_currency': -1440.0, 'reconciled': False}, + {'amount_residual': -1020.0, 'amount_residual_currency': -2040.0, 'reconciled': False}, + ]) + + res = (line_1 + line_3 + line_4).reconcile() + + self.assertPartialReconcile(res['partials'], [ + # Partial generated when reconciling line_1 & line_3: + { + 'amount': 240.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 480.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }, + # Partial generated when reconciling line_1 & line_4: + { + 'amount': 720.0, + 'debit_amount_currency': 1440.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_4.id, + }, + ]) + + self.assertRecordValues(line_1 + line_3 + line_4, [ + {'amount_residual': 240.0, 'amount_residual_currency': 1680.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + ]) + + res = (line_1 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 240.0, + 'debit_amount_currency': 1680.0, + 'credit_amount_currency': 1680.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(line_1 + line_5, [ + {'amount_residual': 0.0, 'amount_residual_currency': 0.0, 'reconciled': True}, + {'amount_residual': -780.0, 'amount_residual_currency': -360.0, 'reconciled': False}, + ]) + + res = (line_2 + line_5).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + # Fix line_2: + { + 'debit': 660.0, + 'credit': 0.0, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': account_id, + }, + { + 'debit': 0.0, + 'credit': 660.0, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': exchange_diff.journal_id.default_credit_account_id.id, + }, + ]) + + self.assertPartialReconcile(res['partials'], [ + # Partial generated when reconciling line_2 & line_5: + { + 'amount': 120.0, + 'debit_amount_currency': 360.0, + 'credit_amount_currency': 360.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_5.id, + }, + # Partial fixing line_4 (exchange difference): + { + 'amount': 660.0, + 'debit_amount_currency': 0.0, + 'credit_amount_currency': 0.0, + 'debit_move_id': exchange_diff_lines[0].id, + 'credit_move_id': line_5.id, + }, + ]) + + self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5) + + def test_reconcile_multiple_currencies(self): + account_id = self.company_data['default_account_receivable'].id + + # Rate is 3.0 in 2016, 2.0 in 2017. + currency1_id = self.currency_data['currency'].id + # Rate is 6.0 in 2016, 4.0 in 2017. + currency2_id = self.currency_data_2['currency'].id + + moves = self.env['account.move'].create([ + { + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 1200.0, 'credit': 0.0, 'amount_currency': 3600.0, 'account_id': account_id, 'currency_id': currency1_id}), + (0, 0, {'debit': 780.0, 'credit': 0.0, 'amount_currency': 2340.0, 'account_id': account_id, 'currency_id': currency1_id}), + + (0, 0, {'debit': 0.0, 'credit': 1980.0, 'account_id': account_id}), + ] + }, + { + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 240.0, 'amount_currency': -960.0, 'account_id': account_id, 'currency_id': currency2_id}), + (0, 0, {'debit': 0.0, 'credit': 720.0, 'amount_currency': -2880.0, 'account_id': account_id, 'currency_id': currency2_id}), + (0, 0, {'debit': 0.0, 'credit': 1020.0, 'amount_currency': -4080.0, 'account_id': account_id, 'currency_id': currency2_id}), + + (0, 0, {'debit': 1980.0, 'credit': 0.0, 'account_id': account_id}), + ] + } + ]) + + moves.post() + + line_1 = moves.line_ids.filtered(lambda line: line.debit == 1200.0) + line_2 = moves.line_ids.filtered(lambda line: line.debit == 780.0) + line_3 = moves.line_ids.filtered(lambda line: line.credit == 240.0) + line_4 = moves.line_ids.filtered(lambda line: line.credit == 720.0) + line_5 = moves.line_ids.filtered(lambda line: line.credit == 1020.0) + + self.assertRecordValues(line_1 + line_2 + line_3 + line_4 + line_5, [ + {'amount_residual': 1200.0, 'amount_residual_currency': 3600.0, 'reconciled': False}, + {'amount_residual': 780.0, 'amount_residual_currency': 2340.0, 'reconciled': False}, + {'amount_residual': -240.0, 'amount_residual_currency': -960.0, 'reconciled': False}, + {'amount_residual': -720.0, 'amount_residual_currency': -2880.0, 'reconciled': False}, + {'amount_residual': -1020.0, 'amount_residual_currency': -4080.0, 'reconciled': False}, + ]) + + res = (line_1 + line_3 + line_4).reconcile() + + self.assertPartialReconcile(res['partials'], [ + # Partial generated when reconciling line_1 & line_3: + { + 'amount': 240.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_3.id, + }, + # Partial generated when reconciling line_1 & line_4: + { + 'amount': 720.0, + 'debit_amount_currency': 1440.0, + 'credit_amount_currency': 4320.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_4.id, + }, + ]) + + self.assertRecordValues(line_1 + line_3 + line_4, [ + {'amount_residual': 240.0, 'amount_residual_currency': 1680.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 480.0, 'reconciled': False}, + {'amount_residual': 0.0, 'amount_residual_currency': 1440.0, 'reconciled': False}, + ]) + + res = (line_1 + line_5).reconcile() + + self.assertPartialReconcile(res['partials'], [{ + 'amount': 240.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_1.id, + 'credit_move_id': line_5.id, + }]) + + self.assertRecordValues(line_1 + line_5, [ + {'amount_residual': 0.0, 'amount_residual_currency': 1200.0, 'reconciled': False}, + {'amount_residual': -780.0, 'amount_residual_currency': -2640.0, 'reconciled': False}, + ]) + + res = (line_2 + line_5).reconcile() + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids.sorted(lambda line: (line.currency_id, abs(line.amount_currency), -line.amount_currency)) + + self.assertRecordValues(exchange_diff_lines, [ + # Fix line_2: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 780.0, + 'currency_id': currency1_id, + 'account_id': exchange_diff.journal_id.default_debit_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -780.0, + 'currency_id': currency1_id, + 'account_id': account_id, + }, + # Fix line_3: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 480.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.default_debit_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -480.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + # Fix line_4: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 1440.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.default_debit_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -1440.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + # Fix line_5: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 2040.0, + 'currency_id': currency2_id, + 'account_id': exchange_diff.journal_id.default_debit_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -2040.0, + 'currency_id': currency2_id, + 'account_id': account_id, + }, + # Fix line_1: + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': 1200.0, + 'currency_id': currency1_id, + 'account_id': exchange_diff.journal_id.default_debit_account_id.id, + }, + { + 'debit': 0.0, + 'credit': 0.0, + 'amount_currency': -1200.0, + 'currency_id': currency1_id, + 'account_id': account_id, + }, + ]) + + self.assertPartialReconcile(res['partials'], [ + # Partial fixing line_3 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 480.0, + 'credit_amount_currency': 480.0, + 'debit_move_id': line_3.id, + 'credit_move_id': exchange_diff_lines[3].id, + }, + # Partial fixing line_2 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 780.0, + 'credit_amount_currency': 780.0, + 'debit_move_id': line_2.id, + 'credit_move_id': exchange_diff_lines[1].id, + }, + # Partial fixing line_1 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 1200.0, + 'credit_amount_currency': 1200.0, + 'debit_move_id': line_1.id, + 'credit_move_id': exchange_diff_lines[9].id, + }, + # Partial fixing line_4 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 1440.0, + 'credit_amount_currency': 1440.0, + 'debit_move_id': line_4.id, + 'credit_move_id': exchange_diff_lines[5].id, + }, + # Partial fixing line_5 (exchange difference): + { + 'amount': 0.0, + 'debit_amount_currency': 2040.0, + 'credit_amount_currency': 2040.0, + 'debit_move_id': line_5.id, + 'credit_move_id': exchange_diff_lines[7].id, + }, + # Partial generated when reconciling line_2 & line_5: + { + 'amount': 780.0, + 'debit_amount_currency': 1560.0, + 'credit_amount_currency': 4680.0, + 'debit_move_id': line_2.id, + 'credit_move_id': line_5.id, + }, + ]) + + self.assertFullReconcile(res['full_reconcile'], line_1 + line_2 + line_3 + line_4 + line_5) + + # ------------------------------------------------------------------------- + # Test creation of extra journal entries during the reconciliation to + # deal with taxes that are exigible on payment (cash basis). + # ------------------------------------------------------------------------- + + def test_reconcile_cash_basis_workflow_single_currency(self): + ''' Test the generated journal entries during the reconciliation to manage the cash basis taxes. + Also, + - Test the case when there is multiple receivable/payable accounts. + - Test the reconciliation with tiny amounts. + - Check there is no rounding issue when making the percentage. + - Check there is no lost cents when the journal entry is fully reconciled. + ''' + cash_basis_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, (self.cash_basis_tax_a_third_amount + self.cash_basis_tax_tiny_amount).ids)], + 'tax_exigible': False, + }), + + # Tax lines + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_tiny_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 44.45, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 44.45, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 44.45, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'account_id': self.extra_payable_account_1.id, + }), + ] + }) + + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 33.34, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 11.11, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 88.89, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.0, 'credit': 0.01, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.01, 'credit': 0.0, 'account_id': self.extra_payable_account_1.id}), + (0, 0, {'debit': 133.34, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (cash_basis_move + payment_move).post() + + # Initial amounts by accounts: + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -33.34, 0.0), + (self.tax_account_1, 0.0, 0.0), + (self.tax_account_2, 0.0, 0.0), + ]) + + # There is 44.45 + 44.45 + 44.45 + 0.01 = 133.36 to reconcile on 'cash_basis_move'. + # Reconciling all the amount in extra_receivable_account_1 should compute 2 percentages: + # 33.34 / 133.36 = 0.25 + # 11.11 / 133.36 = 0.083308338 + + receivable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 2) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 25.0, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 25.0, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 8.33, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 8.33, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 8.33, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 8.33, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 2.78, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 2.78, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -22.23, 0.0), + (self.tax_account_1, -11.11, 0.0), + (self.tax_account_2, 0.0, 0.0), + ]) + + # Reconciling all the amount in extra_receivable_account_2 should compute 3 percentages: + # 44.45 / 133.36 = 0.333308338 + # 44.44 / 133.36 = 0.333233353 + # 0.01 / 133.36 = 0.000074985 + + receivable_lines_2 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_2) + res = receivable_lines_2.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_2) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 3) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 33.33, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 33.33, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 11.11, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 11.11, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 33.32, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 33.32, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 11.11, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 11.11, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][2].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -0.01, 0.0), + (self.tax_account_1, -33.33, 0.0), + (self.tax_account_2, 0.0, 0.0), + ]) + + # Reconciling all the amount in extra_payable_account_1 should trigger the matching number and ensure all + # the base amount has been covered without any rounding issue. + + payable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_payable_account_1) + res = payable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], payable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.01, 'credit': 0.0, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.01, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -33.33, 0.0), + (self.tax_account_2, -0.01, 0.0), + ]) + + def test_reconcile_cash_basis_workflow_multi_currency(self): + ''' Same as before with a foreign currency. ''' + + currency_id = self.currency_data['currency'].id + + cash_basis_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.34, + 'amount_currency': -100.0, + 'currency_id': currency_id, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, (self.cash_basis_tax_a_third_amount + self.cash_basis_tax_tiny_amount).ids)], + 'tax_exigible': False, + }), + + # Tax lines + (0, 0, { + 'debit': 0.0, + 'credit': 11.10, + 'amount_currency': -33.33, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'amount_currency': -0.01, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_tiny_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 14.82, + 'credit': 0.0, + 'amount_currency': 44.45, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 14.82, + 'credit': 0.0, + 'amount_currency': 44.45, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 14.82, + 'credit': 0.0, + 'amount_currency': 44.45, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_2.id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'amount_currency': -0.01, + 'currency_id': currency_id, + 'account_id': self.extra_payable_account_1.id, + }), + ] + }) + + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 16.67, 'amount_currency': -33.34, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 5.6, 'amount_currency': -11.11, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 0.0, 'credit': 44.45, 'amount_currency': -88.89, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.0, 'credit': 0.01, 'amount_currency': -0.01, 'currency_id': currency_id, 'account_id': self.extra_receivable_account_2.id}), + (0, 0, {'debit': 0.01, 'credit': 0.0, 'amount_currency': 0.01, 'currency_id': currency_id, 'account_id': self.extra_payable_account_1.id}), + (0, 0, {'debit': 66.72, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (cash_basis_move + payment_move).post() + + # Initial amounts by accounts: + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -11.11, -33.34), + (self.tax_account_1, 0.0, 0.0), + (self.tax_account_2, 0.0, 0.0), + ]) + + # There is 44.45 + 44.45 + 44.45 + 0.01 = 133.36 to reconcile on 'cash_basis_move'. + # Reconciling all the amount in extra_receivable_account_1 should compute 2 percentages: + # 33.34 / 133.36 = 0.25 + # 11.11 / 133.36 = 0.083308338 + + receivable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 2) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 12.5, 'credit': 0.0, 'amount_currency': 25.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 12.5, 'amount_currency': -25.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 4.17, 'credit': 0.0, 'amount_currency': 8.333, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 4.17, 'amount_currency': -8.333, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.003, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.003, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 4.2, 'credit': 0.0, 'amount_currency': 8.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 4.2, 'amount_currency': -8.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 1.4, 'credit': 0.0, 'amount_currency': 2.777, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 1.4, 'amount_currency': -2.777, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.001, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.001, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -5.54, -22.226), + (self.tax_account_1, -5.57, -11.11), + (self.tax_account_2, 0.0, -0.004), + ]) + + # Reconciling all the amount in extra_receivable_account_2 should compute 3 percentages: + # 44.45 / 133.36 = 0.333308338 + # 44.44 / 133.36 = 0.333233353 + # 0.01 / 133.36 = 0.000074985 + + receivable_lines_2 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_2) + res = receivable_lines_2.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines_2) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 3) + self.assertRecordValues(res['tax_cash_basis_moves'][0].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 16.67, 'credit': 0.0, 'amount_currency': 33.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 16.67, 'amount_currency': -33.331, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 5.56, 'credit': 0.0, 'amount_currency': 11.109, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 5.56, 'amount_currency': -11.109, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.003, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.003, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][1].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 16.66, 'credit': 0.0, 'amount_currency': 33.323, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 16.66, 'amount_currency': -33.323, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 5.55, 'credit': 0.0, 'amount_currency': 11.107, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 5.55, 'amount_currency': -11.107, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.003, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.003, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + self.assertRecordValues(res['tax_cash_basis_moves'][2].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'amount_currency': 0.007, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'amount_currency': -0.007, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.002, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.002, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 5.57, -0.002), + (self.tax_account_1, -16.68, -33.328), + (self.tax_account_2, 0.0, -0.01), + ]) + + # Reconciling all the amount in extra_payable_account_1 should trigger the matching number and ensure all + # the base amount has been covered without any rounding issue. + + payable_lines_1 = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_payable_account_1) + res = payable_lines_1.reconcile() + + self.assertFullReconcile(res['full_reconcile'], payable_lines_1) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount of tax_1 & tax_2: + {'debit': 0.01, 'credit': 0.0, 'amount_currency': 0.008, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 0.01, 'amount_currency': -0.008, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.002, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': -0.002, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.0, 'amount_currency': 0.0, 'currency_id': currency_id, 'account_id': self.tax_account_2.id}, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -16.68, -33.33), + (self.tax_account_2, 0.0, -0.01), + ]) + + def test_reconcile_cash_basis_exchange_difference_transfer_account_not_reconcile(self): + ''' Test the generation of the exchange difference for a tax cash basis journal entry when the transfer + account is not a reconcile one. + ''' + self.cash_basis_transfer_account.reconcile = False + + currency_id = self.currency_data['currency'].id + + # Rate 1/3 in 2016. + cash_basis_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'amount_currency': -300.0, + 'currency_id': currency_id, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'amount_currency': -100.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 133.33, + 'credit': 0.0, + 'amount_currency': 400.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + # Rate 1/2 in 2017. + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 0.0, + 'credit': 201.0, + 'amount_currency': -402.0, # Don't create the full reconcile directly. + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 201.0, + 'credit': 0.0, + 'account_id': self.company_data['default_account_revenue'].id, + }), + ] + }) + + # Move making the payment fully paid. + end_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 1.0, + 'credit': 0.0, + 'amount_currency': 2.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 1.0, + 'account_id': self.company_data['default_account_revenue'].id, + }), + ] + }) + + (cash_basis_move + payment_move + end_move).post() + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -33.33, -100.0), + (self.tax_account_1, 0.0, 0.0), + ]) + + receivable_lines = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount: + {'debit': 150.0, 'credit': 0.0, 'amount_currency': 300.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 150.0, 'amount_currency': -300.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 50.0, 'credit': 0.0, 'amount_currency': 100.0, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 50.0, 'amount_currency': -100.0, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + ]) + + receivable_lines2 = (payment_move + end_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines2.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines + receivable_lines2) + + exchange_diff = res['full_reconcile'].exchange_move_id + exchange_diff_lines = exchange_diff.line_ids\ + .filtered(lambda line: line.account_id == self.cash_basis_transfer_account)\ + .sorted(lambda line: (line.account_id, line.debit, line.credit)) + + self.assertRecordValues(exchange_diff_lines, [ + { + 'debit': 0.0, + 'credit': 16.67, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + }, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -50.0, -100.0), + ]) + + def test_reconcile_cash_basis_exchange_difference_transfer_account_reconcile(self): + ''' Test the generation of the exchange difference for a tax cash basis journal entry when the transfer + account is a reconcile one. + ''' + self.cash_basis_transfer_account.reconcile = True + + currency_id = self.currency_data['currency'].id + + # Rate 1/3 in 2016. + cash_basis_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'amount_currency': -300.0, + 'currency_id': currency_id, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'amount_currency': -100.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 133.33, + 'credit': 0.0, + 'amount_currency': 400.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + # Rate 1/2 in 2017. + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, { + 'debit': 0.0, + 'credit': 200.0, + 'amount_currency': -400.0, + 'currency_id': currency_id, + 'account_id': self.extra_receivable_account_1.id, + }), + (0, 0, { + 'debit': 200.0, + 'credit': 0.0, + 'account_id': self.company_data['default_account_revenue'].id, + }), + ] + }) + + (cash_basis_move + payment_move).post() + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, -33.33, -100.0), + (self.tax_account_1, 0.0, 0.0), + ]) + + receivable_lines = (cash_basis_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount: + {'debit': 150.0, 'credit': 0.0, 'amount_currency': 300.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 150.0, 'amount_currency': -300.0, 'currency_id': currency_id, 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 50.0, 'credit': 0.0, 'amount_currency': 100.0, 'currency_id': currency_id, 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 50.0, 'amount_currency': -100.0, 'currency_id': currency_id, 'account_id': self.tax_account_1.id}, + ]) + + transfer_lines = (cash_basis_move + res['tax_cash_basis_moves']).line_ids\ + .filtered(lambda line: line.account_id == self.cash_basis_transfer_account) + self.assertTrue(transfer_lines.full_reconcile_id) + self.assertFullReconcile(transfer_lines.full_reconcile_id, transfer_lines) + + transfer_exchange_diff = transfer_lines.full_reconcile_id.exchange_move_id + transfer_exchange_diff_lines = transfer_exchange_diff.line_ids.sorted(lambda line: (line.account_id, -line.balance)) + + self.assertRecordValues(transfer_exchange_diff_lines, [ + { + 'debit': 0.0, + 'credit': 16.67, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': self.cash_basis_transfer_account.id, + }, + { + 'debit': 16.67, + 'credit': 0.0, + 'amount_currency': 0.0, + 'currency_id': currency_id, + 'account_id': transfer_exchange_diff.journal_id.default_debit_account_id.id, + }, + ]) + + self.assertAmountsGroupByAccount([ + # Account Balance Amount Currency + (self.cash_basis_transfer_account, 0.0, 0.0), + (self.tax_account_1, -50.0, -100.0), + ]) + + def test_reconcile_cash_basis_revert(self): + ''' Ensure the cash basis journal entry can be reverted. ''' + self.cash_basis_transfer_account.reconcile = True + + invoice_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_exigible': False, + }), + + # Receivable line + (0, 0, { + 'debit': 133.33, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 133.33, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 133.33, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (invoice_move + payment_move).post() + + receivable_lines = (invoice_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + # == Check reconciliation of invoice with payment == + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + + # == Check the reconciliation of invoice with tax cash basis journal entry. + # /!\ We make the assumption the tax cash basis journal entry is well created. + + tax_cash_basis_move = res['tax_cash_basis_moves'] + + taxes_lines = (invoice_move.line_ids + tax_cash_basis_move.line_ids.filtered('debit'))\ + .filtered(lambda line: line.account_id == self.cash_basis_transfer_account) + taxes_full_reconcile = taxes_lines.matched_debit_ids.full_reconcile_id + + self.assertTrue(taxes_full_reconcile) + self.assertFullReconcile(taxes_full_reconcile, taxes_lines) + + # == Check the reconciliation after the reverse == + + tax_cash_basis_move_reverse = tax_cash_basis_move._reverse_moves(cancel=True) + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + + # == Check the reconciliation of the tax cash basis journal entry with its reverse == + + reversed_taxes_lines = (tax_cash_basis_move + tax_cash_basis_move_reverse).line_ids\ + .filtered(lambda line: line.account_id == self.cash_basis_transfer_account) + + reversed_taxes_full_reconcile = reversed_taxes_lines.matched_debit_ids.full_reconcile_id + + self.assertTrue(reversed_taxes_full_reconcile) + self.assertFullReconcile(reversed_taxes_full_reconcile, reversed_taxes_lines) + + def test_reconcile_cash_basis_tax_grid_refund(self): + invoice_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_tag_ids': [(6, 0, self.tax_tags[0].ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[1].ids)], + 'tax_exigible': False, + }), + + # Receivable line + (0, 0, { + 'debit': 133.33, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + refund_move = self.env['account.move'].create({ + 'move_type': 'out_refund', + 'partner_id': self.partner_a.id, + 'invoice_date': '2016-01-01', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 100.0, + 'credit': 0.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, self.cash_basis_tax_a_third_amount.ids)], + 'tax_tag_ids': [(6, 0, self.tax_tags[2].ids)], + 'tax_exigible': False, + }), + + # Tax line + (0, 0, { + 'debit': 33.33, + 'credit': 0.0, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[3].ids)], + 'tax_exigible': False, + }), + + # Receivable line + (0, 0, { + 'debit': 0.0, + 'credit': 133.33, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + (invoice_move + refund_move).post() + + receivable_lines = (invoice_move + refund_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 2) + + tax_cash_basis_moves = res['tax_cash_basis_moves'].sorted(lambda move: move.tax_cash_basis_move_id.id) + + # Invoice: + cb_lines = tax_cash_basis_moves[0].line_ids.sorted(lambda line: (-abs(line.balance), -line.debit, line.account_id)) + self.assertRecordValues(cb_lines, [ + # Base amount: + {'debit': 100.0, 'credit': 0.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 100.0, 'tax_tag_ids': self.tax_tags[0].ids, 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 33.33, 'credit': 0.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 33.33, 'tax_tag_ids': self.tax_tags[1].ids, 'account_id': self.tax_account_1.id}, + ]) + + # Refund: + cb_lines = tax_cash_basis_moves[1].line_ids.sorted(lambda line: (-abs(line.balance), -line.debit, line.account_id)) + self.assertRecordValues(cb_lines, [ + # Base amount: + {'debit': 100.0, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[2].ids, 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 100.0, 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + # tax: + {'debit': 33.33, 'credit': 0.0, 'tax_tag_ids': self.tax_tags[3].ids, 'account_id': self.tax_account_1.id}, + {'debit': 0.0, 'credit': 33.33, 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + ]) + + self.assertTaxGridAmounts([ + # Tag Balance + (self.tax_tags[0], -100.0), + (self.tax_tags[1], -33.33), + (self.tax_tags[2], 100.0), + (self.tax_tags[3], 33.33), + ]) + + def test_reconcile_cash_basis_tax_grid_multi_taxes(self): + ''' Test the tax grid when reconciling an invoice with multiple taxes/tax repartition. ''' + base_taxes = self.cash_basis_tax_a_third_amount + self.cash_basis_tax_tiny_amount + base_tags = self.tax_tags[0] + self.tax_tags[4] + + # An invoice with 2 taxes: + invoice_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2016-01-01', + 'line_ids': [ + # Base Tax line + (0, 0, { + 'debit': 0.0, + 'credit': 100.0, + 'account_id': self.company_data['default_account_revenue'].id, + 'tax_ids': [(6, 0, base_taxes.ids)], + 'tax_tag_ids': [(6, 0, base_tags.ids)], + 'tax_exigible': False, + }), + + # Tax lines + (0, 0, { + 'debit': 0.0, + 'credit': 33.33, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_a_third_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[1].ids)], + 'tax_exigible': False, + }), + (0, 0, { + 'debit': 0.0, + 'credit': 0.01, + 'account_id': self.cash_basis_transfer_account.id, + 'tax_repartition_line_id': self.cash_basis_tax_tiny_amount.invoice_repartition_line_ids.filtered(lambda line: line.repartition_type == 'tax').id, + 'tax_tag_ids': [(6, 0, self.tax_tags[5].ids)], + 'tax_exigible': False, + }), + + # Receivable lines + (0, 0, { + 'debit': 133.34, + 'credit': 0.0, + 'account_id': self.extra_receivable_account_1.id, + }), + ] + }) + + # A payment paying the full invoice amount. + payment_move = self.env['account.move'].create({ + 'move_type': 'entry', + 'date': '2017-01-01', + 'line_ids': [ + (0, 0, {'debit': 0.0, 'credit': 133.34, 'account_id': self.extra_receivable_account_1.id}), + (0, 0, {'debit': 133.34, 'credit': 0.0, 'account_id': self.company_data['default_account_revenue'].id}), + ] + }) + + (invoice_move + payment_move).post() + + receivable_lines = (invoice_move + payment_move).line_ids\ + .filtered(lambda line: line.account_id == self.extra_receivable_account_1) + res = receivable_lines.reconcile() + + self.assertFullReconcile(res['full_reconcile'], receivable_lines) + self.assertEqual(len(res.get('tax_cash_basis_moves', [])), 1) + + self.assertRecordValues(res['tax_cash_basis_moves'].line_ids, [ + # Base amount x 2 because there is two taxes: + {'debit': 100.0, 'credit': 0.0, 'tax_ids': [], 'tax_tag_ids': [], 'account_id': self.cash_basis_base_account.id}, + {'debit': 0.0, 'credit': 100.0, 'tax_ids': base_taxes.ids, 'tax_tag_ids': base_tags.ids, 'account_id': self.cash_basis_base_account.id}, + # tax_1: + {'debit': 33.33, 'credit': 0.0, 'tax_ids': [], 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 33.33, 'tax_ids': [], 'tax_tag_ids': self.tax_tags[1].ids, 'account_id': self.tax_account_1.id}, + # tax_2: + {'debit': 0.01, 'credit': 0.0, 'tax_ids': [], 'tax_tag_ids': [], 'account_id': self.cash_basis_transfer_account.id}, + {'debit': 0.0, 'credit': 0.01, 'tax_ids': [], 'tax_tag_ids': self.tax_tags[5].ids, 'account_id': self.tax_account_2.id}, + ]) + + self.assertTaxGridAmounts([ + # Tag Balance + (self.tax_tags[0], -100.0), + (self.tax_tags[1], -33.33), + (self.tax_tags[4], -100.0), + (self.tax_tags[5], -0.01), + ]) diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 6658704f23a..ec11249216d 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -195,23 +195,6 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertEqual(aml.amount_residual, 0, 'The journal item should be totally reconciled') self.assertEqual(aml.amount_residual_currency, 0, 'The journal item should be totally reconciled') - move_list_vals = [ - ('2', 728.35, 795.05, self.currency_swiss_id), - ('3', -4.46, 0, False), - ('4', 0.32, 0, False), - ('5', 14.72, 16.20, self.currency_swiss_id), - ('6', -737.10, -811.25, self.currency_swiss_id), - ] - move_ids = [] - for name, amount, amount_currency, currency_id in move_list_vals: - move_ids.append(create_move(name, amount, amount_currency, currency_id)) - aml_recs = self.env['account.move.line'].search([('move_id', 'in', move_ids), ('account_id', '=', self.account_rcv.id), ('reconciled', '=', False)]) - aml_recs.reconcile(self.account_rsa, self.bank_journal_usd) - for aml in aml_recs: - self.assertTrue(aml.reconciled, 'The journal item should be totally reconciled') - self.assertEqual(aml.amount_residual, 0, 'The journal item should be totally reconciled') - self.assertEqual(aml.amount_residual_currency, 0, 'The journal item should be totally reconciled') - def test_manual_reconcile_wizard_same_account(self): move_ids = self.env['account.move'] debit_line_vals = { @@ -478,7 +461,7 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertAlmostEqual(inv.amount_residual, 0) # Unreconcile invoice and check residual - credit_aml.with_context(invoice_id=inv.id).remove_move_reconcile() + credit_aml.remove_move_reconcile() self.assertAlmostEqual(inv.amount_residual, 111) def test_revert_payment_and_reconcile(self): @@ -705,636 +688,6 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertTrue(receivable_line.matched_debit_ids) - def test_reconciliation_cash_basis01(self): - # Simulates an expense made up by 2 lines - # one is subject to a cash basis tax - # the other is not subject to tax - - company = self.company - company.tax_cash_basis_journal_id = self.cash_basis_journal - - purchase_move = self.env['account.move'].create({ - 'journal_id': self.purchase_journal.id, - 'line_ids': [ - (0, 0, {'account_id': self.account_rsa.id, 'debit': 0.0, 'credit': 100.0}), - (0, 0, {'account_id': self.account_rsa.id, 'debit': 0.0, 'credit': 50.0}), - (0, 0, {'account_id': self.expense_account.id, 'debit': 50.0, 'credit': 0.0}), - (0, 0, {'account_id': self.expense_account.id, 'debit': 83.33, 'credit': 0.0, 'tax_ids': [(4, self.tax_cash_basis.id)], 'tax_exigible': False}), - (0, 0, { - 'account_id': self.tax_waiting_account.id, - 'debit': 16.67, - 'credit': 0.0, - 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, - 'tax_base_amount': 83.33, - 'tax_exigible': False - }), - ], - }) - purchase_move.post() - - payment_move = self.env['account.move'].create({ - 'journal_id': self.bank_journal_euro.id, - 'line_ids': [ - (0, 0, {'account_id': self.account_rsa.id, 'debit': 150.0, 'credit': 0.0}), - (0, 0, {'account_id': self.account_euro.id, 'debit': 0.0, 'credit': 150.0}), - ], - }) - payment_move.post() - - (purchase_move + payment_move).invalidate_cache(['line_ids']) - to_reconcile = (purchase_move + payment_move).mapped('line_ids').filtered(lambda l: l.account_id.internal_type == 'payable') - to_reconcile.reconcile() - - cash_basis_moves = self.env['account.move'].search([('journal_id', '=', self.cash_basis_journal.id)]) - - self.assertEqual(len(cash_basis_moves), 2) - self.assertTrue(cash_basis_moves.exists()) - - # check reconciliation in Payable account - purchase_line_ids = purchase_move.line_ids.sorted() - payment_line_ids = payment_move.line_ids.sorted() - self.assertTrue(purchase_line_ids[0].full_reconcile_id.exists()) - self.assertEqual(purchase_line_ids[0].full_reconcile_id.reconciled_line_ids, - purchase_line_ids[0] + purchase_line_ids[1] + payment_line_ids[0]) - - cash_basis_aml_ids = cash_basis_moves.mapped('line_ids') - # check reconciliation in the tax waiting account - self.assertTrue(purchase_line_ids[4].full_reconcile_id.exists()) - self.assertEqual(purchase_line_ids[4].full_reconcile_id.reconciled_line_ids, - cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + purchase_line_ids[4]) - - self.assertEqual(len(cash_basis_aml_ids), 8) - - # check amounts - cash_basis_move1 = cash_basis_moves.filtered(lambda m: m.amount_total == 33.34) - cash_basis_move2 = cash_basis_moves.filtered(lambda m: m.amount_total == 66.66) - - self.assertTrue(cash_basis_move1.exists()) - self.assertTrue(cash_basis_move2.exists()) - - # For first move - move_lines = cash_basis_move1.line_ids - base_amount_tax_lines = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account) - self.assertEqual(len(base_amount_tax_lines), 2) - self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('credit')), 27.78) - self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('debit')), 27.78) - - self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_waiting_account).credit, - 5.56) - self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_final_account).debit, - 5.56) - - # For second move - move_lines = cash_basis_move2.line_ids - base_amount_tax_lines = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account) - self.assertEqual(len(base_amount_tax_lines), 2) - self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('credit')), 55.55) - self.assertAlmostEqual(sum(base_amount_tax_lines.mapped('debit')), 55.55) - - self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_waiting_account).credit, - 11.11) - self.assertAlmostEqual((move_lines - base_amount_tax_lines).filtered(lambda l: l.account_id == self.tax_final_account).debit, - 11.11) - - def test_reconciliation_cash_basis02(self): - # Simulates an invoice made up by 2 lines - # both subjected to cash basis taxes - # with 2 payment terms - # And partial payment not martching any payment term - - company = self.company - company.tax_cash_basis_journal_id = self.cash_basis_journal - tax_cash_basis10percent = self.tax_cash_basis.copy({'amount': 10}) - tax_waiting_account10 = self.tax_waiting_account.copy({ - 'name': 'TAX WAIT 10', - 'code': 'TWAIT1', - }) - - purchase_move = self.env['account.move'].create({ - 'journal_id': self.purchase_journal.id, - 'line_ids': [ - (0, 0, {'account_id': self.account_rsa.id, 'debit': 0.0, 'credit': 50.0}), - (0, 0, {'account_id': self.account_rsa.id, 'debit': 0.0, 'credit': 105.0}), - (0, 0, {'name': 'expenseTaxed 10%', 'account_id': self.expense_account.id, 'debit': 50.0, 'credit': 0.0, 'tax_ids': [(4, tax_cash_basis10percent.id)], 'tax_exigible': False}), - (0, 0, { - 'name': 'TaxLine0', - 'account_id': tax_waiting_account10.id, - 'debit': 5.0, - 'credit': 0.0, - 'tax_repartition_line_id': tax_cash_basis10percent.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, - 'tax_exigible': False, - 'tax_base_amount': 50.00, - }), - (0, 0, {'name': 'expenseTaxed 20%', 'account_id': self.expense_account.id, 'debit': 83.33, 'credit': 0.0, 'tax_ids': [(4, self.tax_cash_basis.id)], 'tax_exigible': False}), - (0, 0, { - 'name': 'TaxLine1', - 'account_id': self.tax_waiting_account.id, - 'debit': 16.67, - 'credit': 0.0, - 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, - 'tax_exigible': False, - 'tax_base_amount': 83.33, - }), - ], - }) - purchase_move.post() - - payment_move0 = self.env['account.move'].create({ - 'journal_id': self.bank_journal_euro.id, - 'line_ids': [ - (0, 0, {'account_id': self.account_rsa.id, 'debit': 40.0, 'credit': 0.0}), - (0, 0, {'account_id': self.account_euro.id, 'debit': 0.0, 'credit': 40.0}), - ], - }) - payment_move0.post() - - payment_move1 = self.env['account.move'].create({ - 'journal_id': self.bank_journal_euro.id, - 'line_ids': [ - (0, 0, {'account_id': self.account_rsa.id, 'debit': 115.0, 'credit': 0.0}), - (0, 0, {'account_id': self.account_euro.id, 'debit': 0.0, 'credit': 115.0}), - ], - }) - payment_move1.post() - - (purchase_move + payment_move0 + payment_move1).post() - (purchase_move + payment_move0).mapped('line_ids').sorted().filtered(lambda l: l.account_id.internal_type == 'payable').reconcile() - (purchase_move + payment_move1).mapped('line_ids').sorted().filtered(lambda l: l.account_id.internal_type == 'payable').reconcile() - - cash_basis_moves = self.env['account.move'].search([('journal_id', '=', self.cash_basis_journal.id)]) - - self.assertEqual(len(cash_basis_moves), 3) - self.assertTrue(cash_basis_moves.exists()) - - # check reconciliation in Payable account - purchase_line_ids = purchase_move.line_ids.sorted() - self.assertTrue(purchase_line_ids[0].full_reconcile_id.exists()) - self.assertEqual(purchase_line_ids[0].full_reconcile_id.reconciled_line_ids, - (purchase_move + payment_move0 + payment_move1).mapped('line_ids').filtered(lambda l: l.account_id.internal_type == 'payable')) - - cash_basis_aml_ids = cash_basis_moves.mapped('line_ids') - - # check reconciliation in the tax waiting account - self.assertTrue(purchase_line_ids[3].full_reconcile_id.exists()) - self.assertEqual(purchase_line_ids[3].full_reconcile_id.reconciled_line_ids, - cash_basis_aml_ids.filtered(lambda l: l.account_id == tax_waiting_account10) + purchase_line_ids[3]) - - self.assertTrue(purchase_line_ids[5].full_reconcile_id.exists()) - self.assertEqual(purchase_line_ids[5].full_reconcile_id.reconciled_line_ids, - cash_basis_aml_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + purchase_line_ids[5]) - - self.assertEqual(len(cash_basis_aml_ids), 24) - - # check amounts - expected_move_amounts = [ - {'base_20': 56.45, 'tax_20': 11.29, 'base_10': 33.87, 'tax_10': 3.39}, - {'base_20': 5.38, 'tax_20': 1.08, 'base_10': 3.23, 'tax_10': 0.32}, - {'base_20': 21.50, 'tax_20': 4.30, 'base_10': 12.90, 'tax_10': 1.29}, - ] - - index = 0 - for cb_move in cash_basis_moves: - expected = expected_move_amounts[index] - move_lines = cb_move.line_ids - base_amount_tax_lines20per = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account and '20%' in l.name) - base_amount_tax_lines10per = move_lines.filtered(lambda l: l.account_id == self.tax_base_amount_account and '10%' in l.name) - self.assertEqual(len(base_amount_tax_lines20per), 2) - - self.assertAlmostEqual(sum(base_amount_tax_lines20per.mapped('credit')), expected['base_20']) - self.assertAlmostEqual(sum(base_amount_tax_lines20per.mapped('debit')), expected['base_20']) - - self.assertEqual(len(base_amount_tax_lines10per), 2) - self.assertAlmostEqual(sum(base_amount_tax_lines10per.mapped('credit')), expected['base_10']) - self.assertAlmostEqual(sum(base_amount_tax_lines10per.mapped('debit')), expected['base_10']) - - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per) - .filtered(lambda l: l.account_id == self.tax_waiting_account).credit, - expected['tax_20'] - ) - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per) - .filtered(lambda l: 'TaxLine1' in l.name).debit, - expected['tax_20'] - ) - - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per) - .filtered(lambda l: l.account_id == tax_waiting_account10).credit, - expected['tax_10'] - ) - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines20per - base_amount_tax_lines10per) - .filtered(lambda l: 'TaxLine0' in l.name).debit, - expected['tax_10'] - ) - index += 1 - - def test_reconciliation_cash_basis_fx_01(self): - """ - Company's Currency EUR - - Having issued an invoice at date Nov-21-2018 as: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Expenses 5,301.00 USD 106,841.65 0.00 - Taxes 848.16 USD 17,094.66 0.00 - Payables -6,149.16 USD 0.00 123,936.31 - - On Dec-20-2018 user issues an FX Journal Entry as: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Payables 0.00 USD 167.86 0.00 - FX Gains 0.00 USD 0.00 167.86 - - On Same day user records a payment for: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Payables 6,149.16 USD 123,768.45 0.00 - Bank -6,149.16 USD 0.00 123,768.45 - - And then reconciles the Payables Items which shall render only one Tax - Cash Basis Journal Entry because of the actual payment, i.e. - amount_currency != 0: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Tax Base Acc. 0.00 USD 106,841.65 0.00 - Tax Base Acc. 0.00 USD 0.00 106,841.65 - Creditable Taxes 848.16 USD 17,094.66 0.00 - Taxes -848.16 USD 0.00 17,094.66 - """ - - company = self.company - company.country_id = self.ref('base.us') - company.tax_cash_basis_journal_id = self.cash_basis_journal - - # Purchase - purchase_move = self.env['account.move'].create({ - 'journal_id': self.purchase_journal.id, - 'line_ids': [ - (0, 0, { - 'name': 'expenseTaxed', - 'account_id': self.expense_account.id, - 'currency_id': self.currency_usd_id, - 'tax_ids': [(4, self.tax_cash_basis.id)], - 'tax_exigible': False, - 'debit': 106841.65, 'credit': 0.0, 'amount_currency': 5301.00, - }), - (0, 0, { - 'name': 'TaxLine', - 'account_id': self.tax_waiting_account.id, - 'currency_id': self.currency_usd_id, - 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, - 'tax_exigible': False, - 'tax_base_amount': 106841.65, - 'debit': 17094.66, 'credit': 0.0, 'amount_currency': 848.16, - }), - (0, 0, { - 'name': 'Payable', - 'account_id': self.account_rsa.id, - 'currency_id': self.currency_usd_id, - 'debit': 0.0, 'credit': 123936.31, 'amount_currency': -6149.16, - }), - ], - }) - purchase_move.post() - - # FX 01 Move - fx_move_01 = self.env['account.move'].create({ - 'journal_id': self.fx_journal.id, - 'line_ids': [ - (0, 0, { - 'account_id': self.account_rsa.id, - 'currency_id': self.currency_usd_id, - 'debit': 167.86, 'credit': 0.0, 'amount_currency': 0.00, - }), - (0, 0, { - 'account_id': self.diff_income_account.id, - 'currency_id': self.currency_usd_id, - 'debit': 0.0, 'credit': 167.86, 'amount_currency': 0.0, - }), - ], - }) - fx_move_01.post() - - # Payment Move - payment_move = self.env['account.move'].create({ - 'journal_id': self.bank_journal_usd.id, - 'line_ids': [ - (0, 0, { - 'account_id': self.account_rsa.id, - 'currency_id': self.currency_usd_id, - 'debit': 123768.45, 'credit': 0.0, 'amount_currency': 6149.16, - }), - (0, 0, { - 'account_id': self.account_usd.id, - 'currency_id': self.currency_usd_id, - 'debit': 0.0, 'credit': 123768.45, 'amount_currency': -6149.16, - }), - ], - }) - payment_move.post() - - to_reconcile = ( - (purchase_move + payment_move + fx_move_01).mapped('line_ids') - .filtered(lambda l: l.account_id.internal_type == 'payable')) - to_reconcile.reconcile() - - # check reconciliation in Payable account - purchase_line_ids = purchase_move.line_ids.sorted() - fx_move_01_line_ids = fx_move_01.line_ids.sorted() - payment_line_ids = payment_move.line_ids.sorted() - self.assertTrue(purchase_line_ids[2].full_reconcile_id.exists()) - self.assertEqual( - purchase_line_ids[2].full_reconcile_id.reconciled_line_ids, - purchase_line_ids[2] + fx_move_01_line_ids[0] + payment_line_ids[0]) - - # check cash basis - cash_basis_moves = self.env['account.move'].search( - [('journal_id', '=', self.cash_basis_journal.id)]) - - self.assertEqual(len(cash_basis_moves), 1) - - cash_basis_aml_ids = cash_basis_moves.mapped('line_ids') - self.assertEqual(len(cash_basis_aml_ids), 4) - - # check amounts - cash_basis_move1 = cash_basis_moves.filtered(lambda m: m.currency_id.is_zero(sum(line.credit for line in m.line_ids) - 123936.31)) - - self.assertTrue(cash_basis_move1.exists()) - - # For first move - move_lines = cash_basis_moves.line_ids - base_amount_tax_lines = move_lines.filtered( - lambda l: l.account_id == self.tax_base_amount_account) - self.assertEqual(len(base_amount_tax_lines), 2) - self.assertAlmostEqual( - sum(base_amount_tax_lines.mapped('credit')), 106841.65) - self.assertAlmostEqual( - sum(base_amount_tax_lines.mapped('debit')), 106841.65) - - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines) - .filtered(lambda l: l.account_id == self.tax_waiting_account) - .credit, 17094.66) - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines) - .filtered(lambda l: l.account_id == self.tax_final_account) - .debit, 17094.66) - - def test_reconciliation_cash_basis_fx_02(self): - """ - Company's Currency EUR - - Having issued an invoice at date Nov-21-2018 as: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Expenses 5,301.00 USD 106,841.65 0.00 - Taxes 848.16 USD 17,094.66 0.00 - Payables -6,149.16 USD 0.00 123,936.31 - - On Nov-30-2018 user issues an FX Journal Entry as: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - FX Losses 0.00 USD 1.572.96 0.00 - Payables 0.00 USD 0.00 1.572.96 - - On Dec-20-2018 user issues an FX Journal Entry as: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Payables 0.00 USD 1.740.82 0.00 - FX Gains 0.00 USD 0.00 1.740.82 - - On Same day user records a payment for: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Payables 6,149.16 USD 123,768.45 0.00 - Bank -6,149.16 USD 0.00 123,768.45 - - And then reconciles the Payables Items which shall render only one Tax - Cash Basis Journal Entry because of the actual payment, i.e. - amount_currency != 0: - - Accounts Amount Currency Debit(EUR) Credit(EUR) - --------------------------------------------------------------------- - Tax Base Acc. 0.00 USD 106,841.65 0.00 - Tax Base Acc. 0.00 USD 0.00 106,841.65 - Creditable Taxes 848.16 USD 17,094.66 0.00 - Taxes -848.16 USD 0.00 17,094.66 - """ - - company = self.company - company.country_id = self.ref('base.us') - company.tax_cash_basis_journal_id = self.cash_basis_journal - - # Purchase - purchase_move = self.env['account.move'].create({ - 'journal_id': self.purchase_journal.id, - 'line_ids': [ - (0, 0, { - 'name': 'expenseTaxed', - 'account_id': self.expense_account.id, - 'currency_id': self.currency_usd_id, - 'tax_ids': [(4, self.tax_cash_basis.id)], - 'tax_exigible': False, - 'debit': 106841.65, 'credit': 0.0, 'amount_currency': 5301.00, - }), - (0, 0, { - 'name': 'TaxLine', - 'account_id': self.tax_waiting_account.id, - 'currency_id': self.currency_usd_id, - 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, - 'tax_exigible': False, - 'tax_base_amount': 106841.65, - 'debit': 17094.66, 'credit': 0.0, 'amount_currency': 848.16, - }), - (0, 0, { - 'name': 'Payable', - 'account_id': self.account_rsa.id, - 'currency_id': self.currency_usd_id, - 'debit': 0.0, 'credit': 123936.31, 'amount_currency': -6149.16, - }), - ], - }) - purchase_move.post() - - # FX 01 Move - fx_move_01 = self.env['account.move'].create({ - 'journal_id': self.fx_journal.id, - 'line_ids': [ - (0, 0, { - 'account_id': self.account_rsa.id, - 'currency_id': self.currency_usd_id, - 'debit': 0.0, 'credit': 1572.96, 'amount_currency': 0.00, - }), - (0, 0, { - 'account_id': self.diff_expense_account.id, - 'currency_id': self.currency_usd_id, - 'debit': 1572.96, 'credit': 0.0, 'amount_currency': 0.0, - }), - ], - }) - fx_move_01.post() - - # FX 02 Move - fx_move_02 = self.env['account.move'].create({ - 'journal_id': self.fx_journal.id, - 'line_ids': [ - (0, 0, { - 'account_id': self.account_rsa.id, - 'currency_id': self.currency_usd_id, - 'debit': 1740.82, 'credit': 0.0, 'amount_currency': 0.00, - }), - (0, 0, { - 'account_id': self.diff_income_account.id, - 'currency_id': self.currency_usd_id, - 'debit': 0.0, 'credit': 1740.82, 'amount_currency': 0.0, - }), - ], - }) - fx_move_02.post() - - # Payment Move - payment_move = self.env['account.move'].create({ - 'journal_id': self.bank_journal_usd.id, - 'line_ids': [ - (0, 0, { - 'account_id': self.account_rsa.id, - 'currency_id': self.currency_usd_id, - 'debit': 123768.45, 'credit': 0.0, 'amount_currency': 6149.16, - }), - (0, 0, { - 'account_id': self.account_usd.id, - 'currency_id': self.currency_usd_id, - 'debit': 0.0, 'credit': 123768.45, 'amount_currency': -6149.16, - }), - ], - }) - payment_move.post() - - to_reconcile = ( - (purchase_move + payment_move + fx_move_01 + fx_move_02) - .mapped('line_ids') - .filtered(lambda l: l.account_id.internal_type == 'payable')) - to_reconcile.reconcile() - - # check reconciliation in Payable account - purchase_line_ids = purchase_move.line_ids.sorted() - fx_move_01_line_ids = fx_move_01.line_ids.sorted() - fx_move_02_line_ids = fx_move_02.line_ids.sorted() - payment_line_ids = payment_move.line_ids.sorted() - self.assertTrue(purchase_line_ids[2].full_reconcile_id.exists()) - self.assertEqual( - purchase_line_ids[2].full_reconcile_id.reconciled_line_ids, - purchase_line_ids[2] + fx_move_01_line_ids[0] + fx_move_02_line_ids[0] + - payment_line_ids[0]) - - # check cash basis - cash_basis_moves = self.env['account.move'].search( - [('journal_id', '=', self.cash_basis_journal.id)]) - - self.assertEqual(len(cash_basis_moves), 1) - - cash_basis_aml_ids = cash_basis_moves.mapped('line_ids') - self.assertEqual(len(cash_basis_aml_ids), 4) - - # check amounts - cash_basis_move1 = cash_basis_moves.filtered(lambda m: m.currency_id.is_zero(sum(line.credit for line in m.line_ids) - 123936.31)) - - self.assertTrue(cash_basis_move1.exists()) - - # For first move - move_lines = cash_basis_move1.line_ids - base_amount_tax_lines = move_lines.filtered( - lambda l: l.account_id == self.tax_base_amount_account) - self.assertEqual(len(base_amount_tax_lines), 2) - self.assertAlmostEqual( - sum(base_amount_tax_lines.mapped('credit')), 106841.65) - self.assertAlmostEqual( - sum(base_amount_tax_lines.mapped('debit')), 106841.65) - - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines) - .filtered(lambda l: l.account_id == self.tax_waiting_account) - .credit, 17094.66) - self.assertAlmostEqual( - (move_lines - base_amount_tax_lines) - .filtered(lambda l: l.account_id == self.tax_final_account) - .debit, 17094.66) - - def test_reconciliation_cash_basis_revert(self): - company = self.company - company.tax_cash_basis_journal_id = self.cash_basis_journal - tax_cash_basis10percent = self.tax_cash_basis.copy({'amount': 10}) - self.tax_waiting_account.reconcile = True - tax_waiting_account10 = self.tax_waiting_account.copy({ - 'name': 'TAX WAIT 10', - 'code': 'TWAIT1', - }) - - AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False) - - # Purchase - purchase_move = self.env['account.move'].create({ - 'name': 'invoice', - 'journal_id': self.purchase_journal.id, - }) - - purchase_payable_line0 = AccountMoveLine.create({ - 'account_id': self.account_rsa.id, - 'credit': 175, - 'move_id': purchase_move.id, - }) - - AccountMoveLine.create({ - 'name': 'expenseTaxed 10%', - 'account_id': self.expense_account.id, - 'debit': 50, - 'move_id': purchase_move.id, - 'tax_ids': [(4, tax_cash_basis10percent.id, False)], - }) - tax_line0 = AccountMoveLine.create({ - 'name': 'TaxLine0', - 'account_id': tax_waiting_account10.id, - 'debit': 5, - 'move_id': purchase_move.id, - 'tax_repartition_line_id': tax_cash_basis10percent.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, - 'tax_base_amount': 50, - }) - AccountMoveLine.create({ - 'name': 'expenseTaxed 20%', - 'account_id': self.expense_account.id, - 'debit': 100, - 'move_id': purchase_move.id, - 'tax_ids': [(4, self.tax_cash_basis.id, False)], - }) - tax_line1 = AccountMoveLine.create({ - 'name': 'TaxLine1', - 'account_id': self.tax_waiting_account.id, - 'debit': 20, - 'move_id': purchase_move.id, - 'tax_repartition_line_id': self.tax_cash_basis.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax').id, - 'tax_base_amount': 100, - }) - purchase_move.post() - - reverted = purchase_move._reverse_moves(cancel=True) - self.assertTrue(reverted.exists()) - - for inv_line in [purchase_payable_line0, tax_line0, tax_line1]: - self.assertTrue(inv_line.full_reconcile_id.exists()) - reverted_expected = reverted.line_ids.filtered(lambda l: l.account_id == inv_line.account_id) - self.assertEqual(len(reverted_expected), 1) - self.assertEqual(reverted_expected.full_reconcile_id, inv_line.full_reconcile_id) - def test_reconciliation_revert_future(self): company = self.company company.tax_cash_basis_journal_id = self.cash_basis_journal @@ -1404,69 +757,6 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertEqual(reverse_move.auto_post, True) self.assertEqual(reverse_move.date, reverse_date) - def test_reconciliation_cash_basis_foreign_currency_low_values(self): - self.company.tax_cash_basis_journal_id = self.cash_basis_journal - journal = self.env['account.journal'].create({ - 'name': 'Bank', 'type': 'bank', 'code': 'THE', - 'currency_id': self.currency_usd_id, - }) - usd = self.env['res.currency'].browse(self.currency_usd_id) - usd.rate_ids.unlink() - self.env['res.currency.rate'].create({ - 'name': time.strftime('%Y-01-01'), - 'rate': 1/17.0, - 'currency_id': self.currency_usd_id, - 'company_id': self.company.id, - }) - - move_form = Form(self.env['account.move'].with_context(default_move_type='out_invoice')) - move_form.partner_id = self.partner_agrolait - move_form.currency_id = self.env.ref('base.USD') - move_form.invoice_date = time.strftime('%Y') + '-07-01' - with move_form.invoice_line_ids.new() as line_form: - line_form.name = 'test line' - line_form.price_unit = 50 - line_form.tax_ids.clear() - invoice = move_form.save() - move_form = Form(invoice) - with move_form.invoice_line_ids.edit(0) as line_form: - line_form.tax_ids.add(self.tax_cash_basis) - invoice = move_form.save() - invoice.post() - - self.assertTrue(invoice.currency_id != self.env.user.company_id.currency_id) - - # First Payment - line = invoice.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) - payment0 = self.make_payment(invoice, journal, amount=59.99, reconcile_param=[{'id': line.id, 'balance': -59.99}]) - self.assertEqual(invoice.amount_residual, 0.01) - - tax_waiting_line = invoice.line_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) - self.assertTrue(tax_waiting_line.exists()) - self.assertFalse(tax_waiting_line.reconciled) - - move_caba0 = tax_waiting_line.matched_debit_ids.debit_move_id.move_id - self.assertTrue(move_caba0.exists()) - self.assertEqual(move_caba0.journal_id, self.env.user.company_id.tax_cash_basis_journal_id) - - pay_receivable_line0 = payment0.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv) - self.assertTrue(pay_receivable_line0.reconciled) - self.assertEqual(pay_receivable_line0.matched_debit_ids, move_caba0.tax_cash_basis_rec_id) - - # Second Payment - payment1 = self.make_payment(invoice, journal, 0.01, reconcile_param=[{'id': line.id, 'balance': -0.01}]) - self.assertEqual(invoice.amount_residual, 0) - self.assertEqual(invoice.payment_state, 'paid') - - self.assertTrue(tax_waiting_line.reconciled) - move_caba1 = tax_waiting_line.matched_debit_ids.mapped('debit_move_id').mapped('move_id').filtered(lambda m: m != move_caba0) - self.assertEqual(len(move_caba1.exists()), 1) - self.assertEqual(move_caba1.journal_id, self.env.user.company_id.tax_cash_basis_journal_id) - - pay_receivable_line1 = payment1.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv) - self.assertTrue(pay_receivable_line1.reconciled) - self.assertEqual(pay_receivable_line1.matched_debit_ids, move_caba1.tax_cash_basis_rec_id) - def test_reconciliation_with_currency(self): #reconciliation on an account having a foreign currency being #the same as the company one @@ -1927,7 +1217,13 @@ class TestReconciliationExec(TestAccountReconciliationCommon): self.assertEqual(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) self.assertEqual(inv1_receivable.full_reconcile_id, payment_receivable.full_reconcile_id) - self.assertFalse(inv1_receivable.full_reconcile_id.exchange_move_id) + # Before saas-13.4, there was no exchange difference entry generated because the amount was + # wrongly converted in the _amount_residual method at the invoice date like this: + # 315.15 * (600.0 / 540.25) = 515.15 * 1.110596946 = 350.004627487 ~= 350.0 + # Now, the conversion is made using the payment rate using the _convert method and the + # encoded currency rate: + # 315.15 * 1.1106 = 350.00559 ~= 350.01 + self.assertTrue(inv1_receivable.full_reconcile_id.exchange_move_id) self.assertTrue(inv1.payment_state in ('in_payment', 'paid'), "Invoice should be paid") self.assertEqual(inv2.payment_state, 'paid') diff --git a/addons/account/views/account_chart_template_views.xml b/addons/account/views/account_chart_template_views.xml index 87ee1d4bcf8..f42f53ecdc7 100644 --- a/addons/account/views/account_chart_template_views.xml +++ b/addons/account/views/account_chart_template_views.xml @@ -176,7 +176,6 @@ <group name="tax_configuration"> <field name="active" groups="base.group_no_one"/> <field name="tax_exigibility" widget="radio" attrs="{'invisible':[('amount_type','=', 'group')]}"/> - <field name="cash_basis_base_account_id" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}"/> <field name="cash_basis_transition_account_id" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}"/> </group> </group> diff --git a/addons/account/views/account_tax_views.xml b/addons/account/views/account_tax_views.xml index 8cf2da53e9e..87c9b015233 100644 --- a/addons/account/views/account_tax_views.xml +++ b/addons/account/views/account_tax_views.xml @@ -155,7 +155,6 @@ <field name="include_base_amount" attrs="{'invisible':[('amount_type','=', 'group')]}" /> <field name="hide_tax_exigibility" invisible="1"/> <field name="tax_exigibility" widget="radio" attrs="{'invisible':['|', ('amount_type','=', 'group'), ('hide_tax_exigibility', '=', False)]}" groups="account.group_account_readonly"/> - <field name="cash_basis_base_account_id" options="{'no_create': True}" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')]}" groups="account.group_account_readonly"/> <field name="cash_basis_transition_account_id" options="{'no_create': True}" attrs="{'invisible': [('tax_exigibility', '=', 'on_invoice')], 'required': [('tax_exigibility', '=', 'on_payment')]}" groups="account.group_account_readonly"/> </group> </group> diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml index c472cd3ff68..bb8e3e3e454 100644 --- a/addons/account/views/report_payment_receipt_templates.xml +++ b/addons/account/views/report_payment_receipt_templates.xml @@ -44,7 +44,7 @@ </thead> <tbody> <tr t-foreach="o.move_id._get_reconciled_invoices_partials()" t-as="rec"> - <t t-set="amount" t-value="rec[0]"/> + <t t-set="amount" t-value="rec[1]"/> <t t-set="inv" t-value="rec[2].move_id"/> <td><span t-field="inv.invoice_date"/></td> <td><span t-field="inv.name"/></td> diff --git a/addons/account/views/res_config_settings_views.xml b/addons/account/views/res_config_settings_views.xml index fefa89d4e4a..93e3b7b5841 100644 --- a/addons/account/views/res_config_settings_views.xml +++ b/addons/account/views/res_config_settings_views.xml @@ -122,6 +122,10 @@ <label for="tax_cash_basis_journal_id" class="col-lg-3 o_light_label"/> <field name="tax_cash_basis_journal_id"/> </div> + <div class="row mt16"> + <label for="account_cash_basis_base_account_id" class="col-lg-3 o_light_label"/> + <field name="account_cash_basis_base_account_id"/> + </div> </div> </div> </div> diff --git a/addons/hr_expense/models/account_move_line.py b/addons/hr_expense/models/account_move_line.py index 6ebc17ed523..2dd37f32e3b 100644 --- a/addons/hr_expense/models/account_move_line.py +++ b/addons/hr_expense/models/account_move_line.py @@ -2,7 +2,23 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from odoo.tools import float_compare + + +class AccountMove(models.Model): + _inherit = 'account.move' + + def action_invoice_paid(self): + # OVERRIDE to mark as paid the expense sheets. + res = super().action_invoice_paid() + + if self: + expense_sheets = self.env['hr.expense.sheet'].search([ + ('account_move_id', 'in', self.ids), + ('state', '!=', 'done'), + ]) + expense_sheets.set_to_paid() + + return res class AccountMoveLine(models.Model): @@ -15,13 +31,3 @@ class AccountMoveLine(models.Model): if self.expense_id: attachment_domains.append([('res_model', '=', 'hr.expense'), ('res_id', '=', self.expense_id.id)]) return attachment_domains - - def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False): - res = super(AccountMoveLine, self).reconcile(writeoff_acc_id=writeoff_acc_id, writeoff_journal_id=writeoff_journal_id) - account_move_ids = [l.move_id.id for l in self if float_compare(l.move_id._get_cash_basis_matched_percentage(), 1, precision_digits=5) == 0] - if account_move_ids: - expense_sheets = self.env['hr.expense.sheet'].search([ - ('account_move_id', 'in', account_move_ids), ('state', '!=', 'done') - ]) - expense_sheets.set_to_paid() - return res diff --git a/addons/l10n_mx/data/account_tax_data.xml b/addons/l10n_mx/data/account_tax_data.xml index 8d91bba6cce..0b3280f6ee3 100644 --- a/addons/l10n_mx/data/account_tax_data.xml +++ b/addons/l10n_mx/data/account_tax_data.xml @@ -70,7 +70,6 @@ <field name="type_tax_use">sale</field> <field name="tax_group_id" ref="tax_group_iva_0"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta209_01"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -110,7 +109,6 @@ <field name="type_tax_use">sale</field> <field name="tax_group_id" ref="tax_group_iva_16"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta209_01"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -150,7 +148,6 @@ <field name="type_tax_use">purchase</field> <field name="tax_group_id" ref="tax_group_iva_ret_4"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta216_10"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -190,7 +187,6 @@ <field name="type_tax_use">purchase</field> <field name="tax_group_id" ref="tax_group_iva_ret_10"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta216_10"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -300,7 +296,6 @@ <field name="type_tax_use">purchase</field> <field name="tax_group_id" ref="tax_group_iva_ret_1067"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta216_10"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -340,7 +335,6 @@ <field name="type_tax_use">purchase</field> <field name="tax_group_id" ref="tax_group_iva_ret_1067"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta216_10"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -380,7 +374,6 @@ <field name="type_tax_use">purchase</field> <field name="tax_group_id" ref="tax_group_iva_0"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta119_01"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -418,7 +411,6 @@ <field name="type_tax_use">purchase</field> <field name="tax_group_id" ref="tax_group_iva_16"/> <field name="tax_exigibility">on_payment</field> - <field name="cash_basis_base_account_id" ref="cuenta801_01_99"/> <field name="cash_basis_transition_account_id" ref="cuenta119_01"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { diff --git a/addons/l10n_mx/data/l10n_mx_chart_post_data.xml b/addons/l10n_mx/data/l10n_mx_chart_post_data.xml index e95b3a1302f..9acddb7dfe2 100644 --- a/addons/l10n_mx/data/l10n_mx_chart_post_data.xml +++ b/addons/l10n_mx/data/l10n_mx_chart_post_data.xml @@ -11,6 +11,7 @@ <field name="property_stock_account_input_categ_id" ref="cuenta205_06_01"/> <field name="property_stock_account_output_categ_id" ref="cuenta107_05_01"/> <field name="property_stock_valuation_account_id" ref="cuenta115_01"/> + <field name="property_cash_basis_base_account_id" ref="cuenta801_01_99" /> <field name="income_currency_exchange_account_id" ref="cuenta702_01"/> <field name="expense_currency_exchange_account_id" ref="cuenta701_01"/> <field name="default_pos_receivable_account_id" ref="cuenta105_02" /> diff --git a/addons/sale_stock/tests/test_anglo_saxon_valuation.py b/addons/sale_stock/tests/test_anglo_saxon_valuation.py index 2ea13237145..3b9625d5492 100644 --- a/addons/sale_stock/tests/test_anglo_saxon_valuation.py +++ b/addons/sale_stock/tests/test_anglo_saxon_valuation.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo.tests import Form +from odoo.tests import Form, tagged from odoo.tests.common import SavepointCase from odoo.exceptions import UserError |
