diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 84d96eb4213..6b71f587147 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -537,6 +537,15 @@ class AccountTax(models.Model): analytic = fields.Boolean(string="Include in Analytic Cost", help="If set, the amount computed by this tax will be assigned to the same analytic account as the invoice line (if any)") tag_ids = fields.Many2many('account.account.tag', 'account_tax_account_tag', string='Tags', help="Optional tags you may want to assign for custom reporting") tax_group_id = fields.Many2one('account.tax.group', string="Tax Group", default=_default_tax_group, required=True) + use_cash_basis = fields.Boolean( + 'Use Cash Basis', + help="Select this if the tax should use cash basis," + "which will create an entry for this tax on a given account during reconciliation") + cash_basis_account = fields.Many2one( + 'account.account', + string='Tax Received Account', + domain=[('deprecated', '=', False)], + help='Account use when creating entry for tax cash basis') _sql_constraints = [ ('name_company_uniq', 'unique(name, company_id, type_tax_use)', 'Tax names must be unique !'), diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 5c179baed4f..c2e195a535c 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -101,6 +101,11 @@ class AccountMove(models.Model): statement_line_id = fields.Many2one('account.bank.statement.line', index=True, string='Bank statement line reconciled with this entry', copy=False, readonly=True) # Dummy Account field to search on account.move by account_id dummy_account_id = fields.Many2one('account.account', related='line_ids.account_id', string='Account', store=False) + tax_cash_basis_rec_id = fields.Many2one( + 'account.partial.reconcile', + 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.") @api.model def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): @@ -1116,6 +1121,16 @@ class AccountMoveLine(models.Model): temp['amount_currency'] = bank.company_id.currency_id.with_context(ctx).compute(tax_vals['amount'], bank.currency_id, round=True) tax_lines_vals.append(temp) + #Toggle the 'tax_exigible' field to False in case it is not yet given and the tax in 'tax_line_id' or one of + #the 'tax_ids' is a cash based tax. + taxes = False + if vals.get('tax_line_id'): + taxes = [{'use_cash_basis': self.env['account.tax'].browse(vals['tax_line_id']).use_cash_basis}] + if vals.get('tax_ids'): + taxes = self.env['account.move.line'].resolve_2many_commands('tax_ids', vals['tax_ids']) + if taxes and any([tax['use_cash_basis'] for tax in taxes]) and not vals.get('tax_exigible'): + vals['tax_exigible'] = False + new_line = super(AccountMoveLine, self).create(vals) for tax_line_vals in tax_lines_vals: # TODO: remove .with_context(context) once this context nonsense is solved @@ -1198,6 +1213,45 @@ class AccountMoveLine(models.Model): result.append((line.id, line.move_id.name)) return result + 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 actuallty 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.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: + matched_percentage_per_move[line.move_id.id] = total_reconciled_currency / total_amount_currency + return matched_percentage_per_move + @api.model def compute_amount_fields(self, amount, src_currency, company_currency, invoice_currency=False): """ Helper function to compute value for fields debit/credit/amount_currency based on an amount and the currencies given in parameter""" @@ -1377,9 +1431,116 @@ class AccountPartialReconcile(models.Model): move.post() return line_to_reconcile, partial_rec + 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'] + 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 + for line in move.line_ids: + #TOCHECK: normal and cash basis taxes shoudn't be mixed together (on the same invoice line for example) as it will + # create reporting issues. Not sure of the behavior to implement in that case, though. + if not line.tax_exigible: + percentage_before = percentage_before_rec[move.id] + percentage_after = line._get_matched_percentage()[move.id] + #amount is the current cash_basis amount minus the one before the reconciliation + amount = line.balance * percentage_after - line.balance * percentage_before + rounded_amt = line.company_id.currency_id.round(amount) + if line.tax_line_id and line.tax_line_id.use_cash_basis: + 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, + 'tax_exigible': True, + 'amount_currency': self.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, + }) + # 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_line_id.cash_basis_account.id, + 'tax_line_id': line.tax_line_id.id, + 'tax_exigible': True, + 'amount_currency': self.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, + }) + if line.account_id.reconcile: + #setting the account to allow reconciliation will help to fix rounding errors + to_clear_aml |= line + to_clear_aml.reconcile() + + if any([tax.use_cash_basis for tax in line.tax_ids]): + #create cash basis entry for the base + for tax in line.tax_ids: + 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': line.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': self.amount_currency and line.currency_id.round(line.amount_currency * amount / line.balance) or 0.0, + }) + 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': line.account_id.id, + 'tax_exigible': True, + 'move_id': newly_created_move.id, + 'currency_id': line.currency_id.id, + 'amount_currency': self.amount_currency and line.currency_id.round(-line.amount_currency * amount / line.balance) or 0.0, + }) + if newly_created_move: + if move_date > self.company_id.period_lock_date 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}) + # post move + newly_created_move.post() + + 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, + } + return self.env['account.move'].create(move_vals) + @api.model def create(self, vals): + aml = [] + if vals.get('debit_move_id', False): + aml.append(vals['debit_move_id']) + if vals.get('credit_move_id', False): + aml.append(vals['credit_move_id']) + # Get value of matched percentage from both move before reconciliating + lines = self.env['account.move.line'].browse(aml) + if lines[0].account_id.internal_type in ('receivable', 'payable'): + percentage_before_rec = lines._get_matched_percentage() + # Reconcile res = super(AccountPartialReconcile, self).create(vals) + # if the reconciliation is a matching on a receivable or payable account, eventually create a tax cash basis entry + if lines[0].account_id.internal_type in ('receivable', 'payable'): + res.create_tax_cash_basis_entry(percentage_before_rec) if self._context.get('skip_full_reconcile_check'): #when running the manual reconciliation wizard, don't check the partials separately for full #reconciliation or exchange rate because it is handled manually after the whole processing @@ -1448,11 +1609,15 @@ class AccountPartialReconcile(models.Model): #without the deleted partial reconciliations, the full reconciliation won't be full anymore if rec.full_reconcile_id: full_to_unlink |= rec.full_reconcile_id + #reverse the tax basis move created at the reconciliation time + move = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self._ids)]) + move.reverse_moves() 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" diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index a7841f307c4..5336019ba2a 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -461,6 +461,7 @@ class AccountTaxTemplate(models.Model): name = fields.Char(string='Tax Name', required=True) type_tax_use = fields.Selection([('sale', 'Sales'), ('purchase', 'Purchases'), ('none', 'None')], string='Tax Scope', required=True, default="sale", help="Determines where the tax is selectable. Note : 'None' means a tax can't be used by itself, however it can still be used in a group.") + tax_adjustment = fields.Boolean(default=False) amount_type = fields.Selection(default='percent', string="Tax Computation", required=True, selection=[('group', 'Group of Taxes'), ('fixed', 'Fixed'), ('percent', 'Percentage of Price'), ('division', 'Percentage of Price Tax Included')]) active = fields.Boolean(default=True, help="Set active to false to hide the tax without removing it.") @@ -481,7 +482,15 @@ class AccountTaxTemplate(models.Model): analytic = fields.Boolean(string="Analytic Cost", help="If set, the amount computed by this tax will be assigned to the same analytic account as the invoice line (if any)") tag_ids = fields.Many2many('account.account.tag', string='Account tag', help="Optional tags you may want to assign for custom reporting") tax_group_id = fields.Many2one('account.tax.group', string="Tax Group") - tax_adjustment = fields.Boolean(default=False) + use_cash_basis = fields.Boolean( + 'Use Cash Basis', + help="Select this if the tax should use cash basis," + "which will create an entry for this tax on a given account during reconciliation") + cash_basis_account = fields.Many2one( + 'account.account', + string='Tax Received Account', + domain=[('deprecated', '=', False)], + help='Account use when creating entry for tax cash basis') _sql_constraints = [ ('name_company_uniq', 'unique(name, company_id, type_tax_use)', 'Tax names must be unique !'), @@ -514,6 +523,8 @@ class AccountTaxTemplate(models.Model): 'analytic': self.analytic, 'tag_ids': [(6, 0, [t.id for t in self.tag_ids])], 'tax_adjustment': self.tax_adjustment, + 'use_cash_basis': self.use_cash_basis, + 'cash_basis_account': self.cash_basis_account, } if self.tax_group_id: val['tax_group_id'] = self.tax_group_id.id diff --git a/addons/account/models/company.py b/addons/account/models/company.py index d4ed009ccdc..0798a7ce8ab 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -19,6 +19,7 @@ class ResCompany(models.Model): bank_account_code_prefix = fields.Char(string='Prefix of the bank accounts', oldname="bank_account_code_char") cash_account_code_prefix = fields.Char(string='Prefix of the cash accounts') accounts_code_digits = fields.Integer(string='Number of digits in an account code') + tax_cash_basis_journal_id = fields.Many2one('account.journal', string="Tax Cash Basis Journal") tax_calculation_rounding_method = fields.Selection([ ('round_per_line', 'Round per Line'), ('round_globally', 'Round Globally'), diff --git a/addons/account/models/res_config.py b/addons/account/models/res_config.py index 164e9c84972..8b1386a50dd 100644 --- a/addons/account/models/res_config.py +++ b/addons/account/models/res_config.py @@ -106,8 +106,6 @@ class AccountConfigSettings(models.TransientModel): 'Once the master budgets and the budgets are defined, ' 'the project managers can set the planned amount on each analytic account.\n' '-This installs the module account_budget.') - module_account_tax_cash_basis = fields.Boolean(string="Allow Tax Cash Basis", - help='Generate tax cash basis entrie when reconciliating entries') group_proforma_invoices = fields.Boolean(string='Allow pro-forma invoices', implied_group='account.group_proforma_invoices', @@ -115,6 +113,10 @@ class AccountConfigSettings(models.TransientModel): module_account_reports_followup = fields.Boolean("Enable payment followup management", help='This allows to automate letters for unpaid invoices, with multi-level recalls.\n' '-This installs the module account_reports_followup.') + tax_cash_basis_journal_id = fields.Many2one( + 'account.journal', + related='company_id.tax_cash_basis_journal_id', + string="Tax Cash Basis Journal",) default_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", help="This sale tax will be assigned by default on new products.", oldname="default_sale_tax") default_purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax", help="This purchase tax will be assigned by default on new products.", oldname="default_purchase_tax") diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index fa3ba355c43..e7142256fd8 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -982,6 +982,8 @@ + + diff --git a/addons/account/views/res_config_view.xml b/addons/account/views/res_config_view.xml index 5b1f0872cb1..0875ea0f224 100644 --- a/addons/account/views/res_config_view.xml +++ b/addons/account/views/res_config_view.xml @@ -130,11 +130,8 @@