diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 197ef0a8b37..8817006b4ac 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -1441,20 +1441,27 @@ class AccountTax(models.Model): cached_tax_amounts = {} if handle_price_include: for tax in reversed(taxes): + tax_repartition_lines = ( + is_refund + and tax.refund_repartition_line_ids + or tax.invoice_repartition_line_ids + ).filtered(lambda x: x.repartition_type == "tax") + sum_repartition_factor = sum(tax_repartition_lines.mapped("factor")) + if tax.include_base_amount: base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 store_included_tax_total = True if tax.price_include or self._context.get('force_price_include'): if tax.amount_type == 'percent': - incl_percent_amount += tax.amount + incl_percent_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'division': - incl_division_amount += tax.amount + incl_division_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'fixed': - incl_fixed_amount += quantity * tax.amount + incl_fixed_amount += quantity * tax.amount * sum_repartition_factor else: # tax.amount_type == other (python) - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) * sum_repartition_factor incl_fixed_amount += tax_amount # Avoid unecessary re-computation cached_tax_amounts[i] = tax_amount @@ -1473,6 +1480,9 @@ class AccountTax(models.Model): i = 0 cumulated_tax_included_amount = 0 for tax in taxes: + tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax') + sum_repartition_factor = sum(tax_repartition_lines.mapped('factor')) + #compute the tax_amount if (self._context.get('force_price_include') or tax.price_include) and total_included_checkpoints.get(i): # We know the total to reach for that tax, so we make a substraction to avoid any rounding issues @@ -1480,13 +1490,14 @@ class AccountTax(models.Model): cumulated_tax_included_amount = 0 else: tax_amount = tax.with_context(force_price_include=False)._compute_amount( - base, price_unit, quantity, product, partner) + base, sign * price_unit, quantity, product, partner) - # Round the tax_amount + # Round the tax_amount multiplied by the computed repartition lines factor. tax_amount = round(tax_amount, prec) + factorized_tax_amount = round(tax_amount * sum_repartition_factor, prec) if tax.price_include and not total_included_checkpoints.get(i): - cumulated_tax_included_amount += tax_amount + cumulated_tax_included_amount += factorized_tax_amount # If the tax affects the base of subsequent taxes, its tax move lines must # receive the base tags and tag_ids of these taxes, so that the tax report computes @@ -1497,18 +1508,24 @@ class AccountTax(models.Model): subsequent_taxes = taxes[i+1:] subsequent_tags = subsequent_taxes.get_tax_tags(is_refund, 'base') - # Compute the tax lines - tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax') - sum_factor = sum(tax_repartition_lines.mapped('factor')) - repartition_lines_to_treat = len(tax_repartition_lines) - total_amount = 0 - for repartition_line in tax_repartition_lines: - if repartition_lines_to_treat != 1 or sum_factor != 1.0: - line_amount = round(tax_amount * repartition_line.factor, prec) - else: - # When the sum of the repartition lines factor is 100%, we need to ensure the whole tax amount has - # been spread into the repartition lines. - line_amount = round(tax_amount - total_amount, prec) + # Compute the tax line amounts by multiplying each factor with the tax amount. + # Then, spread the tax rounding to ensure the consistency of each line independently with the factorized + # amount. E.g: + # + # Suppose a tax having 4 x 50% repartition line applied on a tax amount of 0.03 with 2 decimal places. + # The factorized_tax_amount will be 0.06 (200% x 0.03). However, each line taken independently will compute + # 50% * 0.03 = 0.01 with rounding. It means there is 0.06 - 0.04 = 0.02 as total_rounding_error to dispatch + # in lines as 2 x 0.01. + repartition_line_amounts = [round(tax_amount * line.factor, prec) for line in tax_repartition_lines] + total_rounding_error = round(factorized_tax_amount - sum(repartition_line_amounts), prec) + nber_rounding_steps = int(abs(total_rounding_error / currency.rounding)) + rounding_error = round(nber_rounding_steps and total_rounding_error / nber_rounding_steps or 0.0, prec) + + for repartition_line, line_amount in zip(tax_repartition_lines, repartition_line_amounts): + + if nber_rounding_steps: + line_amount += rounding_error + nber_rounding_steps -= 1 taxes_vals.append({ 'id': tax.id, @@ -1525,16 +1542,14 @@ class AccountTax(models.Model): 'tax_ids': subsequent_taxes.ids, }) - total_amount += line_amount if not repartition_line.account_id: total_void += line_amount - repartition_lines_to_treat -= 1 # Affect subsequent taxes if tax.include_base_amount: - base += tax_amount + base += factorized_tax_amount - total_included += tax_amount + total_included += factorized_tax_amount i += 1 return { diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index a3002fa1f8c..61afd62f4eb 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -172,6 +172,8 @@ class AccountMove(models.Model): states={'draft': [('readonly', False)]}, string='Salesperson', default=lambda self: self.env.user) + user_id = fields.Many2one(string='User', related='invoice_user_id', + help='Technical field used to fit the generic behavior in mail templates.') invoice_payment_state = fields.Selection(selection=[ ('not_paid', 'Not Paid'), ('in_payment', 'In Payment'), @@ -426,25 +428,50 @@ class AccountMove(models.Model): :param base_line: The account.move.line owning the taxes. :return: The result of the compute_all method. ''' + move = base_line.move_id + + if move.is_invoice(): + sign = -1 if move.is_inbound() else 1 + quantity = base_line.quantity + if base_line.currency_id: + price_unit_foreign_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0)) + price_unit_comp_curr = base_line.currency_id._convert(price_unit_foreign_curr, move.company_id.currency_id, move.company_id, move.date) + else: + price_unit_foreign_curr = 0.0 + price_unit_comp_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0)) + else: + quantity = 1.0 + price_unit_foreign_curr = base_line.amount_currency + price_unit_comp_curr = base_line.balance + balance_taxes_res = base_line.tax_ids._origin.compute_all( - base_line.balance, + price_unit_comp_curr, currency=base_line.company_currency_id, + quantity=quantity, + product=base_line.product_id, partner=base_line.partner_id, is_refund=self.type in ('out_refund', 'in_refund'), - handle_price_include=False, ) if base_line.currency_id: # Multi-currencies mode: Taxes are computed both in company's currency / foreign currency. amount_currency_taxes_res = base_line.tax_ids._origin.compute_all( - base_line.amount_currency, + price_unit_foreign_curr, currency=base_line.currency_id, + quantity=quantity, + product=base_line.product_id, partner=base_line.partner_id, is_refund=self.type in ('out_refund', 'in_refund'), - handle_price_include=False, ) for b_tax_res, ac_tax_res in zip(balance_taxes_res['taxes'], amount_currency_taxes_res['taxes']): + tax = self.env['account.tax'].browse(b_tax_res['id']) b_tax_res['amount_currency'] = ac_tax_res['amount'] + + # A tax having a fixed amount must be converted into the company currency when dealing with a + # foreign currency. + if tax.amount_type == 'fixed': + b_tax_res['amount'] = base_line.currency_id._convert(b_tax_res['amount'], move.company_id.currency_id, move.company_id, move.date) + return balance_taxes_res taxes_map = {} @@ -897,35 +924,43 @@ class AccountMove(models.Model): total_tax_currency = 0.0 total_residual = 0.0 total_residual_currency = 0.0 + total = 0.0 + total_currency = 0.0 currencies = set() for line in move.line_ids: if line.currency_id: currencies.add(line.currency_id) - # Untaxed amount. - if (move.is_invoice(include_receipts=True) and not line.exclude_from_invoice_tab)\ - or (move.type == 'entry' and line.debit and not line.tax_line_id): - total_untaxed += line.balance - total_untaxed_currency += line.amount_currency + if move.is_invoice(include_receipts=True): + # === Invoices === - # Tax amount. - if line.tax_line_id: - total_tax += line.balance - total_tax_currency += line.amount_currency + if not line.exclude_from_invoice_tab: + # Untaxed amount. + total_untaxed += line.balance + total_untaxed_currency += line.amount_currency + total += line.balance + total_currency += line.amount_currency + elif line.tax_line_id: + # Tax amount. + total_tax += line.balance + total_tax_currency += line.amount_currency + total += line.balance + total_currency += line.amount_currency + elif line.account_id.user_type_id.type in ('receivable', 'payable'): + # Residual amount. + total_residual += line.amount_residual + total_residual_currency += line.amount_residual_currency + else: + # === Miscellaneous journal entry === + if line.debit: + total += line.balance + total_currency += line.amount_currency - # Residual amount. - if move.type == 'entry' or line.account_id.user_type_id.type in ('receivable', 'payable'): - total_residual += line.amount_residual - total_residual_currency += line.amount_residual_currency - - total = total_untaxed + total_tax - total_currency = total_untaxed_currency + total_tax_currency - - if (move.type == 'entry' and total < 0.0) or move.is_inbound(): - sign = -1 - else: + if move.type == 'entry' or move.is_outbound(): sign = 1 + else: + sign = -1 move.amount_untaxed = sign * (total_untaxed_currency if len(currencies) == 1 else total_untaxed) move.amount_tax = sign * (total_tax_currency if len(currencies) == 1 else total_tax) move.amount_total = sign * (total_currency if len(currencies) == 1 else total) @@ -1209,27 +1244,25 @@ class AccountMove(models.Model): if 'posted' in self.mapped('line_ids.payment_id.state'): raise ValidationError(_("You cannot modify a journal entry linked to a posted payment.")) - @api.constrains('name', 'journal_id') + @api.constrains('name', 'journal_id', 'state') def _check_unique_sequence_number(self): - if not self: + moves = self.filtered(lambda move: move.state == 'posted') + if not moves: return + self.flush() + + # /!\ Computed stored fields are not yet inside the database. self._cr.execute(''' - WITH current_moves (id, name, state, company_id, journal_id, type) as ( values {} ) SELECT move2.id - FROM ( SELECT id, name, state, company_id, journal_id, type from account_move - UNION ALL SELECT id, name, state, company_id, journal_id, type from current_moves - ) move + FROM account_move move INNER JOIN account_move move2 ON move2.name = move.name - AND move2.company_id = move.company_id AND move2.journal_id = move.journal_id AND move2.type = move.type AND move2.id != move.id - WHERE move.id IN %s - AND move.state = 'posted' - AND move2.state = 'posted' - '''.format(", ".join(["(%s, %s, %s, %s, %s, %s)"] * len(self))), list(chain(*self.mapped(lambda r: [r.id, r.name, r.state, r.company_id.id, r.journal_id.id, r.type]))) + [tuple(self.ids)]) + WHERE move.id IN %s AND move2.state = 'posted' + ''', [tuple(moves.ids)]) res = self._cr.fetchone() if res: raise ValidationError(_('Posted journal entry must have an unique sequence number per company.')) @@ -1240,23 +1273,28 @@ class AccountMove(models.Model): if not moves: return + self.env["account.move"].flush([ + "ref", "type", "invoice_date", "journal_id", + "company_id", "partner_id", "commercial_partner_id", + ]) + self.env["account.journal"].flush(["company_id"]) + self.env["res.partner"].flush(["commercial_partner_id"]) + + # /!\ Computed stored fields are not yet inside the database. self._cr.execute(''' - WITH current_moves (id, ref, company_id, commercial_partner_id, type, invoice_date) as ( values {} ) SELECT move2.id - FROM ( SELECT id, ref, company_id, commercial_partner_id, type, invoice_date from account_move - UNION ALL SELECT id, ref, company_id, commercial_partner_id, type, invoice_date from current_moves - ) move + FROM account_move move + JOIN account_journal journal ON journal.id = move.journal_id + JOIN res_partner partner ON partner.id = move.partner_id INNER JOIN account_move move2 ON move2.ref = move.ref - AND move2.company_id = move.company_id - AND move2.commercial_partner_id = move.commercial_partner_id + AND move2.company_id = journal.company_id + AND move2.commercial_partner_id = partner.commercial_partner_id AND move2.type = move.type - AND (move2.invoice_date = move.invoice_date OR move.invoice_date is NULL) + AND (move.invoice_date is NULL OR move2.invoice_date = move.invoice_date) AND move2.id != move.id WHERE move.id IN %s - AND move.type in ('in_invoice', 'in_refund') - AND move.ref IS NOT NULL - '''.format(", ".join(["(%s, %s, %s, %s, %s, CAST(%s as DATE))"] * len(moves))), list(chain(*moves.mapped(lambda r: [r.id, r.ref, r.company_id.id, r.commercial_partner_id.id, r.type, r.invoice_date or None]))) + [tuple(moves.ids)]) + ''', [tuple(moves.ids)]) if self._cr.fetchone(): raise ValidationError(_('Duplicated vendor reference detected. You probably encoded twice the same vendor bill/credit note.')) @@ -1304,24 +1342,22 @@ class AccountMove(models.Model): return True def _check_tax_lock_date(self): - if not self: - return - - self._cr.execute(''' - SELECT move.id, company.tax_lock_date - FROM account_move move - JOIN account_journal journal ON journal.id = move.journal_id - JOIN res_company company ON company.id = journal.company_id - WHERE move.id IN %s - AND move.date < company.tax_lock_date - ''', [tuple(self.ids)]) - - query_res = self._cr.fetchone() - if query_res: - raise UserError(_(''' - The operation is refused as it would impact an already issued tax statement. - Please change the journal entry date or the tax lock date set in the settings (%s) to proceed - ''' % query_res[1])) + for move in self: + if ( + move.company_id.tax_lock_date + and move.date <= move.company_id.tax_lock_date + and any( + line.tax_ids + or line.tax_line_id + or line.tag_ids.filtered(lambda x: x.applicability == "taxes") + for line in move.line_ids + ) + ): + raise UserError( + _( + "The operation is refused as it would impact an already issued tax statement. Please change the journal entry date or the tax lock date set in the settings ({}) to proceed" + ).format(move.company_id.tax_lock_date or date.min) + ) def _check_move_consistency(self): for move in self: @@ -1330,6 +1366,7 @@ class AccountMove(models.Model): raise UserError(_("Cannot create moves for different companies.")) self._check_balanced() + self._check_tax_lock_date() self._check_fiscalyear_lock_date() # ------------------------------------------------------------------------- @@ -1694,6 +1731,13 @@ class AccountMove(models.Model): else: return abs(total_reconciled / total_amount) + def _get_reconciled_payments(self): + """Helper used to retrieve the reconciled payments on this journal entry""" + pay_term_line_ids = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + reconciled_amls = pay_term_line_ids.mapped('matched_debit_ids.debit_move_id') + \ + pay_term_line_ids.mapped('matched_credit_ids.credit_move_id') + return reconciled_amls.mapped('payment_id') + def _reverse_move_vals(self, default_values, cancel=True): ''' Reverse values passed as parameter being the copied values of the original journal entry. For example, debit / credit must be switched. The tax lines must be edited in case of refunds. @@ -1820,7 +1864,16 @@ class AccountMove(models.Model): 'reversed_entry_id': move.id, }) move_vals_list.append(move._reverse_move_vals(default_values, cancel=cancel)) + reverse_moves = self.env['account.move'].create(move_vals_list) + for move, reverse_move in zip(self, reverse_moves.with_context(check_move_validity=False)): + # Update amount_currency if the date has changed. + if move.date != reverse_move.date: + for line in reverse_move.line_ids: + if line.currency_id: + line._onchange_currency() + reverse_move._recompute_dynamic_lines(recompute_all_taxes=False) + reverse_moves._check_balanced() # Reconcile moves together to cancel the previous one. if cancel: @@ -1861,7 +1914,6 @@ class AccountMove(models.Model): move.invoice_date = fields.Date.context_today(self) move.with_context(check_move_validity=False)._onchange_invoice_date() - self._check_tax_lock_date() self._check_move_consistency() # Create the analytic lines in batch is faster as it leads to less cache invalidation. self.mapped('line_ids').create_analytic_lines() @@ -1946,6 +1998,7 @@ class AccountMove(models.Model): self.write({'state': 'draft'}) def button_cancel(self): + self._check_tax_lock_date() AccountMoveLine = self.env['account.move.line'] excluded_move_ids = [] @@ -1953,6 +2006,10 @@ class AccountMove(models.Model): excluded_move_ids = AccountMoveLine.search(AccountMoveLine._get_suspense_moves_domain() + [('move_id', 'in', self.ids)]).mapped('move_id').ids for move in self: + if move in move.line_ids.mapped('full_reconcile_id.exchange_move_id'): + raise UserError(_('You cannot cancel an exchange difference journal entry.')) + if move.tax_cash_basis_rec_id: + raise UserError(_('You cannot cancel a tax cash basis journal entry.')) if not move.journal_id.update_posted and move.id not in excluded_move_ids: raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.')) # We remove all the analytics entries for this journal @@ -2002,6 +2059,7 @@ class AccountMove(models.Model): """ if any(not move.is_invoice(include_receipts=True) for move in self): raise UserError(_("Only invoices could be printed.")) + self._check_tax_lock_date() self.filtered(lambda inv: not inv.invoice_sent).write({'invoice_sent': True}) if self.user_has_groups('account.group_account_invoice'): @@ -2695,7 +2753,7 @@ class AccountMoveLine(models.Model): 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 * partial_line.amount * rate + amount_residual_currency += sign_partial_line * line.currency_id.round(partial_line.amount * rate) #computing the `reconciled` field. reconciled = False @@ -2778,36 +2836,21 @@ class AccountMoveLine(models.Model): if line.reconciled: raise UserError(_('Lines from "Off-Balance Sheet" accounts cannot be reconciled')) - @api.constrains('tax_ids', 'tax_line_id') - def _check_tax_lock_date1(self): - for line in self: - if line.date <= (line.company_id.tax_lock_date or date.min): - raise ValidationError( - _("The operation is refused as it would impact an already issued tax statement. " + - "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format( - line.company_id.tax_lock_date or date.min)) - - @api.constrains('credit', 'debit', 'date') - def _check_tax_lock_date2(self): - for line in self: - if (line.tax_ids or line.tax_line_id) and line.date <= (line.company_id.tax_lock_date or date.min): - raise ValidationError( - _("The operation is refused as it would impact an already issued tax statement. " + - "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format( - line.company_id.tax_lock_date or date.min)) - def _update_check(self): """ Raise Warning to cause rollback if the move is posted, some entries are reconciled or the move is older than the lock date""" move_ids = set() for line in self: err_msg = _('Move name (id): %s (%s)') % (line.move_id.name, str(line.move_id.id)) - if line.move_id.state != 'draft': + if line.move_id.state == 'posted': raise UserError(_('You cannot do this modification on a posted journal entry, you can just change some non legal fields. You must revert the journal entry to cancel it.\n%s.') % err_msg) if line.reconciled and not (line.debit == 0 and line.credit == 0): raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg) if line.move_id.id not in move_ids: move_ids.add(line.move_id.id) - self.env['account.move'].browse(list(move_ids))._check_fiscalyear_lock_date() + if move_ids: + moves = self.env['account.move'].browse(list(move_ids)) + moves._check_fiscalyear_lock_date() + moves._check_tax_lock_date() return True # ------------------------------------------------------------------------- @@ -2963,7 +3006,6 @@ class AccountMoveLine(models.Model): def unlink(self): self._update_check() - self._check_tax_lock_date2() move_ids = set() for line in self: if line.move_id.id not in move_ids: @@ -2997,6 +3039,11 @@ class AccountMoveLine(models.Model): # Suggest default value for debit / credit to balance the journal entry. balance = sum(line['debit'] - line['credit'] for line in move.line_ids) + # if we are here, line_ids is in context, so journal_id should also be. + journal = self.env['account.journal'].browse(self._context.get('default_journal_id') or self._context['journal_id']) + currency = journal.exists() and journal.company_id.currency_id + if currency: + balance = currency.round(balance) if balance < 0.0: values.update({'debit': -balance}) if balance > 0.0: @@ -3201,7 +3248,11 @@ class AccountMoveLine(models.Model): 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 + new_rec.credit_move_id).mapped('move_id.reversed_entry_id'): + 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 @@ -3369,8 +3420,11 @@ class AccountMoveLine(models.Model): def copy_data(self, default=None): res = super(AccountMoveLine, self).copy_data(default=default) - if self._context.get('include_business_fields'): - for line, values in zip(self, res): + for line, values in zip(self, res): + # Don't copy the name of a payment term line. + if line.move_id.is_invoice() and line.account_id.user_type_id.type in ('receivable', 'payable'): + values['name'] = '' + if self._context.get('include_business_fields'): line._copy_data_extend_business_fields(values) return res diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index b9e381f7fbf..25ddf2a3544 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -443,7 +443,8 @@ class account_payment(models.Model): for move in rec.move_line_ids.mapped('move_id'): if rec.reconciled_invoice_ids: move.line_ids.remove_move_reconcile() - move.button_cancel() + if move.state != 'draft': + move.button_cancel() move.unlink() rec.write({ 'state': 'cancelled', diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 6ac0ad9986c..3a70d0516ae 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -399,7 +399,7 @@ class AccountReconcileModel(models.Model): query += ' AND st_line.name NOT ILIKE %s' params += ['%%%s%%' % rule['match_' + field + '_param']] elif rule['match_' + field] == 'match_regex': - query += ' AND st_line.name ~ %s' + query += ' AND st_line.name ~* %s' params += [rule['match_' + field + '_param']] # Filter on partners. @@ -469,12 +469,23 @@ class AccountReconcileModel(models.Model): aml.amount_currency AS aml_amount_currency, account.internal_type AS account_internal_type, - -- Determine a matching or not with the statement line communication using the move.name or move.ref. - regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') - && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + -- Determine a matching or not with the statement line communication using the aml.name, move.name or move.ref. + ( + aml.name IS NOT NULL + AND + TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')) != '' + AND + regexp_split_to_array(TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + ) + OR + regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') OR ( move.ref IS NOT NULL + AND + TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')) != '' AND regexp_split_to_array(TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')),'\s+') && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') @@ -518,11 +529,22 @@ class AccountReconcileModel(models.Model): TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')) != '' AND ( - regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') - && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + ( + aml.name IS NOT NULL + AND + TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')) != '' + AND + regexp_split_to_array(TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + ) + OR + regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') OR ( move.ref IS NOT NULL + AND + TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')) != '' AND regexp_split_to_array(TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')),'\s+') && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') @@ -633,8 +655,10 @@ class AccountReconcileModel(models.Model): if line_residual > total_residual: amount_percentage = (total_residual / line_residual) * 100 - else: + elif total_residual: amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0 + else: + return False return amount_percentage >= self.match_total_amount_param def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None): diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 42f8eabb0a7..367b8f82a93 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -141,7 +141,8 @@ class AccountReconciliation(models.AbstractModel): query += 'LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number %s\n' % (where_bank) query += 'LEFT JOIN res_partner p1 ON st_line.partner_id=p1.id \n' query += 'LEFT JOIN res_partner p2 ON bank.partner_id=p2.id \n' - query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s\n' % (where_partner) + # By definition the commercial partner_id doesn't have a parent_id set + query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s AND p3.parent_id is NULL \n' % (where_partner) query += 'WHERE st_line.id IN %s' params += [tuple(st_lines.ids)] @@ -538,8 +539,10 @@ class AccountReconciliation(models.AbstractModel): ]) domain = expression.OR([domain_reconciliation, domain_matching]) + partner_domain = [] if partner_id: - domain = expression.AND([domain, [('partner_id', '=', partner_id)]]) + partner_domain = [('partner_id', '=', partner_id)] + domain = expression.AND([domain, partner_domain]) if mode == 'rp': domain = expression.AND([domain, [('account_id.internal_type', 'in', ['receivable', 'payable', 'liquidity'])] @@ -553,10 +556,13 @@ class AccountReconciliation(models.AbstractModel): if search_str: str_domain = self._domain_move_lines(search_str=search_str) if not partner_id: - str_domain = expression.OR([ - str_domain, - [('partner_id.name', 'ilike', search_str)] - ]) + partner_domain = [('partner_id.name', 'ilike', search_str)] + + str_domain = expression.OR([ + str_domain, + partner_domain, + ]) + domain = expression.AND([ domain, str_domain diff --git a/addons/account/static/tests/tours/reconciliation.js b/addons/account/static/tests/tours/reconciliation.js index 803acef88d0..6566c55e09f 100644 --- a/addons/account/static/tests/tours/reconciliation.js +++ b/addons/account/static/tests/tours/reconciliation.js @@ -63,19 +63,19 @@ Tour.register('bank_statement_reconciliation', { }, { content: "use filter", - trigger: '.o_reconciliation_line:nth-child(2) .match .match_controls .filter', + trigger: '.o_reconciliation_line:nth-child(1) .match .match_controls .filter', run: 'text 4610' }, { content: "select a line linked to Deco Addict ", - extra_trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button:last[data-content*='4,610']", - trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button[data-content*='Deco Addict']" + extra_trigger: ".o_reconciliation_line:nth-child(1) .match .line_info_button:last[data-content*='4,610']", + trigger: ".o_reconciliation_line:nth-child(1) .match .line_info_button[data-content*='Deco Addict']" }, { content: "deselect the line", - trigger: '.o_reconciliation_line:nth-child(2) .accounting_view tbody .cell_label:first', + trigger: '.o_reconciliation_line:nth-child(1) .accounting_view tbody .cell_label:first', run: function() { - $('.o_reconciliation_line:nth-child(2) .accounting_view tbody .cell_label:first').trigger('click'); + $('.o_reconciliation_line:nth-child(1) .accounting_view tbody .cell_label:first').trigger('click'); } }, { diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 0cca1a3c3a1..59dd42269fe 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -20,5 +20,6 @@ from . import test_templates_consistency from . import test_account_fiscal_year from . import test_account_all_l10n from . import test_reconciliation_matching_rules +from . import test_account_move_tax_lock_date from . import test_account_onboarding from . import test_portal_attachment diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 4a0fbf2848a..587dfc48deb 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -69,34 +69,27 @@ class TestAccountMove(InvoiceTestCommon): # lines[3] = 'revenue line 2' lines = self.test_move.line_ids.sorted('debit') - # Writing not affecting a tax is allowed. - self.test_move.write({ - 'line_ids': [ - (1, lines[0].id, {'credit': 1750.0}), # counterpart line - (1, lines[2].id, {'debit': 600.0}), # revenue line 1 - ], - }) self.test_move.flush() self.cr.execute('SAVEPOINT test_misc_tax_lock_date_1') # Writing something affecting a tax is not allowed. - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[0].id, {'credit': 2750.0}), - (1, lines[3].id, {'debit': 2000.0}), + (1, lines[3].id, {'debit': 2100.0}), ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[3].id, {'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)]}), ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[0].id, {'credit': 1900.0}), @@ -104,12 +97,22 @@ class TestAccountMove(InvoiceTestCommon): ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.unlink() self.test_move.flush() self.test_move.invalidate_cache() self.cr.execute('ROLLBACK TO SAVEPOINT test_misc_tax_lock_date_1') - with self.assertRaises(UserError): - self.test_move.post() + def test_misc_unique_sequence_number(self): + ''' Ensure two journal entries can't share the same name when using the same sequence. ''' + self.test_move.post() + + # Edit the sequence to force the next move to get the same name. + self.test_move.journal_id\ + .sequence_id.date_range_ids\ + .filtered(lambda seq: seq.date_from == fields.Date.from_string('2016-01-01')).number_next -= 1 + + test_move2 = self.test_move.copy() + with self.assertRaises(ValidationError): + test_move2.post() diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py index dd4a9a834e5..9696c31065f 100644 --- a/addons/account/tests/test_account_move_in_invoice.py +++ b/addons/account/tests/test_account_move_in_invoice.py @@ -3,6 +3,7 @@ from odoo.addons.account.tests.invoice_test_common import InvoiceTestCommon from odoo.tests.common import Form from odoo.tests import tagged from odoo import fields +from odoo.exceptions import ValidationError @tagged('post_install', '-at_install') @@ -851,7 +852,7 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): self.term_line_vals_1, ], self.move_vals) - def test_in_invoice_create_draft_refund(self): + def test_in_invoice_create_refund(self): self.invoice.post() move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ @@ -885,8 +886,10 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, + 'name': '', 'debit': 1128.0, 'credit': 0.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, @@ -898,7 +901,7 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ 'date': fields.Date.from_string('2019-02-01'), - 'reason': 'because', + 'reason': 'no reason again', 'refund_method': 'cancel', }) reversal = move_reversal.reverse_moves() @@ -927,8 +930,10 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, + 'name': '', 'debit': 1128.0, 'credit': 0.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, @@ -938,6 +943,128 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): 'invoice_payment_state': 'paid', }) + def test_in_invoice_create_refund_multi_currency(self): + ''' Test the account.move.reversal takes care about the currency rates when setting + a custom reversal date. + ''' + move_form = Form(self.invoice) + move_form.date = '2016-01-01' + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.invoice.post() + + # The currency rate changed from 1/3 to 1/2. + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 564.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason again', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 564.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'paid', + }) + def test_in_invoice_create_1(self): # Test creating an account_move with the least information. move = self.env['account.move'].create({ @@ -1042,6 +1169,14 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): 'currency_id': self.currency_data['currency'].id, }) + def test_in_invoice_duplicate_supplier_reference(self): + ''' Ensure two vendor bills can't share the same vendor reference. ''' + self.invoice.ref = 'a supplier reference' + invoice2 = self.invoice.copy() + + with self.assertRaises(ValidationError): + invoice2.ref = 'a supplier reference' + def test_in_invoice_switch_in_refund_1(self): # Test creating an account_move with an in_invoice_type and switch it in an in_refund. move = self.env['account.move'].create({ diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index c7a6330db8e..d3c68051ee1 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -836,7 +836,7 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): self.assertRecordValues(invoice_copy, [{'name': 'INV/2019/0043'}]) - def test_out_invoice_create_draft_refund(self): + def test_out_invoice_create_refund(self): self.invoice.post() move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ @@ -870,12 +870,14 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, - 'name': self.invoice.name, + 'name': '', 'debit': 0.0, 'credit': 1410.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, + 'name': '/', 'date': move_reversal.date, 'state': 'draft', 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), @@ -913,9 +915,10 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, - 'name': self.invoice.name, + 'name': '', 'debit': 0.0, 'credit': 1410.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, @@ -925,6 +928,128 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): 'invoice_payment_state': 'paid', }) + def test_out_invoice_create_refund_multi_currency(self): + ''' Test the account.move.reversal takes care about the currency rates when setting + a custom reversal date. + ''' + move_form = Form(self.invoice) + move_form.date = '2016-01-01' + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.invoice.post() + + # The currency rate changed from 1/3 to 1/2. + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 90.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 15.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 705.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 90.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 15.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 705.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'paid', + }) + def test_out_invoice_create_1(self): # Test creating an account_move with the least information. move = self.env['account.move'].create({ diff --git a/addons/account/tests/test_account_move_tax_lock_date.py b/addons/account/tests/test_account_move_tax_lock_date.py new file mode 100644 index 00000000000..280536e6b46 --- /dev/null +++ b/addons/account/tests/test_account_move_tax_lock_date.py @@ -0,0 +1,98 @@ +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.exceptions import UserError +from datetime import date +from calendar import monthrange +# from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestTaxBlockDate(AccountingTestCase): + """ + Forbid creation, edition and deletion of Journal Items related to taxes with + a date prior to the Tax Block Date. + """ + + def setUp(self): + super(TestTaxBlockDate, self).setUp() + self.user_id = self.env.user + self.company = self.env.company + company_id = self.company.id + + last_day_month = date.today() + self.last_day_month = last_day_month.replace(day=monthrange(last_day_month.year, last_day_month.month)[1]) + first_day_month = date.today() + self.first_day_month = first_day_month.replace(day=1) + middle_day_month = date.today() + middle_day_month = middle_day_month.replace(day=15) + + self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company_id)], limit=1)[0] + self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.other_account_id = self.env['account.account'].search([('internal_type', '!=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.tax_id = self.env['account.tax'].search([('company_id', '=', company_id)], limit=1)[0] + + self.move = { + 'name': '/', + 'journal_id': self.sale_journal_id.id, + 'date': middle_day_month, + 'line_ids': [(0, 0, { + 'name': 'foo', + 'debit': 10, + 'account_id': self.account_id.id, + 'tax_ids': [(6, False, [self.tax_id.id])] + }), (0, 0, { + 'name': 'bar', + 'credit': 10, + 'account_id': self.account_id.id, + })] + } + + self.move_no_tax = { + 'name': '/', + 'journal_id': self.sale_journal_id.id, + 'date': middle_day_month, + 'line_ids': [(0, 0, { + 'name': 'foo', + 'debit': 10, + 'account_id': self.account_id.id, + }), (0, 0, { + 'name': 'bar', + 'credit': 10, + 'account_id': self.account_id.id, + })] + } + + def test_tax_lock_date_post(self): + """ Checks that it is impossible to post an entry + before the tax lock date. + """ + # Posting after the lock date is always allowed + self.company.tax_lock_date = self.first_day_month + # copy() because mail.thread modifies the dictionary given to create + move_after_lock = self.env['account.move'].create(self.move.copy()) + move_after_lock.post() + + # Posting before the tax lock date is allowed only if the move doesn't contain any tax + self.user_id.company_id.tax_lock_date = self.last_day_month + move_before_lock_no_tax = self.env['account.move'].create(self.move_no_tax) + move_before_lock_no_tax.post() + + # Posting before the tax lock date is not allowed for moves impacting the tax report + self.user_id.company_id.tax_lock_date = self.last_day_month + with self.assertRaises(UserError): + self.env['account.move'].create(self.move.copy()) + + def test_tax_lock_date_cancel(self): + """ Checks that it is not possible to cancel an entry posted before the + tax lock date if it impacts the tax report. """ + self.sale_journal_id.update_posted = True + move = self.env['account.move'].create(self.move) + move.post() + move_no_tax = self.env['account.move'].create(self.move_no_tax) + move_no_tax.post() + + self.user_id.company_id.tax_lock_date = self.last_day_month + + move_no_tax.button_cancel() + with self.assertRaises(UserError): + move.button_cancel() diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index fa94e34018e..94e5e69bee4 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -149,6 +149,24 @@ class TestReconciliation(AccountingTestCase): invoice.post() return invoice + def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False): + move = self.env['account.move'].with_context(default_type=type).create({ + 'type': type, + 'partner_id': partner_id, + 'invoice_date': time.strftime('%Y') + '-07-01', + 'date': time.strftime('%Y') + '-07-01', + 'currency_id': currency_id, + 'invoice_payment_term_id': payment_term_id, + 'invoice_line_ids': [(0, 0, { + 'quantity': 1, + 'price_unit': invoice_amount, + + 'name': 'product that cost ' + str(invoice_amount), + })], + }) + move.post() + return move + def make_payment(self, invoice_record, bank_journal, amount=0.0, amount_currency=0.0, currency_id=None): bank_stmt = self.acc_bank_stmt_model.create({ 'journal_id': bank_journal.id, @@ -895,22 +913,6 @@ class TestReconciliationExec(TestReconciliation): self.assertEqual(reversed_bank_line.full_reconcile_id.id, bank_line.full_reconcile_id.id) self.assertEqual(reversed_customer_line.full_reconcile_id.id, customer_line.full_reconcile_id.id) - def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False): - move = self.env['account.move'].with_context(default_type=type).create({ - 'type': type, - 'partner_id': partner_id, - 'invoice_date': time.strftime('%Y') + '-07-01', - 'date': time.strftime('%Y') + '-07-01', - 'currency_id': currency_id, - 'invoice_payment_term_id': payment_term_id, - 'invoice_line_ids': [(0, 0, { - 'quantity': 1, - 'price_unit': invoice_amount, - 'name': 'product that cost ' + str(invoice_amount), - })], - }) - move.post() - return move def test_aged_report(self): AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True) @@ -1169,6 +1171,68 @@ class TestReconciliationExec(TestReconciliation): # because they owe us still 50 CC. self.assertEqual(invoice_cust_1.invoice_payment_state, 'not_paid', 'Invoice is in status %s' % invoice_cust_1.state) + def test_inv_refund_foreign_payment_writeoff_domestic(self): + company = self.env.ref('base.main_company') + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.113900, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': self.env.ref('base.main_company').id + }) + inv1 = self.create_invoice(invoice_amount=480, currency_id=self.currency_usd_id) + inv2 = self.create_invoice(type="out_refund", invoice_amount=140, currency_id=self.currency_usd_id) + + payment = self.env['account.payment'].create({ + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': inv1.partner_id.id, + 'amount': 287.20, + 'journal_id': self.bank_journal_euro.id, + 'company_id': company.id, + }) + payment.post() + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + data_for_reconciliation = [ + { + 'type': 'partner', + 'id': inv1.partner_id.id, + 'mv_line_ids': (inv1_receivable + inv2_receivable + pay_receivable).ids, + 'new_mv_line_dicts': [ + { + 'credit': 18.04, + 'debit': 0.00, + 'journal_id': self.bank_journal_euro.id, + 'name': 'Total WriteOff (Fees)', + 'account_id': self.diff_expense_account.id + } + ] + } + ] + + self.env["account.reconciliation.widget"].process_move_lines(data_for_reconciliation) + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEquals(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEquals(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) + + self.assertTrue(all(l.reconciled for l in inv1_receivable)) + self.assertTrue(all(l.reconciled for l in inv2_receivable)) + + self.assertEquals(inv1.invoice_payment_state, 'paid') + self.assertEquals(inv2.invoice_payment_state, 'paid') + def test_multiple_term_reconciliation_opw_1906665(self): '''Test that when registering a payment to an invoice with multiple payment term lines the reconciliation happens against the line diff --git a/addons/account/tests/test_reconciliation_widget.py b/addons/account/tests/test_reconciliation_widget.py index a81b3471d9b..99769bc2f2e 100644 --- a/addons/account/tests/test_reconciliation_widget.py +++ b/addons/account/tests/test_reconciliation_widget.py @@ -53,3 +53,82 @@ class TestReconciliationWidget(TestReconciliation): result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids) self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00') + + def test_filter_partner1(self): + inv1 = self.create_invoice(currency_id=self.currency_euro_id) + inv2 = self.create_invoice(currency_id=self.currency_euro_id) + + receivable1 = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + receivable2 = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + bank_stmt = self.acc_bank_stmt_model.create({ + 'company_id': self.env.ref('base.main_company').id, + 'journal_id': self.bank_journal_euro.id, + 'date': time.strftime('%Y-07-15'), + 'name': 'test', + }) + + bank_stmt_line = self.acc_bank_stmt_line_model.create({ + 'name': 'testLine', + 'statement_id': bank_stmt.id, + 'amount': 100, + 'date': time.strftime('%Y-07-15'), + }) + + # This is like opening the widget, and type "deco" in the filter + mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line( + bank_stmt_line.id, + partner_id=False, + excluded_ids=[], + search_str="deco", + mode="rp", + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertIn(receivable1.id, mv_lines_ids) + self.assertIn(receivable2.id, mv_lines_ids) + + # This is like clicking on the first receivable + partner = inv1.partner_id + mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line( + bank_stmt_line.id, + partner_id=partner.id, + excluded_ids=[receivable1.id], + search_str="deco", + mode="rp", + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertNotIn(receivable1.id, mv_lines_ids) + self.assertIn(receivable2.id, mv_lines_ids) + + def test_partner_name_with_parent(self): + parent_partner = self.env['res.partner'].create({ + 'name': 'test', + }) + child_partner = self.env['res.partner'].create({ + 'name': 'test', + 'parent_id': parent_partner.id, + 'type': 'delivery', + }) + self.create_invoice_partner(currency_id=self.currency_euro_id, partner_id=child_partner.id) + + bank_stmt = self.acc_bank_stmt_model.create({ + 'company_id': self.env.ref('base.main_company').id, + 'journal_id': self.bank_journal_euro.id, + 'date': time.strftime('%Y-07-15'), + 'name': 'test', + }) + + bank_stmt_line = self.acc_bank_stmt_line_model.create({ + 'name': 'testLine', + 'statement_id': bank_stmt.id, + 'amount': 100, + 'date': time.strftime('%Y-07-15'), + 'partner_name': 'test', + }) + + bkstmt_data = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids) + + self.assertEqual(len(bkstmt_data['lines']), 1) + self.assertEqual(bkstmt_data['lines'][0]['partner_id'], parent_partner.id) diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 903699dc4d9..48119a58cba 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -348,12 +348,32 @@ class TestTax(AccountTestUsers): 'price_include': True, 'include_base_amount': True, 'sequence': 1, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_2 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_2', 'amount_type': 'percent', 'amount': 10, 'sequence': 2, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_3 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_3', @@ -361,12 +381,32 @@ class TestTax(AccountTestUsers): 'amount': 10, 'price_include': True, 'sequence': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_4 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_4', 'amount_type': 'percent', 'amount': 10, 'sequence': 4, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_5 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_5', @@ -374,21 +414,229 @@ class TestTax(AccountTestUsers): 'amount': 10, 'price_include': True, 'sequence': 5, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 - res = taxes.compute_all(132.0) + + # Test with positive amount. self._check_compute_all_results( 154, # 'total_included' 100, # 'total_excluded' [ # base , amount | seq | amount | incl | incl_base # --------------------------------------------------- - (100.0, 10.0), # | 1 | 10% | t | t - (110.0, 11.0), # | 2 | 10% | | - (110.0, 11.0), # | 3 | 10% | t | - (110.0, 11.0), # | 4 | 10% | | - (110.0, 11.0), # | 5 | 10% | t | + (100.0, 5.0), # | 1 | 10% | t | t + (100.0, 5.0), # | 1 | 10% | t | t + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 5 | 10% | t | + (110.0, 5.5), # | 5 | 10% | t | # --------------------------------------------------- ], - res + taxes.compute_all(132.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -154, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (-100.0, -5.0), # | 1 | 10% | t | t + (-100.0, -5.0), # | 1 | 10% | t | t + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 5 | 10% | t | + (-110.0, -5.5), # | 5 | 10% | t | + # --------------------------------------------------- + ], + taxes.compute_all(-132.0) + ) + + def test_intracomm_taxes_computation_0(self): + ''' Test usage of intracomm taxes having e.g.+100%, -100% as repartition lines. ''' + intracomm_tax = self.env['account.tax'].create({ + 'name': 'test_intracomm_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 21, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 100, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount + # --------------- + (100.0, 21.0), + (100.0, -21.0), + # --------------- + ], + intracomm_tax.compute_all(100.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -100, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount + # --------------- + (-100.0, -21.0), + (-100.0, 21.0), + # --------------- + ], + intracomm_tax.compute_all(-100.0) + ) + + def test_rounding_issues_0(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_rounding_issues_0', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1.09, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.02), + (1.0, 0.02), + (1.0, 0.02), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1.09, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.02), + (-1.0, -0.02), + (-1.0, -0.02), + # --------------- + ], + tax.compute_all(-1.0) + ) + + def test_rounding_issues_1(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_repartition_lines_computation_1', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.02), + (1.0, -0.02), + (1.0, 0.01), + (1.0, 0.01), + (1.0, -0.01), + (1.0, -0.01), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.02), + (-1.0, 0.02), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, 0.01), + (-1.0, 0.01), + # --------------- + ], + tax.compute_all(-1.0) ) diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index bd0cbd95093..f5443190945 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -61,6 +61,9 @@ + + + @@ -611,7 +614,7 @@