summaryrefslogtreecommitdiff
path: root/addons/account/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/account/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/account/models')
-rw-r--r--addons/account/models/__init__.py32
-rw-r--r--addons/account/models/account_account.py701
-rw-r--r--addons/account/models/account_account_tag.py30
-rw-r--r--addons/account/models/account_analytic_default.py65
-rw-r--r--addons/account/models/account_analytic_line.py93
-rw-r--r--addons/account/models/account_bank_statement.py1305
-rw-r--r--addons/account/models/account_cash_rounding.py54
-rw-r--r--addons/account/models/account_full_reconcile.py37
-rw-r--r--addons/account/models/account_incoterms.py19
-rw-r--r--addons/account/models/account_journal.py785
-rw-r--r--addons/account/models/account_journal_dashboard.py564
-rw-r--r--addons/account/models/account_move.py5082
-rw-r--r--addons/account/models/account_partial_reconcile.py617
-rw-r--r--addons/account/models/account_payment.py863
-rw-r--r--addons/account/models/account_payment_term.py125
-rw-r--r--addons/account/models/account_reconcile_model.py988
-rw-r--r--addons/account/models/account_tax.py685
-rw-r--r--addons/account/models/account_tax_report.py277
-rw-r--r--addons/account/models/chart_template.py1242
-rw-r--r--addons/account/models/company.py554
-rw-r--r--addons/account/models/digest.py34
-rw-r--r--addons/account/models/ir_actions_report.py35
-rw-r--r--addons/account/models/mail_thread.py27
-rw-r--r--addons/account/models/partner.py510
-rw-r--r--addons/account/models/product.py61
-rw-r--r--addons/account/models/res_bank.py96
-rw-r--r--addons/account/models/res_config_settings.py164
-rw-r--r--addons/account/models/res_currency.py66
-rw-r--r--addons/account/models/res_partner_bank.py16
-rw-r--r--addons/account/models/res_users.py27
-rw-r--r--addons/account/models/sequence_mixin.py241
31 files changed, 15395 insertions, 0 deletions
diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py
new file mode 100644
index 00000000..a8c6e893
--- /dev/null
+++ b/addons/account/models/__init__.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+
+from . import sequence_mixin
+from . import partner
+from . import res_partner_bank
+from . import account_account_tag
+from . import account_account
+from . import account_journal
+from . import account_tax
+from . import account_tax_report
+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
+from . import account_analytic_line
+from . import account_journal_dashboard
+from . import product
+from . import company
+from . import res_config_settings
+from . import account_cash_rounding
+from . import account_incoterms
+from . import digest
+from . import res_users
+from . import ir_actions_report
+from . import res_currency
+from . import res_bank
+from . import mail_thread
diff --git a/addons/account/models/account_account.py b/addons/account/models/account_account.py
new file mode 100644
index 00000000..05317a8a
--- /dev/null
+++ b/addons/account/models/account_account.py
@@ -0,0 +1,701 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _, tools
+from odoo.osv import expression
+from odoo.exceptions import UserError, ValidationError
+
+
+class AccountAccountType(models.Model):
+ _name = "account.account.type"
+ _description = "Account Type"
+
+ name = fields.Char(string='Account Type', required=True, translate=True)
+ include_initial_balance = fields.Boolean(string="Bring Accounts Balance Forward", help="Used in reports to know if we should consider journal items from the beginning of time instead of from the fiscal year only. Account types that should be reset to zero at each new fiscal year (like expenses, revenue..) should not have this option set.")
+ type = fields.Selection([
+ ('other', 'Regular'),
+ ('receivable', 'Receivable'),
+ ('payable', 'Payable'),
+ ('liquidity', 'Liquidity'),
+ ], required=True, default='other',
+ help="The 'Internal Type' is used for features available on "\
+ "different types of accounts: liquidity type is for cash or bank accounts"\
+ ", payable/receivable is for vendor/customer accounts.")
+ internal_group = fields.Selection([
+ ('equity', 'Equity'),
+ ('asset', 'Asset'),
+ ('liability', 'Liability'),
+ ('income', 'Income'),
+ ('expense', 'Expense'),
+ ('off_balance', 'Off Balance'),
+ ], string="Internal Group",
+ required=True,
+ help="The 'Internal Group' is used to filter accounts based on the internal group set on the account type.")
+ note = fields.Text(string='Description')
+
+
+class AccountAccount(models.Model):
+ _name = "account.account"
+ _description = "Account"
+ _order = "is_off_balance, code, company_id"
+ _check_company_auto = True
+
+ @api.constrains('internal_type', 'reconcile')
+ def _check_reconcile(self):
+ for account in self:
+ if account.internal_type in ('receivable', 'payable') and account.reconcile == False:
+ raise ValidationError(_('You cannot have a receivable/payable account that is not reconcilable. (account code: %s)', account.code))
+
+ @api.constrains('user_type_id')
+ def _check_user_type_id_unique_current_year_earning(self):
+ data_unaffected_earnings = self.env.ref('account.data_unaffected_earnings')
+ result = self.read_group([('user_type_id', '=', data_unaffected_earnings.id)], ['company_id'], ['company_id'])
+ for res in result:
+ if res.get('company_id_count', 0) >= 2:
+ account_unaffected_earnings = self.search([('company_id', '=', res['company_id'][0]),
+ ('user_type_id', '=', data_unaffected_earnings.id)])
+ raise ValidationError(_('You cannot have more than one account with "Current Year Earnings" as type. (accounts: %s)', [a.code for a in account_unaffected_earnings]))
+
+ name = fields.Char(string="Account Name", required=True, index=True)
+ currency_id = fields.Many2one('res.currency', string='Account Currency',
+ help="Forces all moves for this account to have this account currency.")
+ code = fields.Char(size=64, required=True, index=True)
+ deprecated = fields.Boolean(index=True, default=False)
+ used = fields.Boolean(compute='_compute_used', search='_search_used')
+ user_type_id = fields.Many2one('account.account.type', string='Type', required=True,
+ help="Account Type is used for information purpose, to generate country-specific legal reports, and set the rules to close a fiscal year and generate opening entries.")
+ internal_type = fields.Selection(related='user_type_id.type', string="Internal Type", store=True, readonly=True)
+ internal_group = fields.Selection(related='user_type_id.internal_group', string="Internal Group", store=True, readonly=True)
+ #has_unreconciled_entries = fields.Boolean(compute='_compute_has_unreconciled_entries',
+ # help="The account has at least one unreconciled debit and credit since last time the invoices & payments matching was performed.")
+ reconcile = fields.Boolean(string='Allow Reconciliation', default=False,
+ help="Check this box if this account allows invoices & payments matching of journal items.")
+ tax_ids = fields.Many2many('account.tax', 'account_account_tax_default_rel',
+ 'account_id', 'tax_id', string='Default Taxes',
+ check_company=True,
+ context={'append_type_to_tax_name': True})
+ note = fields.Text('Internal Notes')
+ company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True,
+ default=lambda self: self.env.company)
+ tag_ids = fields.Many2many('account.account.tag', 'account_account_account_tag', string='Tags', help="Optional tags you may want to assign for custom reporting")
+ group_id = fields.Many2one('account.group', compute='_compute_account_group', store=True, readonly=True)
+ root_id = fields.Many2one('account.root', compute='_compute_account_root', store=True)
+ allowed_journal_ids = fields.Many2many('account.journal', string="Allowed Journals", help="Define in which journals this account can be used. If empty, can be used in all journals.")
+
+ opening_debit = fields.Monetary(string="Opening Debit", compute='_compute_opening_debit_credit', inverse='_set_opening_debit', help="Opening debit value for this account.")
+ opening_credit = fields.Monetary(string="Opening Credit", compute='_compute_opening_debit_credit', inverse='_set_opening_credit', help="Opening credit value for this account.")
+ opening_balance = fields.Monetary(string="Opening Balance", compute='_compute_opening_debit_credit', help="Opening balance value for this account.")
+
+ is_off_balance = fields.Boolean(compute='_compute_is_off_balance', default=False, store=True, readonly=True)
+
+ _sql_constraints = [
+ ('code_company_uniq', 'unique (code,company_id)', 'The code of the account must be unique per company !')
+ ]
+
+ @api.constrains('reconcile', 'internal_group', 'tax_ids')
+ def _constrains_reconcile(self):
+ for record in self:
+ if record.internal_group == 'off_balance':
+ if record.reconcile:
+ raise UserError(_('An Off-Balance account can not be reconcilable'))
+ if record.tax_ids:
+ raise UserError(_('An Off-Balance account can not have taxes'))
+
+ @api.constrains('allowed_journal_ids')
+ def _constrains_allowed_journal_ids(self):
+ self.env['account.move.line'].flush(['account_id', 'journal_id'])
+ self.flush(['allowed_journal_ids'])
+ self._cr.execute("""
+ SELECT aml.id
+ FROM account_move_line aml
+ WHERE aml.account_id in %s
+ AND EXISTS (SELECT 1 FROM account_account_account_journal_rel WHERE account_account_id = aml.account_id)
+ AND NOT EXISTS (SELECT 1 FROM account_account_account_journal_rel WHERE account_account_id = aml.account_id AND account_journal_id = aml.journal_id)
+ """, [tuple(self.ids)])
+ ids = self._cr.fetchall()
+ if ids:
+ raise ValidationError(_('Some journal items already exist with this account but in other journals than the allowed ones.'))
+
+ @api.constrains('currency_id')
+ def _check_journal_consistency(self):
+ ''' Ensure the currency set on the journal is the same as the currency set on the
+ linked accounts.
+ '''
+ if not self:
+ return
+
+ self.env['account.account'].flush(['currency_id'])
+ self.env['account.journal'].flush([
+ 'currency_id',
+ 'default_account_id',
+ 'payment_debit_account_id',
+ 'payment_credit_account_id',
+ 'suspense_account_id',
+ ])
+ self._cr.execute('''
+ SELECT account.id, journal.id
+ FROM account_account account
+ JOIN res_company company ON company.id = account.company_id
+ JOIN account_journal journal ON
+ journal.default_account_id = account.id
+ WHERE account.id IN %s
+ AND journal.type IN ('bank', 'cash')
+ AND journal.currency_id IS NOT NULL
+ AND journal.currency_id != company.currency_id
+ AND account.currency_id != journal.currency_id
+ ''', [tuple(self.ids)])
+ res = self._cr.fetchone()
+ if res:
+ account = self.env['account.account'].browse(res[0])
+ journal = self.env['account.journal'].browse(res[1])
+ raise ValidationError(_(
+ "The foreign currency set on the journal '%(journal)s' and the account '%(account)s' must be the same.",
+ journal=journal.display_name,
+ account=account.display_name
+ ))
+
+ @api.constrains('company_id')
+ def _check_company_consistency(self):
+ if not self:
+ return
+
+ self.flush(['company_id'])
+ self._cr.execute('''
+ SELECT line.id
+ FROM account_move_line line
+ JOIN account_account account ON account.id = line.account_id
+ WHERE line.account_id IN %s
+ AND line.company_id != account.company_id
+ ''', [tuple(self.ids)])
+ if self._cr.fetchone():
+ raise UserError(_("You can't change the company of your account since there are some journal items linked to it."))
+
+ @api.constrains('user_type_id')
+ def _check_user_type_id_sales_purchase_journal(self):
+ if not self:
+ return
+
+ self.flush(['user_type_id'])
+ self._cr.execute('''
+ SELECT account.id
+ FROM account_account account
+ JOIN account_account_type acc_type ON account.user_type_id = acc_type.id
+ JOIN account_journal journal ON journal.default_account_id = account.id
+ WHERE account.id IN %s
+ AND acc_type.type IN ('receivable', 'payable')
+ AND journal.type IN ('sale', 'purchase')
+ LIMIT 1;
+ ''', [tuple(self.ids)])
+
+ if self._cr.fetchone():
+ raise ValidationError(_("The account is already in use in a 'sale' or 'purchase' journal. This means that the account's type couldn't be 'receivable' or 'payable'."))
+
+ @api.constrains('reconcile')
+ def _check_used_as_journal_default_debit_credit_account(self):
+ accounts = self.filtered(lambda a: not a.reconcile)
+ if not accounts:
+ return
+
+ self.flush(['reconcile'])
+ self._cr.execute('''
+ SELECT journal.id
+ FROM account_journal journal
+ WHERE journal.payment_credit_account_id in %(credit_account)s
+ OR journal.payment_debit_account_id in %(debit_account)s ;
+ ''', {
+ 'credit_account': tuple(accounts.ids),
+ 'debit_account': tuple(accounts.ids)
+ })
+
+ rows = self._cr.fetchall()
+ if rows:
+ journals = self.env['account.journal'].browse([r[0] for r in rows])
+ raise ValidationError(_(
+ "This account is configured in %(journal_names)s journal(s) (ids %(journal_ids)s) as payment debit or credit account. This means that this account's type should be reconcilable.",
+ journal_names=journals.mapped('display_name'),
+ journal_ids=journals.ids
+ ))
+
+ @api.depends('code')
+ def _compute_account_root(self):
+ # this computes the first 2 digits of the account.
+ # This field should have been a char, but the aim is to use it in a side panel view with hierarchy, and it's only supported by many2one fields so far.
+ # So instead, we make it a many2one to a psql view with what we need as records.
+ for record in self:
+ record.root_id = (ord(record.code[0]) * 1000 + ord(record.code[1:2] or '\x00')) if record.code else False
+
+ @api.depends('code')
+ def _compute_account_group(self):
+ if self.ids:
+ self.env['account.group']._adapt_accounts_for_account_groups(self)
+ else:
+ self.group_id = False
+
+ def _search_used(self, operator, value):
+ if operator not in ['=', '!='] or not isinstance(value, bool):
+ raise UserError(_('Operation not supported'))
+ if operator != '=':
+ value = not value
+ self._cr.execute("""
+ SELECT id FROM account_account account
+ WHERE EXISTS (SELECT * FROM account_move_line aml WHERE aml.account_id = account.id LIMIT 1)
+ """)
+ return [('id', 'in' if value else 'not in', [r[0] for r in self._cr.fetchall()])]
+
+ def _compute_used(self):
+ ids = set(self._search_used('=', True)[0][2])
+ for record in self:
+ record.used = record.id in ids
+
+ @api.model
+ def _search_new_account_code(self, company, digits, prefix):
+ for num in range(1, 10000):
+ new_code = str(prefix.ljust(digits - 1, '0')) + str(num)
+ rec = self.search([('code', '=', new_code), ('company_id', '=', company.id)], limit=1)
+ if not rec:
+ return new_code
+ raise UserError(_('Cannot generate an unused account code.'))
+
+ def _compute_opening_debit_credit(self):
+ self.opening_debit = 0
+ self.opening_credit = 0
+ self.opening_balance = 0
+ if not self.ids:
+ return
+ self.env.cr.execute("""
+ SELECT line.account_id,
+ SUM(line.balance) AS balance,
+ SUM(line.debit) AS debit,
+ SUM(line.credit) AS credit
+ FROM account_move_line line
+ JOIN res_company comp ON comp.id = line.company_id
+ WHERE line.move_id = comp.account_opening_move_id
+ AND line.account_id IN %s
+ GROUP BY line.account_id
+ """, [tuple(self.ids)])
+ result = {r['account_id']: r for r in self.env.cr.dictfetchall()}
+ for record in self:
+ res = result.get(record.id) or {'debit': 0, 'credit': 0, 'balance': 0}
+ record.opening_debit = res['debit']
+ record.opening_credit = res['credit']
+ record.opening_balance = res['balance']
+
+ @api.depends('internal_group')
+ def _compute_is_off_balance(self):
+ for account in self:
+ account.is_off_balance = account.internal_group == "off_balance"
+
+ def _set_opening_debit(self):
+ for record in self:
+ record._set_opening_debit_credit(record.opening_debit, 'debit')
+
+ def _set_opening_credit(self):
+ for record in self:
+ record._set_opening_debit_credit(record.opening_credit, 'credit')
+
+ def _set_opening_debit_credit(self, amount, field):
+ """ Generic function called by both opening_debit and opening_credit's
+ inverse function. 'Amount' parameter is the value to be set, and field
+ either 'debit' or 'credit', depending on which one of these two fields
+ got assigned.
+ """
+ self.company_id.create_op_move_if_non_existant()
+ opening_move = self.company_id.account_opening_move_id
+
+ if opening_move.state == 'draft':
+ # check whether we should create a new move line or modify an existing one
+ account_op_lines = self.env['account.move.line'].search([('account_id', '=', self.id),
+ ('move_id','=', opening_move.id),
+ (field,'!=', False),
+ (field,'!=', 0.0)]) # 0.0 condition important for import
+
+ if account_op_lines:
+ op_aml_debit = sum(account_op_lines.mapped('debit'))
+ op_aml_credit = sum(account_op_lines.mapped('credit'))
+
+ # There might be more than one line on this account if the opening entry was manually edited
+ # If so, we need to merge all those lines into one before modifying its balance
+ opening_move_line = account_op_lines[0]
+ if len(account_op_lines) > 1:
+ merge_write_cmd = [(1, opening_move_line.id, {'debit': op_aml_debit, 'credit': op_aml_credit, 'partner_id': None ,'name': _("Opening balance")})]
+ unlink_write_cmd = [(2, line.id) for line in account_op_lines[1:]]
+ opening_move.write({'line_ids': merge_write_cmd + unlink_write_cmd})
+
+ if amount:
+ # modify the line
+ opening_move_line.with_context(check_move_validity=False)[field] = amount
+ else:
+ # delete the line (no need to keep a line with value = 0)
+ opening_move_line.with_context(check_move_validity=False).unlink()
+
+ elif amount:
+ # create a new line, as none existed before
+ self.env['account.move.line'].with_context(check_move_validity=False).create({
+ 'name': _('Opening balance'),
+ field: amount,
+ 'move_id': opening_move.id,
+ 'account_id': self.id,
+ })
+
+ # Then, we automatically balance the opening move, to make sure it stays valid
+ if not 'import_file' in self.env.context:
+ # When importing a file, avoid recomputing the opening move for each account and do it at the end, for better performances
+ self.company_id._auto_balance_opening_move()
+
+ @api.model
+ def default_get(self, default_fields):
+ """If we're creating a new account through a many2one, there are chances that we typed the account code
+ instead of its name. In that case, switch both fields values.
+ """
+ if 'name' not in default_fields and 'code' not in default_fields:
+ return super().default_get(default_fields)
+ default_name = self._context.get('default_name')
+ default_code = self._context.get('default_code')
+ if default_name and not default_code:
+ try:
+ default_code = int(default_name)
+ except ValueError:
+ pass
+ if default_code:
+ default_name = False
+ contextual_self = self.with_context(default_name=default_name, default_code=default_code)
+ return super(AccountAccount, contextual_self).default_get(default_fields)
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ args = args or []
+ domain = []
+ if name:
+ domain = ['|', ('code', '=ilike', name.split(' ')[0] + '%'), ('name', operator, name)]
+ if operator in expression.NEGATIVE_TERM_OPERATORS:
+ domain = ['&', '!'] + domain[1:]
+ return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
+
+ @api.onchange('user_type_id')
+ def _onchange_user_type_id(self):
+ self.reconcile = self.internal_type in ('receivable', 'payable')
+ if self.internal_type == 'liquidity':
+ self.reconcile = False
+ elif self.internal_group == 'off_balance':
+ self.reconcile = False
+ self.tax_ids = False
+ elif self.internal_group == 'income' and not self.tax_ids:
+ self.tax_ids = self.company_id.account_sale_tax_id
+ elif self.internal_group == 'expense' and not self.tax_ids:
+ self.tax_ids = self.company_id.account_purchase_tax_id
+
+ def name_get(self):
+ result = []
+ for account in self:
+ name = account.code + ' ' + account.name
+ result.append((account.id, name))
+ return result
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ default = dict(default or {})
+ if default.get('code', False):
+ return super(AccountAccount, self).copy(default)
+ try:
+ default['code'] = (str(int(self.code) + 10) or '').zfill(len(self.code))
+ default.setdefault('name', _("%s (copy)") % (self.name or ''))
+ while self.env['account.account'].search([('code', '=', default['code']),
+ ('company_id', '=', default.get('company_id', False) or self.company_id.id)], limit=1):
+ default['code'] = (str(int(default['code']) + 10) or '')
+ default['name'] = _("%s (copy)") % (self.name or '')
+ except ValueError:
+ default['code'] = _("%s (copy)") % (self.code or '')
+ default['name'] = self.name
+ return super(AccountAccount, self).copy(default)
+
+ @api.model
+ def load(self, fields, data):
+ """ Overridden for better performances when importing a list of account
+ with opening debit/credit. In that case, the auto-balance is postpone
+ until the whole file has been imported.
+ """
+ rslt = super(AccountAccount, self).load(fields, data)
+
+ if 'import_file' in self.env.context:
+ companies = self.search([('id', 'in', rslt['ids'])]).mapped('company_id')
+ for company in companies:
+ company._auto_balance_opening_move()
+ return rslt
+
+ def _toggle_reconcile_to_true(self):
+ '''Toggle the `reconcile´ boolean from False -> True
+
+ Note that: lines with debit = credit = amount_currency = 0 are set to `reconciled´ = True
+ '''
+ if not self.ids:
+ return None
+ query = """
+ UPDATE account_move_line SET
+ reconciled = CASE WHEN debit = 0 AND credit = 0 AND amount_currency = 0
+ THEN true ELSE false END,
+ amount_residual = (debit-credit),
+ amount_residual_currency = amount_currency
+ WHERE full_reconcile_id IS NULL and account_id IN %s
+ """
+ self.env.cr.execute(query, [tuple(self.ids)])
+
+ def _toggle_reconcile_to_false(self):
+ '''Toggle the `reconcile´ boolean from True -> False
+
+ Note that it is disallowed if some lines are partially reconciled.
+ '''
+ if not self.ids:
+ return None
+ partial_lines_count = self.env['account.move.line'].search_count([
+ ('account_id', 'in', self.ids),
+ ('full_reconcile_id', '=', False),
+ ('|'),
+ ('matched_debit_ids', '!=', False),
+ ('matched_credit_ids', '!=', False),
+ ])
+ if partial_lines_count > 0:
+ raise UserError(_('You cannot switch an account to prevent the reconciliation '
+ 'if some partial reconciliations are still pending.'))
+ query = """
+ UPDATE account_move_line
+ SET amount_residual = 0, amount_residual_currency = 0
+ WHERE full_reconcile_id IS NULL AND account_id IN %s
+ """
+ self.env.cr.execute(query, [tuple(self.ids)])
+
+ def write(self, vals):
+ # Do not allow changing the company_id when account_move_line already exist
+ if vals.get('company_id', False):
+ move_lines = self.env['account.move.line'].search([('account_id', 'in', self.ids)], limit=1)
+ for account in self:
+ if (account.company_id.id != vals['company_id']) and move_lines:
+ raise UserError(_('You cannot change the owner company of an account that already contains journal items.'))
+ if 'reconcile' in vals:
+ if vals['reconcile']:
+ self.filtered(lambda r: not r.reconcile)._toggle_reconcile_to_true()
+ else:
+ self.filtered(lambda r: r.reconcile)._toggle_reconcile_to_false()
+
+ if vals.get('currency_id'):
+ for account in self:
+ if self.env['account.move.line'].search_count([('account_id', '=', account.id), ('currency_id', 'not in', (False, vals['currency_id']))]):
+ raise UserError(_('You cannot set a currency on this account as it already has some journal entries having a different foreign currency.'))
+
+ return super(AccountAccount, self).write(vals)
+
+ def unlink(self):
+ if self.env['account.move.line'].search([('account_id', 'in', self.ids)], limit=1):
+ raise UserError(_('You cannot perform this action on an account that contains journal items.'))
+ #Checking whether the account is set as a property to any Partner or not
+ values = ['account.account,%s' % (account_id,) for account_id in self.ids]
+ partner_prop_acc = self.env['ir.property'].sudo().search([('value_reference', 'in', values)], limit=1)
+ if partner_prop_acc:
+ account_name = partner_prop_acc.get_by_record().display_name
+ raise UserError(
+ _('You cannot remove/deactivate the account %s which is set on a customer or vendor.', account_name)
+ )
+ return super(AccountAccount, self).unlink()
+
+ def action_read_account(self):
+ self.ensure_one()
+ return {
+ 'name': self.display_name,
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.account',
+ 'res_id': self.id,
+ }
+
+ def action_duplicate_accounts(self):
+ for account in self.browse(self.env.context['active_ids']):
+ account.copy()
+
+
+class AccountGroup(models.Model):
+ _name = "account.group"
+ _description = 'Account Group'
+ _parent_store = True
+ _order = 'code_prefix_start'
+
+ parent_id = fields.Many2one('account.group', index=True, ondelete='cascade', readonly=True)
+ parent_path = fields.Char(index=True)
+ name = fields.Char(required=True)
+ code_prefix_start = fields.Char()
+ code_prefix_end = fields.Char()
+ company_id = fields.Many2one('res.company', required=True, readonly=True, default=lambda self: self.env.company)
+
+ _sql_constraints = [
+ (
+ 'check_length_prefix',
+ 'CHECK(char_length(COALESCE(code_prefix_start, \'\')) = char_length(COALESCE(code_prefix_end, \'\')))',
+ 'The length of the starting and the ending code prefix must be the same'
+ ),
+ ]
+
+ @api.onchange('code_prefix_start')
+ def _onchange_code_prefix_start(self):
+ if not self.code_prefix_end or self.code_prefix_end < self.code_prefix_start:
+ self.code_prefix_end = self.code_prefix_start
+
+ @api.onchange('code_prefix_end')
+ def _onchange_code_prefix_end(self):
+ if not self.code_prefix_start or self.code_prefix_start > self.code_prefix_end:
+ self.code_prefix_start = self.code_prefix_end
+
+ def name_get(self):
+ result = []
+ for group in self:
+ prefix = group.code_prefix_start and str(group.code_prefix_start)
+ if prefix and group.code_prefix_end != group.code_prefix_start:
+ prefix += '-' + str(group.code_prefix_end)
+ name = (prefix and (prefix + ' ') or '') + group.name
+ result.append((group.id, name))
+ return result
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ args = args or []
+ if operator == 'ilike' and not (name or '').strip():
+ domain = []
+ else:
+ criteria_operator = ['|'] if operator not in expression.NEGATIVE_TERM_OPERATORS else ['&', '!']
+ domain = criteria_operator + [('code_prefix_start', '=ilike', name + '%'), ('name', operator, name)]
+ return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
+
+ @api.constrains('code_prefix_start', 'code_prefix_end')
+ def _constraint_prefix_overlap(self):
+ self.env['account.group'].flush()
+ query = """
+ SELECT other.id FROM account_group this
+ JOIN account_group other
+ ON char_length(other.code_prefix_start) = char_length(this.code_prefix_start)
+ AND other.id != this.id
+ AND other.company_id = this.company_id
+ AND (
+ other.code_prefix_start <= this.code_prefix_start AND this.code_prefix_start <= other.code_prefix_end
+ OR
+ other.code_prefix_start >= this.code_prefix_start AND this.code_prefix_end >= other.code_prefix_start
+ )
+ WHERE this.id IN %(ids)s
+ """
+ self.env.cr.execute(query, {'ids': tuple(self.ids)})
+ res = self.env.cr.fetchall()
+ if res:
+ raise ValidationError(_('Account Groups with the same granularity can\'t overlap'))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if 'code_prefix_start' in vals and not vals.get('code_prefix_end'):
+ vals['code_prefix_end'] = vals['code_prefix_start']
+ res_ids = super(AccountGroup, self).create(vals_list)
+ res_ids._adapt_accounts_for_account_groups()
+ res_ids._adapt_parent_account_group()
+ return res_ids
+
+ def write(self, vals):
+ res = super(AccountGroup, self).write(vals)
+ if 'code_prefix_start' in vals or 'code_prefix_end' in vals:
+ self._adapt_accounts_for_account_groups()
+ self._adapt_parent_account_group()
+ return res
+
+ def unlink(self):
+ for record in self:
+ account_ids = self.env['account.account'].search([('group_id', '=', record.id)])
+ account_ids.write({'group_id': record.parent_id.id})
+
+ children_ids = self.env['account.group'].search([('parent_id', '=', record.id)])
+ children_ids.write({'parent_id': record.parent_id.id})
+ super(AccountGroup, self).unlink()
+
+ def _adapt_accounts_for_account_groups(self, account_ids=None):
+ """Ensure consistency between accounts and account groups.
+
+ Find and set the most specific group matching the code of the account.
+ The most specific is the one with the longest prefixes and with the starting
+ prefix being smaller than the account code and the ending prefix being greater.
+ """
+ if not self and not account_ids:
+ return
+ self.env['account.group'].flush(self.env['account.group']._fields)
+ self.env['account.account'].flush(self.env['account.account']._fields)
+ query = """
+ WITH relation AS (
+ SELECT DISTINCT FIRST_VALUE(agroup.id) OVER (PARTITION BY account.id ORDER BY char_length(agroup.code_prefix_start) DESC, agroup.id) AS group_id,
+ account.id AS account_id
+ FROM account_group agroup
+ JOIN account_account account
+ ON agroup.code_prefix_start <= LEFT(account.code, char_length(agroup.code_prefix_start))
+ AND agroup.code_prefix_end >= LEFT(account.code, char_length(agroup.code_prefix_end))
+ AND agroup.company_id = account.company_id
+ WHERE account.company_id IN %(company_ids)s {where_account}
+ )
+ UPDATE account_account account
+ SET group_id = relation.group_id
+ FROM relation
+ WHERE relation.account_id = account.id;
+ """.format(
+ where_account=account_ids and 'AND account.id IN %(account_ids)s' or ''
+ )
+ self.env.cr.execute(query, {'company_ids': tuple((self.company_id or account_ids.company_id).ids), 'account_ids': account_ids and tuple(account_ids.ids)})
+ self.env['account.account'].invalidate_cache(fnames=['group_id'])
+
+ def _adapt_parent_account_group(self):
+ """Ensure consistency of the hierarchy of account groups.
+
+ Find and set the most specific parent for each group.
+ The most specific is the one with the longest prefixes and with the starting
+ prefix being smaller than the child prefixes and the ending prefix being greater.
+ """
+ if not self:
+ return
+ self.env['account.group'].flush(self.env['account.group']._fields)
+ query = """
+ WITH relation AS (
+ SELECT DISTINCT FIRST_VALUE(parent.id) OVER (PARTITION BY child.id ORDER BY child.id, char_length(parent.code_prefix_start) DESC) AS parent_id,
+ child.id AS child_id
+ FROM account_group parent
+ JOIN account_group child
+ ON char_length(parent.code_prefix_start) < char_length(child.code_prefix_start)
+ AND parent.code_prefix_start <= LEFT(child.code_prefix_start, char_length(parent.code_prefix_start))
+ AND parent.code_prefix_end >= LEFT(child.code_prefix_end, char_length(parent.code_prefix_end))
+ AND parent.id != child.id
+ AND parent.company_id = child.company_id
+ WHERE child.company_id IN %(company_ids)s
+ )
+ UPDATE account_group child
+ SET parent_id = relation.parent_id
+ FROM relation
+ WHERE child.id = relation.child_id;
+ """
+ self.env.cr.execute(query, {'company_ids': tuple(self.company_id.ids)})
+ self.env['account.group'].invalidate_cache(fnames=['parent_id'])
+ self.env['account.group'].search([('company_id', 'in', self.company_id.ids)])._parent_store_update()
+
+
+class AccountRoot(models.Model):
+ _name = 'account.root'
+ _description = 'Account codes first 2 digits'
+ _auto = False
+
+ name = fields.Char()
+ parent_id = fields.Many2one('account.root')
+ company_id = fields.Many2one('res.company')
+
+ def init(self):
+ tools.drop_view_if_exists(self.env.cr, self._table)
+ self.env.cr.execute('''
+ CREATE OR REPLACE VIEW %s AS (
+ SELECT DISTINCT ASCII(code) * 1000 + ASCII(SUBSTRING(code,2,1)) AS id,
+ LEFT(code,2) AS name,
+ ASCII(code) AS parent_id,
+ company_id
+ FROM account_account WHERE code IS NOT NULL
+ UNION ALL
+ SELECT DISTINCT ASCII(code) AS id,
+ LEFT(code,1) AS name,
+ NULL::int AS parent_id,
+ company_id
+ FROM account_account WHERE code IS NOT NULL
+ )''' % (self._table,)
+ )
diff --git a/addons/account/models/account_account_tag.py b/addons/account/models/account_account_tag.py
new file mode 100644
index 00000000..ed75b28c
--- /dev/null
+++ b/addons/account/models/account_account_tag.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class AccountAccountTag(models.Model):
+ _name = 'account.account.tag'
+ _description = 'Account Tag'
+
+ name = fields.Char('Tag Name', required=True)
+ applicability = fields.Selection([('accounts', 'Accounts'), ('taxes', 'Taxes')], required=True, default='accounts')
+ color = fields.Integer('Color Index')
+ active = fields.Boolean(default=True, help="Set active to false to hide the Account Tag without removing it.")
+ tax_report_line_ids = fields.Many2many(string="Tax Report Lines", comodel_name='account.tax.report.line', relation='account_tax_report_line_tags_rel', help="The tax report lines using this tag")
+ tax_negate = fields.Boolean(string="Negate Tax Balance", help="Check this box to negate the absolute value of the balance of the lines associated with this tag in tax report computation.")
+ country_id = fields.Many2one(string="Country", comodel_name='res.country', help="Country for which this tag is available, when applied on taxes.")
+
+ @api.model
+ def _get_tax_tags(self, tag_name, country_id):
+ """ Returns all the tax tags corresponding to the tag name given in parameter
+ in the specified country.
+ """
+ escaped_tag_name = tag_name.replace('\\', '\\\\').replace('%', '\%').replace('_', '\_')
+ return self.env['account.account.tag'].search([('name', '=like', '_' + escaped_tag_name), ('country_id', '=', country_id), ('applicability', '=', 'taxes')])
+
+ @api.constrains('country_id', 'applicability')
+ def _validate_tag_country(self):
+ for record in self:
+ if record.applicability == 'taxes' and not record.country_id:
+ raise ValidationError(_("A tag defined to be used on taxes must always have a country set."))
diff --git a/addons/account/models/account_analytic_default.py b/addons/account/models/account_analytic_default.py
new file mode 100644
index 00000000..5b4e5a09
--- /dev/null
+++ b/addons/account/models/account_analytic_default.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class AccountAnalyticDefault(models.Model):
+ _name = "account.analytic.default"
+ _description = "Analytic Distribution"
+ _rec_name = "analytic_id"
+ _order = "sequence"
+
+ sequence = fields.Integer(string='Sequence', help="Gives the sequence order when displaying a list of analytic distribution")
+ analytic_id = fields.Many2one('account.analytic.account', string='Analytic Account')
+ analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags')
+ product_id = fields.Many2one('product.product', string='Product', ondelete='cascade', help="Select a product which will use analytic account specified in analytic default (e.g. create new customer invoice or Sales order if we select this product, it will automatically take this as an analytic account)")
+ partner_id = fields.Many2one('res.partner', string='Partner', ondelete='cascade', help="Select a partner which will use analytic account specified in analytic default (e.g. create new customer invoice or Sales order if we select this partner, it will automatically take this as an analytic account)")
+ account_id = fields.Many2one('account.account', string='Account', ondelete='cascade', help="Select an accounting account which will use analytic account specified in analytic default (e.g. create new customer invoice or Sales order if we select this account, it will automatically take this as an analytic account)")
+ user_id = fields.Many2one('res.users', string='User', ondelete='cascade', help="Select a user which will use analytic account specified in analytic default.")
+ company_id = fields.Many2one('res.company', string='Company', ondelete='cascade', help="Select a company which will use analytic account specified in analytic default (e.g. create new customer invoice or Sales order if we select this company, it will automatically take this as an analytic account)")
+ date_start = fields.Date(string='Start Date', help="Default start date for this Analytic Account.")
+ date_stop = fields.Date(string='End Date', help="Default end date for this Analytic Account.")
+
+ @api.constrains('analytic_id', 'analytic_tag_ids')
+ def _check_account_or_tags(self):
+ if any(not default.analytic_id and not default.analytic_tag_ids for default in self):
+ raise ValidationError(_('An analytic default requires at least an analytic account or an analytic tag.'))
+
+ @api.model
+ def account_get(self, product_id=None, partner_id=None, account_id=None, user_id=None, date=None, company_id=None):
+ domain = []
+ if product_id:
+ domain += ['|', ('product_id', '=', product_id)]
+ domain += [('product_id', '=', False)]
+ if partner_id:
+ domain += ['|', ('partner_id', '=', partner_id)]
+ domain += [('partner_id', '=', False)]
+ if account_id:
+ domain += ['|', ('account_id', '=', account_id)]
+ domain += [('account_id', '=', False)]
+ if company_id:
+ domain += ['|', ('company_id', '=', company_id)]
+ domain += [('company_id', '=', False)]
+ if user_id:
+ domain += ['|', ('user_id', '=', user_id)]
+ domain += [('user_id', '=', False)]
+ if date:
+ domain += ['|', ('date_start', '<=', date), ('date_start', '=', False)]
+ domain += ['|', ('date_stop', '>=', date), ('date_stop', '=', False)]
+ best_index = -1
+ res = self.env['account.analytic.default']
+ for rec in self.search(domain):
+ index = 0
+ if rec.product_id: index += 1
+ if rec.partner_id: index += 1
+ if rec.account_id: index += 1
+ if rec.company_id: index += 1
+ if rec.user_id: index += 1
+ if rec.date_start: index += 1
+ if rec.date_stop: index += 1
+ if index > best_index:
+ res = rec
+ best_index = index
+ return res
diff --git a/addons/account/models/account_analytic_line.py b/addons/account/models/account_analytic_line.py
new file mode 100644
index 00000000..80e35b80
--- /dev/null
+++ b/addons/account/models/account_analytic_line.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+
+class AccountAnalyticAccount(models.Model):
+ _inherit = 'account.analytic.account'
+
+ @api.constrains('company_id')
+ def _check_company_consistency(self):
+ analytic_accounts = self.filtered('company_id')
+
+ if not analytic_accounts:
+ return
+
+ self.flush(['company_id'])
+ self._cr.execute('''
+ SELECT line.id
+ FROM account_move_line line
+ JOIN account_analytic_account account ON account.id = line.analytic_account_id
+ WHERE line.analytic_account_id IN %s
+ AND line.company_id != account.company_id
+ ''', [tuple(analytic_accounts.ids)])
+
+ if self._cr.fetchone():
+ raise UserError(_("You can't set a different company on your analytic account since there are some journal items linked to it."))
+
+
+class AccountAnalyticTag(models.Model):
+ _inherit = 'account.analytic.tag'
+
+ @api.constrains('company_id')
+ def _check_company_consistency(self):
+ analytic_tags = self.filtered('company_id')
+
+ if not analytic_tags:
+ return
+
+ self.flush(['company_id'])
+ self._cr.execute('''
+ SELECT line.id
+ FROM account_analytic_tag_account_move_line_rel tag_rel
+ JOIN account_analytic_tag tag ON tag.id = tag_rel.account_analytic_tag_id
+ JOIN account_move_line line ON line.id = tag_rel.account_move_line_id
+ WHERE tag_rel.account_analytic_tag_id IN %s
+ AND line.company_id != tag.company_id
+ ''', [tuple(analytic_tags.ids)])
+
+ if self._cr.fetchone():
+ raise UserError(_("You can't set a different company on your analytic tags since there are some journal items linked to it."))
+
+
+class AccountAnalyticLine(models.Model):
+ _inherit = 'account.analytic.line'
+ _description = 'Analytic Line'
+
+ product_id = fields.Many2one('product.product', string='Product', check_company=True)
+ general_account_id = fields.Many2one('account.account', string='Financial Account', ondelete='restrict', readonly=True,
+ related='move_id.account_id', store=True, domain="[('deprecated', '=', False), ('company_id', '=', company_id)]",
+ compute_sudo=True)
+ move_id = fields.Many2one('account.move.line', string='Journal Item', ondelete='cascade', index=True, check_company=True)
+ code = fields.Char(size=8)
+ ref = fields.Char(string='Ref.')
+
+ @api.onchange('product_id', 'product_uom_id', 'unit_amount', 'currency_id')
+ def on_change_unit_amount(self):
+ if not self.product_id:
+ return {}
+
+ result = 0.0
+ prod_accounts = self.product_id.product_tmpl_id.with_company(self.company_id)._get_product_accounts()
+ unit = self.product_uom_id
+ account = prod_accounts['expense']
+ if not unit or self.product_id.uom_po_id.category_id.id != unit.category_id.id:
+ unit = self.product_id.uom_po_id
+
+ # Compute based on pricetype
+ amount_unit = self.product_id.price_compute('standard_price', uom=unit)[self.product_id.id]
+ amount = amount_unit * self.unit_amount or 0.0
+ result = (self.currency_id.round(amount) if self.currency_id else round(amount, 2)) * -1
+ self.amount = result
+ self.general_account_id = account
+ self.product_uom_id = unit
+
+ @api.model
+ def view_header_get(self, view_id, view_type):
+ if self.env.context.get('account_id'):
+ return _(
+ "Entries: %(account)s",
+ account=self.env['account.analytic.account'].browse(self.env.context['account_id']).name
+ )
+ return super().view_header_get(view_id, view_type)
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
new file mode 100644
index 00000000..203b4b2b
--- /dev/null
+++ b/addons/account/models/account_bank_statement.py
@@ -0,0 +1,1305 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.osv import expression
+from odoo.tools import float_is_zero
+from odoo.tools import float_compare, float_round, float_repr
+from odoo.tools.misc import formatLang, format_date
+from odoo.exceptions import UserError, ValidationError
+
+import time
+import math
+import base64
+import re
+
+
+class AccountCashboxLine(models.Model):
+ """ Cash Box Details """
+ _name = 'account.cashbox.line'
+ _description = 'CashBox Line'
+ _rec_name = 'coin_value'
+ _order = 'coin_value'
+
+ @api.depends('coin_value', 'number')
+ def _sub_total(self):
+ """ Calculates Sub total"""
+ for cashbox_line in self:
+ cashbox_line.subtotal = cashbox_line.coin_value * cashbox_line.number
+
+ coin_value = fields.Float(string='Coin/Bill Value', required=True, digits=0)
+ number = fields.Integer(string='#Coins/Bills', help='Opening Unit Numbers')
+ subtotal = fields.Float(compute='_sub_total', string='Subtotal', digits=0, readonly=True)
+ cashbox_id = fields.Many2one('account.bank.statement.cashbox', string="Cashbox")
+ currency_id = fields.Many2one('res.currency', related='cashbox_id.currency_id')
+
+
+class AccountBankStmtCashWizard(models.Model):
+ """
+ Account Bank Statement popup that allows entering cash details.
+ """
+ _name = 'account.bank.statement.cashbox'
+ _description = 'Bank Statement Cashbox'
+ _rec_name = 'id'
+
+ cashbox_lines_ids = fields.One2many('account.cashbox.line', 'cashbox_id', string='Cashbox Lines')
+ start_bank_stmt_ids = fields.One2many('account.bank.statement', 'cashbox_start_id')
+ end_bank_stmt_ids = fields.One2many('account.bank.statement', 'cashbox_end_id')
+ total = fields.Float(compute='_compute_total')
+ currency_id = fields.Many2one('res.currency', compute='_compute_currency')
+
+ @api.depends('start_bank_stmt_ids', 'end_bank_stmt_ids')
+ def _compute_currency(self):
+ for cashbox in self:
+ cashbox.currency_id = False
+ if cashbox.end_bank_stmt_ids:
+ cashbox.currency_id = cashbox.end_bank_stmt_ids[0].currency_id
+ if cashbox.start_bank_stmt_ids:
+ cashbox.currency_id = cashbox.start_bank_stmt_ids[0].currency_id
+
+ @api.depends('cashbox_lines_ids', 'cashbox_lines_ids.coin_value', 'cashbox_lines_ids.number')
+ def _compute_total(self):
+ for cashbox in self:
+ cashbox.total = sum([line.subtotal for line in cashbox.cashbox_lines_ids])
+
+ @api.model
+ def default_get(self, fields):
+ vals = super(AccountBankStmtCashWizard, self).default_get(fields)
+ balance = self.env.context.get('balance')
+ statement_id = self.env.context.get('statement_id')
+ if 'start_bank_stmt_ids' in fields and not vals.get('start_bank_stmt_ids') and statement_id and balance == 'start':
+ vals['start_bank_stmt_ids'] = [(6, 0, [statement_id])]
+ if 'end_bank_stmt_ids' in fields and not vals.get('end_bank_stmt_ids') and statement_id and balance == 'close':
+ vals['end_bank_stmt_ids'] = [(6, 0, [statement_id])]
+
+ return vals
+
+ def name_get(self):
+ result = []
+ for cashbox in self:
+ result.append((cashbox.id, str(cashbox.total)))
+ return result
+
+ @api.model_create_multi
+ def create(self, vals):
+ cashboxes = super(AccountBankStmtCashWizard, self).create(vals)
+ cashboxes._validate_cashbox()
+ return cashboxes
+
+ def write(self, vals):
+ res = super(AccountBankStmtCashWizard, self).write(vals)
+ self._validate_cashbox()
+ return res
+
+ def _validate_cashbox(self):
+ for cashbox in self:
+ if cashbox.start_bank_stmt_ids:
+ cashbox.start_bank_stmt_ids.write({'balance_start': cashbox.total})
+ if cashbox.end_bank_stmt_ids:
+ cashbox.end_bank_stmt_ids.write({'balance_end_real': cashbox.total})
+
+
+class AccountBankStmtCloseCheck(models.TransientModel):
+ """
+ Account Bank Statement wizard that check that closing balance is correct.
+ """
+ _name = 'account.bank.statement.closebalance'
+ _description = 'Bank Statement Closing Balance'
+
+ def validate(self):
+ bnk_stmt_id = self.env.context.get('active_id', False)
+ if bnk_stmt_id:
+ self.env['account.bank.statement'].browse(bnk_stmt_id).button_validate()
+ return {'type': 'ir.actions.act_window_close'}
+
+
+class AccountBankStatement(models.Model):
+ _name = "account.bank.statement"
+ _description = "Bank Statement"
+ _order = "date desc, name desc, id desc"
+ _inherit = ['mail.thread', 'sequence.mixin']
+ _check_company_auto = True
+ _sequence_index = "journal_id"
+
+ # Note: the reason why we did 2 separate function with the same dependencies (one for balance_start and one for balance_end_real)
+ # is because if we create a bank statement with a default value for one of the field but not the other, the compute method
+ # won't be called and therefore the other field will have a value of 0 and we don't want that.
+ @api.depends('previous_statement_id', 'previous_statement_id.balance_end_real')
+ def _compute_starting_balance(self):
+ for statement in self:
+ if statement.previous_statement_id.balance_end_real != statement.balance_start:
+ statement.balance_start = statement.previous_statement_id.balance_end_real
+ else:
+ # Need default value
+ statement.balance_start = statement.balance_start or 0.0
+
+ @api.depends('previous_statement_id', 'previous_statement_id.balance_end_real')
+ def _compute_ending_balance(self):
+ latest_statement = self.env['account.bank.statement'].search([('journal_id', '=', self[0].journal_id.id)], limit=1)
+ for statement in self:
+ # recompute balance_end_real in case we are in a bank journal and if we change the
+ # balance_end_real of previous statement as we don't want
+ # holes in case we add a statement in between 2 others statements.
+ # We only do this for the bank journal as we use the balance_end_real in cash
+ # journal for verification and creating cash difference entries so we don't want
+ # to recompute the value in that case
+ if statement.journal_type == 'bank':
+ # If we are on last statement and that statement already has a balance_end_real, don't change the balance_end_real
+ # Otherwise, recompute balance_end_real to prevent holes between statement.
+ if latest_statement.id and statement.id == latest_statement.id and not float_is_zero(statement.balance_end_real, precision_digits=statement.currency_id.decimal_places):
+ statement.balance_end_real = statement.balance_end_real or 0.0
+ else:
+ total_entry_encoding = sum([line.amount for line in statement.line_ids])
+ statement.balance_end_real = statement.previous_statement_id.balance_end_real + total_entry_encoding
+ else:
+ # Need default value
+ statement.balance_end_real = statement.balance_end_real or 0.0
+
+ @api.depends('line_ids', 'balance_start', 'line_ids.amount', 'balance_end_real')
+ def _end_balance(self):
+ for statement in self:
+ statement.total_entry_encoding = sum([line.amount for line in statement.line_ids])
+ statement.balance_end = statement.balance_start + statement.total_entry_encoding
+ statement.difference = statement.balance_end_real - statement.balance_end
+
+ def _is_difference_zero(self):
+ for bank_stmt in self:
+ bank_stmt.is_difference_zero = float_is_zero(bank_stmt.difference, precision_digits=bank_stmt.currency_id.decimal_places)
+
+ @api.depends('journal_id')
+ def _compute_currency(self):
+ for statement in self:
+ statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id
+
+ @api.depends('move_line_ids')
+ def _get_move_line_count(self):
+ for statement in self:
+ statement.move_line_count = len(statement.move_line_ids)
+
+ @api.model
+ def _default_journal(self):
+ journal_type = self.env.context.get('journal_type', False)
+ company_id = self.env.company.id
+ if journal_type:
+ journals = self.env['account.journal'].search([('type', '=', journal_type), ('company_id', '=', company_id)])
+ if journals:
+ return journals[0]
+ return self.env['account.journal']
+
+ @api.depends('balance_start', 'previous_statement_id')
+ def _compute_is_valid_balance_start(self):
+ for bnk in self:
+ bnk.is_valid_balance_start = (
+ bnk.currency_id.is_zero(
+ bnk.balance_start - bnk.previous_statement_id.balance_end_real
+ )
+ if bnk.previous_statement_id
+ else True
+ )
+
+ @api.depends('date', 'journal_id')
+ def _get_previous_statement(self):
+ for st in self:
+ # Search for the previous statement
+ domain = [('date', '<=', st.date), ('journal_id', '=', st.journal_id.id)]
+ # The reason why we have to perform this test is because we have two use case here:
+ # First one is in case we are creating a new record, in that case that new record does
+ # not have any id yet. However if we are updating an existing record, the domain date <= st.date
+ # will find the record itself, so we have to add a condition in the search to ignore self.id
+ if not isinstance(st.id, models.NewId):
+ domain.extend(['|', '&', ('id', '<', st.id), ('date', '=', st.date), '&', ('id', '!=', st.id), ('date', '!=', st.date)])
+ previous_statement = self.search(domain, limit=1)
+ st.previous_statement_id = previous_statement.id
+
+ name = fields.Char(string='Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True)
+ reference = fields.Char(string='External Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True, help="Used to hold the reference of the external mean that created this statement (name of imported file, reference of online synchronization...)")
+ date = fields.Date(required=True, states={'confirm': [('readonly', True)]}, index=True, copy=False, default=fields.Date.context_today)
+ date_done = fields.Datetime(string="Closed On")
+ balance_start = fields.Monetary(string='Starting Balance', states={'confirm': [('readonly', True)]}, compute='_compute_starting_balance', readonly=False, store=True)
+ balance_end_real = fields.Monetary('Ending Balance', states={'confirm': [('readonly', True)]}, compute='_compute_ending_balance', readonly=False, store=True)
+ state = fields.Selection(string='Status', required=True, readonly=True, copy=False, selection=[
+ ('open', 'New'),
+ ('posted', 'Processing'),
+ ('confirm', 'Validated'),
+ ], default='open',
+ help="The current state of your bank statement:"
+ "- New: Fully editable with draft Journal Entries."
+ "- Processing: No longer editable with posted Journal entries, ready for the reconciliation."
+ "- Validated: All lines are reconciled. There is nothing left to process.")
+ currency_id = fields.Many2one('res.currency', compute='_compute_currency', string="Currency")
+ journal_id = fields.Many2one('account.journal', string='Journal', required=True, states={'confirm': [('readonly', True)]}, default=_default_journal, check_company=True)
+ journal_type = fields.Selection(related='journal_id.type', help="Technical field used for usability purposes")
+ company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', store=True, readonly=True,
+ default=lambda self: self.env.company)
+
+ total_entry_encoding = fields.Monetary('Transactions Subtotal', compute='_end_balance', store=True, help="Total of transaction lines.")
+ balance_end = fields.Monetary('Computed Balance', compute='_end_balance', store=True, help='Balance as calculated based on Opening Balance and transaction lines')
+ difference = fields.Monetary(compute='_end_balance', store=True, help="Difference between the computed ending balance and the specified ending balance.")
+
+ line_ids = fields.One2many('account.bank.statement.line', 'statement_id', string='Statement lines', states={'confirm': [('readonly', True)]}, copy=True)
+ move_line_ids = fields.One2many('account.move.line', 'statement_id', string='Entry lines', states={'confirm': [('readonly', True)]})
+ move_line_count = fields.Integer(compute="_get_move_line_count")
+
+ all_lines_reconciled = fields.Boolean(compute='_compute_all_lines_reconciled',
+ help="Technical field indicating if all statement lines are fully reconciled.")
+ user_id = fields.Many2one('res.users', string='Responsible', required=False, default=lambda self: self.env.user)
+ cashbox_start_id = fields.Many2one('account.bank.statement.cashbox', string="Starting Cashbox")
+ cashbox_end_id = fields.Many2one('account.bank.statement.cashbox', string="Ending Cashbox")
+ is_difference_zero = fields.Boolean(compute='_is_difference_zero', string='Is zero', help="Check if difference is zero.")
+ previous_statement_id = fields.Many2one('account.bank.statement', help='technical field to compute starting balance correctly', compute='_get_previous_statement', store=True)
+ is_valid_balance_start = fields.Boolean(string="Is Valid Balance Start", store=True,
+ compute="_compute_is_valid_balance_start",
+ help="Technical field to display a warning message in case starting balance is different than previous ending balance")
+ country_code = fields.Char(related='company_id.country_id.code')
+
+ def write(self, values):
+ res = super(AccountBankStatement, self).write(values)
+ if values.get('date') or values.get('journal'):
+ # If we are changing the date or journal of a bank statement, we have to change its previous_statement_id. This is done
+ # automatically using the compute function, but we also have to change the previous_statement_id of records that were
+ # previously pointing toward us and records that were pointing towards our new previous_statement_id. This is done here
+ # by marking those record as needing to be recomputed.
+ # Note that marking the field is not enough as we also have to recompute all its other fields that are depending on 'previous_statement_id'
+ # hence the need to call modified afterwards.
+ to_recompute = self.search([('previous_statement_id', 'in', self.ids), ('id', 'not in', self.ids), ('journal_id', 'in', self.mapped('journal_id').ids)])
+ if to_recompute:
+ self.env.add_to_compute(self._fields['previous_statement_id'], to_recompute)
+ to_recompute.modified(['previous_statement_id'])
+ next_statements_to_recompute = self.search([('previous_statement_id', 'in', [st.previous_statement_id.id for st in self]), ('id', 'not in', self.ids), ('journal_id', 'in', self.mapped('journal_id').ids)])
+ if next_statements_to_recompute:
+ self.env.add_to_compute(self._fields['previous_statement_id'], next_statements_to_recompute)
+ next_statements_to_recompute.modified(['previous_statement_id'])
+ return res
+
+ @api.model_create_multi
+ def create(self, values):
+ res = super(AccountBankStatement, self).create(values)
+ # Upon bank stmt creation, it is possible that the statement is inserted between two other statements and not at the end
+ # In that case, we have to search for statement that are pointing to the same previous_statement_id as ourselve in order to
+ # change their previous_statement_id to us. This is done by marking the field 'previous_statement_id' to be recomputed for such records.
+ # Note that marking the field is not enough as we also have to recompute all its other fields that are depending on 'previous_statement_id'
+ # hence the need to call modified afterwards.
+ # The reason we are doing this here and not in a compute field is that it is not easy to write dependencies for such field.
+ next_statements_to_recompute = self.search([('previous_statement_id', 'in', [st.previous_statement_id.id for st in res]), ('id', 'not in', res.ids), ('journal_id', 'in', res.journal_id.ids)])
+ if next_statements_to_recompute:
+ self.env.add_to_compute(self._fields['previous_statement_id'], next_statements_to_recompute)
+ next_statements_to_recompute.modified(['previous_statement_id'])
+ return res
+
+ @api.depends('line_ids.is_reconciled')
+ def _compute_all_lines_reconciled(self):
+ for statement in self:
+ statement.all_lines_reconciled = all(st_line.is_reconciled for st_line in statement.line_ids)
+
+ @api.onchange('journal_id')
+ def onchange_journal_id(self):
+ for st_line in self.line_ids:
+ st_line.journal_id = self.journal_id
+ st_line.currency_id = self.journal_id.currency_id or self.company_id.currency_id
+
+ def _check_balance_end_real_same_as_computed(self):
+ ''' Check the balance_end_real (encoded manually by the user) is equals to the balance_end (computed by odoo).
+ In case of a cash statement, the different is set automatically to a profit/loss account.
+ '''
+ for stmt in self:
+ if not stmt.currency_id.is_zero(stmt.difference):
+ if stmt.journal_type == 'cash':
+ st_line_vals = {
+ 'statement_id': stmt.id,
+ 'journal_id': stmt.journal_id.id,
+ 'amount': stmt.difference,
+ 'date': stmt.date,
+ }
+
+ if stmt.difference < 0.0:
+ if not stmt.journal_id.loss_account_id:
+ raise UserError(_('Please go on the %s journal and define a Loss Account. This account will be used to record cash difference.', stmt.journal_id.name))
+
+ st_line_vals['payment_ref'] = _("Cash difference observed during the counting (Loss)")
+ st_line_vals['counterpart_account_id'] = stmt.journal_id.loss_account_id.id
+ else:
+ # statement.difference > 0.0
+ if not stmt.journal_id.profit_account_id:
+ raise UserError(_('Please go on the %s journal and define a Profit Account. This account will be used to record cash difference.', stmt.journal_id.name))
+
+ st_line_vals['payment_ref'] = _("Cash difference observed during the counting (Profit)")
+ st_line_vals['counterpart_account_id'] = stmt.journal_id.profit_account_id.id
+
+ self.env['account.bank.statement.line'].create(st_line_vals)
+ else:
+ balance_end_real = formatLang(self.env, stmt.balance_end_real, currency_obj=stmt.currency_id)
+ balance_end = formatLang(self.env, stmt.balance_end, currency_obj=stmt.currency_id)
+ raise UserError(_(
+ 'The ending balance is incorrect !\nThe expected balance (%(real_balance)s) is different from the computed one (%(computed_balance)s).',
+ real_balance=balance_end_real,
+ computed_balance=balance_end
+ ))
+ return True
+
+ def unlink(self):
+ for statement in self:
+ if statement.state != 'open':
+ raise UserError(_('In order to delete a bank statement, you must first cancel it to delete related journal items.'))
+ # Explicitly unlink bank statement lines so it will check that the related journal entries have been deleted first
+ statement.line_ids.unlink()
+ # Some other bank statements might be link to this one, so in that case we have to switch the previous_statement_id
+ # from that statement to the one linked to this statement
+ next_statement = self.search([('previous_statement_id', '=', statement.id), ('journal_id', '=', statement.journal_id.id)])
+ if next_statement:
+ next_statement.previous_statement_id = statement.previous_statement_id
+ return super(AccountBankStatement, self).unlink()
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('journal_id')
+ def _check_journal(self):
+ for statement in self:
+ if any(st_line.journal_id != statement.journal_id for st_line in statement.line_ids):
+ raise ValidationError(_('The journal of a bank statement line must always be the same as the bank statement one.'))
+
+ def _constrains_date_sequence(self):
+ # Multiple import methods set the name to things that are not sequences:
+ # i.e. Statement from {date1} to {date2}
+ # It makes this constraint not applicable, and it is less needed on bank statements as it
+ # is only an indication and not some thing legal.
+ return
+
+ # -------------------------------------------------------------------------
+ # BUSINESS METHODS
+ # -------------------------------------------------------------------------
+
+ def open_cashbox_id(self):
+ self.ensure_one()
+ context = dict(self.env.context or {})
+ if context.get('balance'):
+ context['statement_id'] = self.id
+ if context['balance'] == 'start':
+ cashbox_id = self.cashbox_start_id.id
+ elif context['balance'] == 'close':
+ cashbox_id = self.cashbox_end_id.id
+ else:
+ cashbox_id = False
+
+ action = {
+ 'name': _('Cash Control'),
+ 'view_mode': 'form',
+ 'res_model': 'account.bank.statement.cashbox',
+ 'view_id': self.env.ref('account.view_account_bnk_stmt_cashbox_footer').id,
+ 'type': 'ir.actions.act_window',
+ 'res_id': cashbox_id,
+ 'context': context,
+ 'target': 'new'
+ }
+
+ return action
+
+ def button_post(self):
+ ''' Move the bank statements from 'draft' to 'posted'. '''
+ if any(statement.state != 'open' for statement in self):
+ raise UserError(_("Only new statements can be posted."))
+
+ self._check_balance_end_real_same_as_computed()
+
+ for statement in self:
+ if not statement.name:
+ statement._set_next_sequence()
+
+ self.write({'state': 'posted'})
+ lines_of_moves_to_post = self.line_ids.filtered(lambda line: line.move_id.state != 'posted')
+ if lines_of_moves_to_post:
+ lines_of_moves_to_post.move_id._post(soft=False)
+
+ def button_validate(self):
+ if any(statement.state != 'posted' or not statement.all_lines_reconciled for statement in self):
+ raise UserError(_('All the account entries lines must be processed in order to validate the statement.'))
+
+ for statement in self:
+
+ # Chatter.
+ statement.message_post(body=_('Statement %s confirmed.', statement.name))
+
+ # Bank statement report.
+ if statement.journal_id.type == 'bank':
+ content, content_type = self.env.ref('account.action_report_account_statement')._render(statement.id)
+ self.env['ir.attachment'].create({
+ 'name': statement.name and _("Bank Statement %s.pdf", statement.name) or _("Bank Statement.pdf"),
+ 'type': 'binary',
+ 'datas': base64.encodebytes(content),
+ 'res_model': statement._name,
+ 'res_id': statement.id
+ })
+
+ self._check_balance_end_real_same_as_computed()
+ self.write({'state': 'confirm', 'date_done': fields.Datetime.now()})
+
+ def button_validate_or_action(self):
+ if self.journal_type == 'cash' and not self.currency_id.is_zero(self.difference):
+ return self.env['ir.actions.act_window']._for_xml_id('account.action_view_account_bnk_stmt_check')
+
+ return self.button_validate()
+
+ def button_reopen(self):
+ ''' Move the bank statements back to the 'open' state. '''
+ if any(statement.state == 'draft' for statement in self):
+ raise UserError(_("Only validated statements can be reset to new."))
+
+ self.write({'state': 'open'})
+ self.line_ids.move_id.button_draft()
+ self.line_ids.button_undo_reconciliation()
+
+ def button_reprocess(self):
+ """Move the bank statements back to the 'posted' state."""
+ if any(statement.state != 'confirm' for statement in self):
+ raise UserError(_("Only Validated statements can be reset to new."))
+
+ self.write({'state': 'posted', 'date_done': False})
+
+ def button_journal_entries(self):
+ return {
+ 'name': _('Journal Entries'),
+ 'view_mode': 'tree,form',
+ 'res_model': 'account.move',
+ 'view_id': False,
+ 'type': 'ir.actions.act_window',
+ 'domain': [('id', 'in', self.line_ids.move_id.ids)],
+ 'context': {
+ 'journal_id': self.journal_id.id,
+ }
+ }
+
+ def _get_last_sequence_domain(self, relaxed=False):
+ self.ensure_one()
+ where_string = "WHERE journal_id = %(journal_id)s AND name != '/'"
+ param = {'journal_id': self.journal_id.id}
+
+ if not relaxed:
+ domain = [('journal_id', '=', self.journal_id.id), ('id', '!=', self.id or self._origin.id), ('name', '!=', False)]
+ previous_name = self.search(domain + [('date', '<', self.date)], order='date desc', limit=1).name
+ if not previous_name:
+ previous_name = self.search(domain, order='date desc', limit=1).name
+ sequence_number_reset = self._deduce_sequence_number_reset(previous_name)
+ if sequence_number_reset == 'year':
+ where_string += " AND date_trunc('year', date) = date_trunc('year', %(date)s) "
+ param['date'] = self.date
+ elif sequence_number_reset == 'month':
+ where_string += " AND date_trunc('month', date) = date_trunc('month', %(date)s) "
+ param['date'] = self.date
+ return where_string, param
+
+ def _get_starting_sequence(self):
+ self.ensure_one()
+ return "%s %s %04d/%02d/00000" % (self.journal_id.code, _('Statement'), self.date.year, self.date.month)
+
+
+class AccountBankStatementLine(models.Model):
+ _name = "account.bank.statement.line"
+ _inherits = {'account.move': 'move_id'}
+ _description = "Bank Statement Line"
+ _order = "statement_id desc, date, sequence, id desc"
+ _check_company_auto = True
+
+ # FIXME: Fields having the same name in both tables are confusing (partner_id & state). We don't change it because:
+ # - It's a mess to track/fix.
+ # - Some fields here could be simplified when the onchanges will be gone in account.move.
+ # Should be improved in the future.
+
+ # == Business fields ==
+ move_id = fields.Many2one(
+ comodel_name='account.move',
+ string='Journal Entry', required=True, readonly=True, ondelete='cascade',
+ check_company=True)
+ statement_id = fields.Many2one(
+ comodel_name='account.bank.statement',
+ string='Statement', index=True, required=True, ondelete='cascade',
+ check_company=True)
+
+ sequence = fields.Integer(index=True, help="Gives the sequence order when displaying a list of bank statement lines.", default=1)
+ account_number = fields.Char(string='Bank Account Number', help="Technical field used to store the bank account number before its creation, upon the line's processing")
+ partner_name = fields.Char(
+ help="This field is used to record the third party name when importing bank statement in electronic format, "
+ "when the partner doesn't exist yet in the database (or cannot be found).")
+ transaction_type = fields.Char(string='Transaction Type')
+ payment_ref = fields.Char(string='Label', required=True)
+ amount = fields.Monetary(currency_field='currency_id')
+ amount_currency = fields.Monetary(currency_field='foreign_currency_id',
+ help="The amount expressed in an optional other currency if it is a multi-currency entry.")
+ foreign_currency_id = fields.Many2one('res.currency', string='Foreign Currency',
+ help="The optional other currency if it is a multi-currency entry.")
+ amount_residual = fields.Float(string="Residual Amount",
+ compute="_compute_is_reconciled",
+ store=True,
+ help="The amount left to be reconciled on this statement line (signed according to its move lines' balance), expressed in its currency. This is a technical field use to speedup the application of reconciliation models.")
+ currency_id = fields.Many2one('res.currency', string='Journal Currency')
+ partner_id = fields.Many2one(
+ comodel_name='res.partner',
+ string='Partner', ondelete='restrict',
+ domain="['|', ('parent_id','=', False), ('is_company','=',True)]",
+ check_company=True)
+ payment_ids = fields.Many2many(
+ comodel_name='account.payment',
+ relation='account_payment_account_bank_statement_line_rel',
+ string='Auto-generated Payments',
+ help="Payments generated during the reconciliation of this bank statement lines.")
+
+ # == Display purpose fields ==
+ is_reconciled = fields.Boolean(string='Is Reconciled', store=True,
+ compute='_compute_is_reconciled',
+ help="Technical field indicating if the statement line is already reconciled.")
+ state = fields.Selection(related='statement_id.state', string='Status', readonly=True)
+ country_code = fields.Char(related='company_id.country_id.code')
+
+ # -------------------------------------------------------------------------
+ # HELPERS
+ # -------------------------------------------------------------------------
+
+ def _seek_for_lines(self):
+ ''' Helper used to dispatch the journal items between:
+ - The lines using the liquidity account.
+ - The lines using the transfer account.
+ - The lines being not in one of the two previous categories.
+ :return: (liquidity_lines, suspense_lines, other_lines)
+ '''
+ liquidity_lines = self.env['account.move.line']
+ suspense_lines = self.env['account.move.line']
+ other_lines = self.env['account.move.line']
+
+ for line in self.move_id.line_ids:
+ if line.account_id == self.journal_id.default_account_id:
+ liquidity_lines += line
+ elif line.account_id == self.journal_id.suspense_account_id:
+ suspense_lines += line
+ else:
+ other_lines += line
+ return liquidity_lines, suspense_lines, other_lines
+
+ @api.model
+ def _prepare_liquidity_move_line_vals(self):
+ ''' Prepare values to create a new account.move.line record corresponding to the
+ liquidity line (having the bank/cash account).
+ :return: The values to create a new account.move.line record.
+ '''
+ self.ensure_one()
+
+ statement = self.statement_id
+ journal = statement.journal_id
+ company_currency = journal.company_id.currency_id
+ journal_currency = journal.currency_id if journal.currency_id != company_currency else False
+
+ if self.foreign_currency_id and journal_currency:
+ currency_id = journal_currency.id
+ if self.foreign_currency_id == company_currency:
+ amount_currency = self.amount
+ balance = self.amount_currency
+ else:
+ amount_currency = self.amount
+ balance = journal_currency._convert(amount_currency, company_currency, journal.company_id, self.date)
+ elif self.foreign_currency_id and not journal_currency:
+ amount_currency = self.amount_currency
+ balance = self.amount
+ currency_id = self.foreign_currency_id.id
+ elif not self.foreign_currency_id and journal_currency:
+ currency_id = journal_currency.id
+ amount_currency = self.amount
+ balance = journal_currency._convert(amount_currency, journal.company_id.currency_id, journal.company_id, self.date)
+ else:
+ currency_id = company_currency.id
+ amount_currency = self.amount
+ balance = self.amount
+
+ return {
+ 'name': self.payment_ref,
+ 'move_id': self.move_id.id,
+ 'partner_id': self.partner_id.id,
+ 'currency_id': currency_id,
+ 'account_id': journal.default_account_id.id,
+ 'debit': balance > 0 and balance or 0.0,
+ 'credit': balance < 0 and -balance or 0.0,
+ 'amount_currency': amount_currency,
+ }
+
+ @api.model
+ def _prepare_counterpart_move_line_vals(self, counterpart_vals, move_line=None):
+ ''' Prepare values to create a new account.move.line move_line.
+ By default, without specified 'counterpart_vals' or 'move_line', the counterpart line is
+ created using the suspense account. Otherwise, this method is also called during the
+ reconciliation to prepare the statement line's journal entry. In that case,
+ 'counterpart_vals' will be used to create a custom account.move.line (from the reconciliation widget)
+ and 'move_line' will be used to create the counterpart of an existing account.move.line to which
+ the newly created journal item will be reconciled.
+ :param counterpart_vals: A python dictionary containing:
+ 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the
+ counterpart line in the same foreign currency as the statement line, then this amount is
+ considered as the amount in foreign currency. If not specified, the full balance is took.
+ This value must be provided if move_line is not.
+ 'amount_residual': The residual amount to reconcile expressed in the company's currency.
+ /!\ This value should be equivalent to move_line.amount_residual except we want
+ to avoid browsing the record when the only thing we need in an overview of the
+ reconciliation, for example in the reconciliation widget.
+ 'amount_residual_currency': The residual amount to reconcile expressed in the foreign's currency.
+ Using this key doesn't make sense without passing 'currency_id' in vals.
+ /!\ This value should be equivalent to move_line.amount_residual_currency except
+ we want to avoid browsing the record when the only thing we need in an overview
+ of the reconciliation, for example in the reconciliation widget.
+ **kwargs: Additional values that need to land on the account.move.line to create.
+ :param move_line: An optional account.move.line move_line representing the counterpart line to reconcile.
+ :return: The values to create a new account.move.line move_line.
+ '''
+ self.ensure_one()
+
+ statement = self.statement_id
+ journal = statement.journal_id
+ company_currency = journal.company_id.currency_id
+ journal_currency = journal.currency_id or company_currency
+ foreign_currency = self.foreign_currency_id or journal_currency or company_currency
+ statement_line_rate = (self.amount_currency / self.amount) if self.amount else 0.0
+
+ balance_to_reconcile = counterpart_vals.pop('balance', None)
+ amount_residual = -counterpart_vals.pop('amount_residual', move_line.amount_residual if move_line else 0.0) \
+ if balance_to_reconcile is None else balance_to_reconcile
+ amount_residual_currency = -counterpart_vals.pop('amount_residual_currency', move_line.amount_residual_currency if move_line else 0.0)\
+ if balance_to_reconcile is None else balance_to_reconcile
+
+ if 'currency_id' in counterpart_vals:
+ currency_id = counterpart_vals['currency_id'] or company_currency.id
+ elif move_line:
+ currency_id = move_line.currency_id.id or company_currency.id
+ else:
+ currency_id = foreign_currency.id
+
+ if currency_id not in (foreign_currency.id, journal_currency.id):
+ currency_id = company_currency.id
+ amount_residual_currency = 0.0
+
+ amounts = {
+ company_currency.id: 0.0,
+ journal_currency.id: 0.0,
+ foreign_currency.id: 0.0,
+ }
+
+ amounts[currency_id] = amount_residual_currency
+ amounts[company_currency.id] = amount_residual
+
+ if currency_id == journal_currency.id and journal_currency != company_currency:
+ if foreign_currency != company_currency:
+ amounts[company_currency.id] = journal_currency._convert(amounts[currency_id], company_currency, journal.company_id, self.date)
+ if statement_line_rate:
+ amounts[foreign_currency.id] = amounts[currency_id] * statement_line_rate
+ elif currency_id == foreign_currency.id and self.foreign_currency_id:
+ if statement_line_rate:
+ amounts[journal_currency.id] = amounts[foreign_currency.id] / statement_line_rate
+ if foreign_currency != company_currency:
+ amounts[company_currency.id] = journal_currency._convert(amounts[journal_currency.id], company_currency, journal.company_id, self.date)
+ else:
+ amounts[journal_currency.id] = company_currency._convert(amounts[company_currency.id], journal_currency, journal.company_id, self.date)
+ if statement_line_rate:
+ amounts[foreign_currency.id] = amounts[journal_currency.id] * statement_line_rate
+
+ if foreign_currency == company_currency and journal_currency != company_currency and self.foreign_currency_id:
+ balance = amounts[foreign_currency.id]
+ else:
+ balance = amounts[company_currency.id]
+
+ if foreign_currency != company_currency and self.foreign_currency_id:
+ amount_currency = amounts[foreign_currency.id]
+ currency_id = foreign_currency.id
+ elif journal_currency != company_currency and not self.foreign_currency_id:
+ amount_currency = amounts[journal_currency.id]
+ currency_id = journal_currency.id
+ else:
+ amount_currency = amounts[company_currency.id]
+ currency_id = company_currency.id
+
+ return {
+ **counterpart_vals,
+ 'name': counterpart_vals.get('name', move_line.name if move_line else ''),
+ 'move_id': self.move_id.id,
+ 'partner_id': self.partner_id.id or (move_line.partner_id.id if move_line else False),
+ 'currency_id': currency_id,
+ 'account_id': counterpart_vals.get('account_id', move_line.account_id.id if move_line else False),
+ 'debit': balance if balance > 0.0 else 0.0,
+ 'credit': -balance if balance < 0.0 else 0.0,
+ 'amount_currency': amount_currency,
+ }
+
+ @api.model
+ def _prepare_move_line_default_vals(self, counterpart_account_id=None):
+ ''' Prepare the dictionary to create the default account.move.lines for the current account.bank.statement.line
+ record.
+ :return: A list of python dictionary to be passed to the account.move.line's 'create' method.
+ '''
+ self.ensure_one()
+
+ if not counterpart_account_id:
+ counterpart_account_id = self.journal_id.suspense_account_id.id
+
+ if not counterpart_account_id:
+ raise UserError(_(
+ "You can't create a new statement line without a suspense account set on the %s journal."
+ ) % self.journal_id.display_name)
+
+ liquidity_line_vals = self._prepare_liquidity_move_line_vals()
+
+ # Ensure the counterpart will have a balance exactly equals to the amount in journal currency.
+ # This avoid some rounding issues when the currency rate between two currencies is not symmetrical.
+ # E.g:
+ # A.convert(amount_a, B) = amount_b
+ # B.convert(amount_b, A) = amount_c != amount_a
+
+ counterpart_vals = {
+ 'name': self.payment_ref,
+ 'account_id': counterpart_account_id,
+ 'amount_residual': liquidity_line_vals['debit'] - liquidity_line_vals['credit'],
+ }
+
+ if self.foreign_currency_id and self.foreign_currency_id != self.company_currency_id:
+ # Ensure the counterpart will have exactly the same amount in foreign currency as the amount set in the
+ # statement line to avoid some rounding issues when making a currency conversion.
+
+ counterpart_vals.update({
+ 'currency_id': self.foreign_currency_id.id,
+ 'amount_residual_currency': self.amount_currency,
+ })
+ elif liquidity_line_vals['currency_id']:
+ # Ensure the counterpart will have a balance exactly equals to the amount in journal currency.
+ # This avoid some rounding issues when the currency rate between two currencies is not symmetrical.
+ # E.g:
+ # A.convert(amount_a, B) = amount_b
+ # B.convert(amount_b, A) = amount_c != amount_a
+
+ counterpart_vals.update({
+ 'currency_id': liquidity_line_vals['currency_id'],
+ 'amount_residual_currency': liquidity_line_vals['amount_currency'],
+ })
+
+ counterpart_line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals)
+ return [liquidity_line_vals, counterpart_line_vals]
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('journal_id', 'currency_id', 'amount', 'foreign_currency_id', 'amount_currency',
+ 'move_id.to_check',
+ 'move_id.line_ids.account_id', 'move_id.line_ids.amount_currency',
+ 'move_id.line_ids.amount_residual_currency', 'move_id.line_ids.currency_id',
+ 'move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids')
+ def _compute_is_reconciled(self):
+ ''' Compute the field indicating if the statement lines are already reconciled with something.
+ This field is used for display purpose (e.g. display the 'cancel' button on the statement lines).
+ Also computes the residual amount of the statement line.
+ '''
+ for st_line in self:
+ liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
+
+ # Compute residual amount
+ if st_line.to_check:
+ st_line.amount_residual = -st_line.amount_currency if st_line.foreign_currency_id else -st_line.amount
+ elif suspense_lines.account_id.reconcile:
+ st_line.amount_residual = sum(suspense_lines.mapped('amount_residual_currency'))
+ else:
+ st_line.amount_residual = sum(suspense_lines.mapped('amount_currency'))
+
+ # Compute is_reconciled
+ if not st_line.id:
+ # New record: The journal items are not yet there.
+ st_line.is_reconciled = False
+ elif suspense_lines:
+ # In case of the statement line comes from an older version, it could have a residual amount of zero.
+ st_line.is_reconciled = suspense_lines.currency_id.is_zero(st_line.amount_residual)
+ elif st_line.currency_id.is_zero(st_line.amount):
+ st_line.is_reconciled = True
+ else:
+ # The journal entry seems reconciled.
+ st_line.is_reconciled = True
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('amount', 'amount_currency', 'currency_id', 'foreign_currency_id', 'journal_id')
+ def _check_amounts_currencies(self):
+ ''' Ensure the consistency the specified amounts and the currencies. '''
+
+ for st_line in self:
+ if st_line.journal_id != st_line.statement_id.journal_id:
+ raise ValidationError(_('The journal of a statement line must always be the same as the bank statement one.'))
+ if st_line.foreign_currency_id == st_line.currency_id:
+ raise ValidationError(_("The foreign currency must be different than the journal one: %s", st_line.currency_id.name))
+ if not st_line.foreign_currency_id and st_line.amount_currency:
+ raise ValidationError(_("You can't provide an amount in foreign currency without specifying a foreign currency."))
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # OVERRIDE
+ counterpart_account_ids = []
+
+ for vals in vals_list:
+ statement = self.env['account.bank.statement'].browse(vals['statement_id'])
+ if statement.state != 'open' and self._context.get('check_move_validity', True):
+ raise UserError(_("You can only create statement line in open bank statements."))
+
+ # Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context.
+ vals['move_type'] = 'entry'
+
+ journal = statement.journal_id
+ # Ensure the journal is the same as the statement one.
+ vals['journal_id'] = journal.id
+ vals['currency_id'] = (journal.currency_id or journal.company_id.currency_id).id
+ if 'date' not in vals:
+ vals['date'] = statement.date
+
+ # Hack to force different account instead of the suspense account.
+ counterpart_account_ids.append(vals.pop('counterpart_account_id', None))
+
+ st_lines = super().create(vals_list)
+
+ for i, st_line in enumerate(st_lines):
+ counterpart_account_id = counterpart_account_ids[i]
+
+ to_write = {'statement_line_id': st_line.id}
+ if 'line_ids' not in vals_list[i]:
+ to_write['line_ids'] = [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals(counterpart_account_id=counterpart_account_id)]
+
+ st_line.move_id.write(to_write)
+
+ return st_lines
+
+ def write(self, vals):
+ # OVERRIDE
+ res = super().write(vals)
+ self._synchronize_to_moves(set(vals.keys()))
+ return res
+
+ def unlink(self):
+ # OVERRIDE to unlink the inherited account.move (move_id field) as well.
+ moves = self.with_context(force_delete=True).mapped('move_id')
+ res = super().unlink()
+ moves.unlink()
+ return res
+
+ # -------------------------------------------------------------------------
+ # SYNCHRONIZATION account.bank.statement.line <-> account.move
+ # -------------------------------------------------------------------------
+
+ def _synchronize_from_moves(self, changed_fields):
+ ''' Update the account.bank.statement.line regarding its related account.move.
+ Also, check both models are still consistent.
+ :param changed_fields: A set containing all modified fields on account.move.
+ '''
+ if self._context.get('skip_account_move_synchronization'):
+ return
+
+ for st_line in self.with_context(skip_account_move_synchronization=True):
+ move = st_line.move_id
+ move_vals_to_write = {}
+ st_line_vals_to_write = {}
+
+ if 'state' in changed_fields:
+ if (st_line.state == 'open' and move.state != 'draft') or (st_line.state == 'posted' and move.state != 'posted'):
+ raise UserError(_(
+ "You can't manually change the state of journal entry %s, as it has been created by bank "
+ "statement %s."
+ ) % (st_line.move_id.display_name, st_line.statement_id.display_name))
+
+ if 'line_ids' in changed_fields:
+ liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
+ company_currency = st_line.journal_id.company_id.currency_id
+ journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency else False
+
+ if len(liquidity_lines) != 1:
+ raise UserError(_(
+ "The journal entry %s reached an invalid state regarding its related statement line.\n"
+ "To be consistent, the journal entry must always have exactly one journal item involving the "
+ "bank/cash account."
+ ) % st_line.move_id.display_name)
+
+ st_line_vals_to_write.update({
+ 'payment_ref': liquidity_lines.name,
+ 'partner_id': liquidity_lines.partner_id.id,
+ })
+
+ # Update 'amount' according to the liquidity line.
+
+ if journal_currency:
+ st_line_vals_to_write.update({
+ 'amount': liquidity_lines.amount_currency,
+ })
+ else:
+ st_line_vals_to_write.update({
+ 'amount': liquidity_lines.balance,
+ })
+
+ if len(suspense_lines) == 1:
+
+ if journal_currency and suspense_lines.currency_id == journal_currency:
+
+ # The suspense line is expressed in the journal's currency meaning the foreign currency
+ # set on the statement line is no longer needed.
+
+ st_line_vals_to_write.update({
+ 'amount_currency': 0.0,
+ 'foreign_currency_id': False,
+ })
+
+ elif not journal_currency and suspense_lines.currency_id == company_currency:
+
+ # Don't set a specific foreign currency on the statement line.
+
+ st_line_vals_to_write.update({
+ 'amount_currency': 0.0,
+ 'foreign_currency_id': False,
+ })
+
+ else:
+
+ # Update the statement line regarding the foreign currency of the suspense line.
+
+ st_line_vals_to_write.update({
+ 'amount_currency': -suspense_lines.amount_currency,
+ 'foreign_currency_id': suspense_lines.currency_id.id,
+ })
+
+ move_vals_to_write.update({
+ 'partner_id': liquidity_lines.partner_id.id,
+ 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id,
+ })
+
+ move.write(move._cleanup_write_orm_values(move, move_vals_to_write))
+ st_line.write(move._cleanup_write_orm_values(st_line, st_line_vals_to_write))
+
+ def _synchronize_to_moves(self, changed_fields):
+ ''' Update the account.move regarding the modified account.bank.statement.line.
+ :param changed_fields: A list containing all modified fields on account.bank.statement.line.
+ '''
+ if self._context.get('skip_account_move_synchronization'):
+ return
+
+ if not any(field_name in changed_fields for field_name in (
+ 'payment_ref', 'amount', 'amount_currency',
+ 'foreign_currency_id', 'currency_id', 'partner_id',
+ )):
+ return
+
+ for st_line in self.with_context(skip_account_move_synchronization=True):
+ liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
+ company_currency = st_line.journal_id.company_id.currency_id
+ journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency else False
+
+ line_vals_list = self._prepare_move_line_default_vals()
+ line_ids_commands = [(1, liquidity_lines.id, line_vals_list[0])]
+
+ if suspense_lines:
+ line_ids_commands.append((1, suspense_lines.id, line_vals_list[1]))
+ else:
+ line_ids_commands.append((0, 0, line_vals_list[1]))
+
+ for line in other_lines:
+ line_ids_commands.append((2, line.id))
+
+ st_line.move_id.write({
+ 'partner_id': st_line.partner_id.id,
+ 'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id,
+ 'line_ids': line_ids_commands,
+ })
+
+ # -------------------------------------------------------------------------
+ # RECONCILIATION METHODS
+ # -------------------------------------------------------------------------
+
+ def _prepare_reconciliation(self, lines_vals_list, create_payment_for_invoice=False):
+ ''' Helper for the "reconcile" method used to get a full preview of the reconciliation result. This method is
+ quite useful to deal with reconcile models or the reconciliation widget because it ensures the values seen by
+ the user are exactly the values you get after reconciling.
+
+ :param lines_vals_list: See the 'reconcile' method.
+ :param create_payment_for_invoice: A flag indicating the statement line must create payments on the fly during
+ the reconciliation.
+ :return: The diff to be applied on the statement line as a tuple
+ (
+ lines_to_create: The values to create the account.move.line on the statement line.
+ payments_to_create: The values to create the account.payments.
+ open_balance_vals: A dictionary to create the open-balance line or None if the reconciliation is full.
+ existing_lines: The counterpart lines to which the reconciliation will be done.
+ )
+ '''
+
+ self.ensure_one()
+ journal = self.journal_id
+ company_currency = journal.company_id.currency_id
+ foreign_currency = self.foreign_currency_id or journal.currency_id or company_currency
+
+ liquidity_lines, suspense_lines, other_lines = self._seek_for_lines()
+
+ # Ensure the statement line has not yet been already reconciled.
+ # If the move has 'to_check' enabled, it means the statement line has created some lines that
+ # need to be checked later and replaced by the real ones.
+ if not self.move_id.to_check and other_lines:
+ raise UserError(_("The statement line has already been reconciled."))
+
+ # A list of dictionary containing:
+ # - line_vals: The values to create the account.move.line on the statement line.
+ # - payment_vals: The optional values to create a bridge account.payment
+ # - counterpart_line: The optional counterpart line to reconcile with 'line'.
+ reconciliation_overview = []
+
+ total_balance = liquidity_lines.balance
+ total_amount_currency = liquidity_lines.amount_currency
+
+ # Step 1: Split 'lines_vals_list' into two batches:
+ # - The existing account.move.lines that need to be reconciled with the statement line.
+ # => Will be managed at step 2.
+ # - The account.move.lines to be created from scratch.
+ # => Will be managed directly.
+
+ to_browse_ids = []
+ to_process_vals = []
+ for vals in lines_vals_list:
+ # Don't modify the params directly.
+ vals = dict(vals)
+
+ if 'id' in vals:
+ # Existing account.move.line.
+ to_browse_ids.append(vals.pop('id'))
+ to_process_vals.append(vals)
+ else:
+ # Newly created account.move.line from scratch.
+ line_vals = self._prepare_counterpart_move_line_vals(vals)
+ total_balance += line_vals['debit'] - line_vals['credit']
+ total_amount_currency += line_vals['amount_currency']
+
+ reconciliation_overview.append({
+ 'line_vals': line_vals,
+ })
+
+ # Step 2: Browse counterpart lines all in one and process them.
+
+ existing_lines = self.env['account.move.line'].browse(to_browse_ids)
+ for line, counterpart_vals in zip(existing_lines, to_process_vals):
+ line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals, move_line=line)
+ balance = line_vals['debit'] - line_vals['credit']
+ amount_currency = line_vals['amount_currency']
+
+ reconciliation_vals = {
+ 'line_vals': line_vals,
+ 'counterpart_line': line,
+ }
+
+ if create_payment_for_invoice and line.account_internal_type in ('receivable', 'payable'):
+
+ # Prepare values to create a new account.payment.
+ payment_vals = self.env['account.payment.register']\
+ .with_context(active_model='account.move.line', active_ids=line.ids)\
+ .create({
+ 'amount': abs(amount_currency) if line_vals['currency_id'] else abs(balance),
+ 'payment_date': self.date,
+ 'payment_type': 'inbound' if balance < 0.0 else 'outbound',
+ 'journal_id': self.journal_id.id,
+ 'currency_id': (self.foreign_currency_id or self.currency_id).id,
+ })\
+ ._create_payment_vals_from_wizard()
+
+ if payment_vals['payment_type'] == 'inbound':
+ liquidity_account = self.journal_id.payment_debit_account_id
+ else:
+ liquidity_account = self.journal_id.payment_credit_account_id
+
+ # Preserve the rate of the statement line.
+ payment_vals['line_ids'] = [
+ # Receivable / Payable line.
+ (0, 0, {
+ **line_vals,
+ }),
+
+ # Liquidity line.
+ (0, 0, {
+ **line_vals,
+ 'amount_currency': -line_vals['amount_currency'],
+ 'debit': line_vals['credit'],
+ 'credit': line_vals['debit'],
+ 'account_id': liquidity_account.id,
+ }),
+ ]
+
+ # Prepare the line to be reconciled with the payment.
+ if payment_vals['payment_type'] == 'inbound':
+ # Receive money.
+ line_vals['account_id'] = self.journal_id.payment_debit_account_id.id
+ elif payment_vals['payment_type'] == 'outbound':
+ # Send money.
+ line_vals['account_id'] = self.journal_id.payment_credit_account_id.id
+
+ reconciliation_vals['payment_vals'] = payment_vals
+
+ reconciliation_overview.append(reconciliation_vals)
+
+ total_balance += balance
+ total_amount_currency += amount_currency
+
+ # Step 3: Fix rounding issue due to currency conversions.
+ # Add the remaining balance on the first encountered line starting with the custom ones.
+
+ if foreign_currency.is_zero(total_amount_currency) and not company_currency.is_zero(total_balance):
+ vals = reconciliation_overview[0]['line_vals']
+ new_balance = vals['debit'] - vals['credit'] - total_balance
+ vals.update({
+ 'debit': new_balance if new_balance > 0.0 else 0.0,
+ 'credit': -new_balance if new_balance < 0.0 else 0.0,
+ })
+ total_balance = 0.0
+
+ # Step 4: If the journal entry is not yet balanced, create an open balance.
+
+ if self.company_currency_id.round(total_balance):
+ counterpart_vals = {
+ 'name': '%s: %s' % (self.payment_ref, _('Open Balance')),
+ 'balance': -total_balance,
+ 'currency_id': self.company_currency_id.id,
+ }
+
+ partner = self.partner_id or existing_lines.mapped('partner_id')[:1]
+ if partner:
+ if self.amount > 0:
+ open_balance_account = partner.with_company(self.company_id).property_account_receivable_id
+ else:
+ open_balance_account = partner.with_company(self.company_id).property_account_payable_id
+
+ counterpart_vals['account_id'] = open_balance_account.id
+ counterpart_vals['partner_id'] = partner.id
+ else:
+ if self.amount > 0:
+ open_balance_account = self.company_id.partner_id.with_company(self.company_id).property_account_receivable_id
+ else:
+ open_balance_account = self.company_id.partner_id.with_company(self.company_id).property_account_payable_id
+ counterpart_vals['account_id'] = open_balance_account.id
+
+ open_balance_vals = self._prepare_counterpart_move_line_vals(counterpart_vals)
+ else:
+ open_balance_vals = None
+
+ return reconciliation_overview, open_balance_vals
+
+ def reconcile(self, lines_vals_list, to_check=False):
+ ''' Perform a reconciliation on the current account.bank.statement.line with some
+ counterpart account.move.line.
+ If the statement line entry is not fully balanced after the reconciliation, an open balance will be created
+ using the partner.
+
+ :param lines_vals_list: A list of python dictionary containing:
+ 'id': Optional id of an existing account.move.line.
+ For each line having an 'id', a new line will be created in the current statement line.
+ 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the
+ counterpart line in the same foreign currency as the statement line, then this amount is
+ considered as the amount in foreign currency. If not specified, the full balance is taken.
+ This value must be provided if 'id' is not.
+ **kwargs: Custom values to be set on the newly created account.move.line.
+ :param to_check: Mark the current statement line as "to_check" (see field for more details).
+ '''
+ self.ensure_one()
+ liquidity_lines, suspense_lines, other_lines = self._seek_for_lines()
+
+ reconciliation_overview, open_balance_vals = self._prepare_reconciliation(lines_vals_list)
+
+ # ==== Manage res.partner.bank ====
+
+ if self.account_number and self.partner_id and not self.partner_bank_id:
+ self.partner_bank_id = self._find_or_create_bank_account()
+
+ # ==== Check open balance ====
+
+ if open_balance_vals:
+ if not open_balance_vals.get('partner_id'):
+ raise UserError(_("Unable to create an open balance for a statement line without a partner set."))
+ if not open_balance_vals.get('account_id'):
+ raise UserError(_("Unable to create an open balance for a statement line because the receivable "
+ "/ payable accounts are missing on the partner."))
+
+ # ==== Create & reconcile payments ====
+ # When reconciling to a receivable/payable account, create an payment on the fly.
+
+ pay_reconciliation_overview = [reconciliation_vals
+ for reconciliation_vals in reconciliation_overview
+ if reconciliation_vals.get('payment_vals')]
+ if pay_reconciliation_overview:
+ payment_vals_list = [reconciliation_vals['payment_vals'] for reconciliation_vals in pay_reconciliation_overview]
+ payments = self.env['account.payment'].create(payment_vals_list)
+
+ payments.action_post()
+
+ for reconciliation_vals, payment in zip(pay_reconciliation_overview, payments):
+ reconciliation_vals['payment'] = payment
+
+ # Reconcile the newly created payment with the counterpart line.
+ (reconciliation_vals['counterpart_line'] + payment.line_ids)\
+ .filtered(lambda line: line.account_id == reconciliation_vals['counterpart_line'].account_id)\
+ .reconcile()
+
+ # ==== Create & reconcile lines on the bank statement line ====
+
+ to_create_commands = [(0, 0, open_balance_vals)] if open_balance_vals else []
+ to_delete_commands = [(2, line.id) for line in suspense_lines + other_lines]
+
+ # Cleanup previous lines.
+ self.move_id.with_context(check_move_validity=False, skip_account_move_synchronization=True, force_delete=True).write({
+ 'line_ids': to_delete_commands + to_create_commands,
+ 'to_check': to_check,
+ })
+
+ line_vals_list = [reconciliation_vals['line_vals'] for reconciliation_vals in reconciliation_overview]
+ new_lines = self.env['account.move.line'].create(line_vals_list)
+ new_lines = new_lines.with_context(skip_account_move_synchronization=True)
+ for reconciliation_vals, line in zip(reconciliation_overview, new_lines):
+ if reconciliation_vals.get('payment'):
+ accounts = (self.journal_id.payment_debit_account_id, self.journal_id.payment_credit_account_id)
+ counterpart_line = reconciliation_vals['payment'].line_ids.filtered(lambda line: line.account_id in accounts)
+ elif reconciliation_vals.get('counterpart_line'):
+ counterpart_line = reconciliation_vals['counterpart_line']
+ else:
+ continue
+
+ (line + counterpart_line).reconcile()
+
+ # Assign partner if needed (for example, when reconciling a statement
+ # line with no partner, with an invoice; assign the partner of this invoice)
+ if not self.partner_id:
+ rec_overview_partners = set(overview['counterpart_line'].partner_id.id
+ for overview in reconciliation_overview
+ if overview.get('counterpart_line') and overview['counterpart_line'].partner_id)
+ if len(rec_overview_partners) == 1:
+ self.line_ids.write({'partner_id': rec_overview_partners.pop()})
+
+ # Refresh analytic lines.
+ self.move_id.line_ids.analytic_line_ids.unlink()
+ self.move_id.line_ids.create_analytic_lines()
+
+ # -------------------------------------------------------------------------
+ # BUSINESS METHODS
+ # -------------------------------------------------------------------------
+
+ def _find_or_create_bank_account(self):
+ bank_account = self.env['res.partner.bank'].search(
+ [('company_id', '=', self.company_id.id), ('acc_number', '=', self.account_number)])
+ if not bank_account:
+ bank_account = self.env['res.partner.bank'].create({
+ 'acc_number': self.account_number,
+ 'partner_id': self.partner_id.id,
+ 'company_id': self.company_id.id,
+ })
+ return bank_account
+
+ def button_undo_reconciliation(self):
+ ''' Undo the reconciliation mades on the statement line and reset their journal items
+ to their original states.
+ '''
+ self.line_ids.remove_move_reconcile()
+ self.payment_ids.unlink()
+
+ for st_line in self:
+ st_line.with_context(force_delete=True).write({
+ 'to_check': False,
+ 'line_ids': [(5, 0)] + [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals()],
+ })
diff --git a/addons/account/models/account_cash_rounding.py b/addons/account/models/account_cash_rounding.py
new file mode 100644
index 00000000..b4cb8a23
--- /dev/null
+++ b/addons/account/models/account_cash_rounding.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+from odoo.tools import float_round
+from odoo.exceptions import ValidationError
+
+
+class AccountCashRounding(models.Model):
+ """
+ In some countries, we need to be able to make appear on an invoice a rounding line, appearing there only because the
+ smallest coinage has been removed from the circulation. For example, in Switzerland invoices have to be rounded to
+ 0.05 CHF because coins of 0.01 CHF and 0.02 CHF aren't used anymore.
+ see https://en.wikipedia.org/wiki/Cash_rounding for more details.
+ """
+ _name = 'account.cash.rounding'
+ _description = 'Account Cash Rounding'
+
+ name = fields.Char(string='Name', translate=True, required=True)
+ rounding = fields.Float(string='Rounding Precision', required=True, default=0.01,
+ help='Represent the non-zero value smallest coinage (for example, 0.05).')
+ strategy = fields.Selection([('biggest_tax', 'Modify tax amount'), ('add_invoice_line', 'Add a rounding line')],
+ string='Rounding Strategy', default='add_invoice_line', required=True,
+ help='Specify which way will be used to round the invoice amount to the rounding precision')
+ profit_account_id = fields.Many2one('account.account', string='Profit Account', company_dependent=True, domain="[('deprecated', '=', False), ('company_id', '=', current_company_id)]")
+ loss_account_id = fields.Many2one('account.account', string='Loss Account', company_dependent=True, domain="[('deprecated', '=', False), ('company_id', '=', current_company_id)]")
+ rounding_method = fields.Selection(string='Rounding Method', required=True,
+ selection=[('UP', 'UP'), ('DOWN', 'DOWN'), ('HALF-UP', 'HALF-UP')],
+ default='HALF-UP', help='The tie-breaking rule used for float rounding operations')
+ company_id = fields.Many2one('res.company', related='profit_account_id.company_id')
+
+ @api.constrains('rounding')
+ def validate_rounding(self):
+ for record in self:
+ if record.rounding <= 0:
+ raise ValidationError(_("Please set a strictly positive rounding value."))
+
+ def round(self, amount):
+ """Compute the rounding on the amount passed as parameter.
+
+ :param amount: the amount to round
+ :return: the rounded amount depending the rounding value and the rounding method
+ """
+ return float_round(amount, precision_rounding=self.rounding, rounding_method=self.rounding_method)
+
+ def compute_difference(self, currency, amount):
+ """Compute the difference between the base_amount and the amount after rounding.
+ For example, base_amount=23.91, after rounding=24.00, the result will be 0.09.
+
+ :param currency: The currency.
+ :param amount: The amount
+ :return: round(difference)
+ """
+ difference = self.round(amount) - amount
+ return currency.round(difference)
diff --git a/addons/account/models/account_full_reconcile.py b/addons/account/models/account_full_reconcile.py
new file mode 100644
index 00000000..f2ff0ea0
--- /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.context_today(self)
+ 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_incoterms.py b/addons/account/models/account_incoterms.py
new file mode 100644
index 00000000..a8015b10
--- /dev/null
+++ b/addons/account/models/account_incoterms.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class AccountIncoterms(models.Model):
+ _name = 'account.incoterms'
+ _description = 'Incoterms'
+
+ name = fields.Char(
+ 'Name', required=True, translate=True,
+ help="Incoterms are series of sales terms. They are used to divide transaction costs and responsibilities between buyer and seller and reflect state-of-the-art transportation practices.")
+ code = fields.Char(
+ 'Code', size=3, required=True,
+ help="Incoterm Standard Code")
+ active = fields.Boolean(
+ 'Active', default=True,
+ help="By unchecking the active field, you may hide an INCOTERM you will not use.")
diff --git a/addons/account/models/account_journal.py b/addons/account/models/account_journal.py
new file mode 100644
index 00000000..c7b84a6b
--- /dev/null
+++ b/addons/account/models/account_journal.py
@@ -0,0 +1,785 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _
+from odoo.osv import expression
+from odoo.exceptions import UserError, ValidationError
+from odoo.addons.base.models.res_bank import sanitize_account_number
+from odoo.tools import remove_accents
+import logging
+import re
+
+_logger = logging.getLogger(__name__)
+
+
+class AccountJournalGroup(models.Model):
+ _name = 'account.journal.group'
+ _description = "Account Journal Group"
+ _check_company_auto = True
+
+ name = fields.Char("Journal Group", required=True, translate=True)
+ company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)
+ excluded_journal_ids = fields.Many2many('account.journal', string="Excluded Journals", domain="[('company_id', '=', company_id)]",
+ check_company=True)
+ sequence = fields.Integer(default=10)
+
+
+class AccountJournal(models.Model):
+ _name = "account.journal"
+ _description = "Journal"
+ _order = 'sequence, type, code'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _check_company_auto = True
+
+ def _default_inbound_payment_methods(self):
+ return self.env.ref('account.account_payment_method_manual_in')
+
+ def _default_outbound_payment_methods(self):
+ return self.env.ref('account.account_payment_method_manual_out')
+
+ def __get_bank_statements_available_sources(self):
+ return [('undefined', _('Undefined Yet'))]
+
+ def _get_bank_statements_available_sources(self):
+ return self.__get_bank_statements_available_sources()
+
+ def _default_alias_domain(self):
+ return self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain")
+
+ def _default_invoice_reference_model(self):
+ """Get the invoice reference model according to the company's country."""
+ country_code = self.env.company.country_id.code
+ country_code = country_code and country_code.lower()
+ if country_code:
+ for model in self._fields['invoice_reference_model'].get_values(self.env):
+ if model.startswith(country_code):
+ return model
+ return 'odoo'
+
+ name = fields.Char(string='Journal Name', required=True)
+ code = fields.Char(string='Short Code', size=5, required=True, help="Shorter name used for display. The journal entries of this journal will also be named using this prefix by default.")
+ active = fields.Boolean(default=True, help="Set active to false to hide the Journal without removing it.")
+ type = fields.Selection([
+ ('sale', 'Sales'),
+ ('purchase', 'Purchase'),
+ ('cash', 'Cash'),
+ ('bank', 'Bank'),
+ ('general', 'Miscellaneous'),
+ ], required=True,
+ help="Select 'Sale' for customer invoices journals.\n"\
+ "Select 'Purchase' for vendor bills journals.\n"\
+ "Select 'Cash' or 'Bank' for journals that are used in customer or vendor payments.\n"\
+ "Select 'General' for miscellaneous operations journals.")
+ type_control_ids = fields.Many2many('account.account.type', 'journal_account_type_control_rel', 'journal_id', 'type_id', string='Allowed account types')
+ account_control_ids = fields.Many2many('account.account', 'journal_account_control_rel', 'journal_id', 'account_id', string='Allowed accounts',
+ check_company=True,
+ domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('is_off_balance', '=', False)]")
+ default_account_type = fields.Many2one('account.account.type', compute="_compute_default_account_type")
+ default_account_id = fields.Many2one(
+ comodel_name='account.account', check_company=True, copy=False, ondelete='restrict',
+ string='Default Account',
+ domain="[('deprecated', '=', False), ('company_id', '=', company_id),"
+ "'|', ('user_type_id', '=', default_account_type), ('user_type_id', 'in', type_control_ids),"
+ "('user_type_id.type', 'not in', ('receivable', 'payable'))]")
+ payment_debit_account_id = fields.Many2one(
+ comodel_name='account.account', check_company=True, copy=False, ondelete='restrict',
+ help="Incoming payments entries triggered by invoices/refunds will be posted on the Outstanding Receipts Account "
+ "and displayed as blue lines in the bank reconciliation widget. During the reconciliation process, concerned "
+ "transactions will be reconciled with entries on the Outstanding Receipts Account instead of the "
+ "receivable account.", string='Outstanding Receipts Account',
+ domain=lambda self: "[('deprecated', '=', False), ('company_id', '=', company_id), \
+ ('user_type_id.type', 'not in', ('receivable', 'payable')), \
+ '|', ('user_type_id', '=', %s), ('id', '=', default_account_id)]" % self.env.ref('account.data_account_type_current_assets').id)
+ payment_credit_account_id = fields.Many2one(
+ comodel_name='account.account', check_company=True, copy=False, ondelete='restrict',
+ help="Outgoing payments entries triggered by bills/credit notes will be posted on the Outstanding Payments Account "
+ "and displayed as blue lines in the bank reconciliation widget. During the reconciliation process, concerned "
+ "transactions will be reconciled with entries on the Outstanding Payments Account instead of the "
+ "payable account.", string='Outstanding Payments Account',
+ domain=lambda self: "[('deprecated', '=', False), ('company_id', '=', company_id), \
+ ('user_type_id.type', 'not in', ('receivable', 'payable')), \
+ '|', ('user_type_id', '=', %s), ('id', '=', default_account_id)]" % self.env.ref('account.data_account_type_current_assets').id)
+ suspense_account_id = fields.Many2one(
+ comodel_name='account.account', check_company=True, ondelete='restrict', readonly=False, store=True,
+ compute='_compute_suspense_account_id',
+ help="Bank statements transactions will be posted on the suspense account until the final reconciliation "
+ "allowing finding the right account.", string='Suspense Account',
+ domain=lambda self: "[('deprecated', '=', False), ('company_id', '=', company_id), \
+ ('user_type_id.type', 'not in', ('receivable', 'payable')), \
+ ('user_type_id', '=', %s)]" % self.env.ref('account.data_account_type_current_liabilities').id)
+ restrict_mode_hash_table = fields.Boolean(string="Lock Posted Entries with Hash",
+ help="If ticked, the accounting entry or invoice receives a hash as soon as it is posted and cannot be modified anymore.")
+ sequence = fields.Integer(help='Used to order Journals in the dashboard view', default=10)
+
+ invoice_reference_type = fields.Selection(string='Communication Type', required=True, selection=[('none', 'Free'), ('partner', 'Based on Customer'), ('invoice', 'Based on Invoice')], default='invoice', help='You can set here the default communication that will appear on customer invoices, once validated, to help the customer to refer to that particular invoice when making the payment.')
+ invoice_reference_model = fields.Selection(string='Communication Standard', required=True, selection=[('odoo', 'Odoo'),('euro', 'European')], default=_default_invoice_reference_model, help="You can choose different models for each type of reference. The default one is the Odoo reference.")
+
+ #groups_id = fields.Many2many('res.groups', 'account_journal_group_rel', 'journal_id', 'group_id', string='Groups')
+ currency_id = fields.Many2one('res.currency', help='The currency used to enter statement', string="Currency")
+ company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, index=True, default=lambda self: self.env.company,
+ help="Company related to this journal")
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
+
+ refund_sequence = fields.Boolean(string='Dedicated Credit Note Sequence', help="Check this box if you don't want to share the same sequence for invoices and credit notes made from this journal", default=False)
+ sequence_override_regex = fields.Text(help="Technical field used to enforce complex sequence composition that the system would normally misunderstand.\n"\
+ "This is a regex that can include all the following capture groups: prefix1, year, prefix2, month, prefix3, seq, suffix.\n"\
+ "The prefix* groups are the separators between the year, month and the actual increasing sequence number (seq).\n"\
+
+ "e.g: ^(?P<prefix1>.*?)(?P<year>\d{4})(?P<prefix2>\D*?)(?P<month>\d{2})(?P<prefix3>\D+?)(?P<seq>\d+)(?P<suffix>\D*?)$")
+
+ inbound_payment_method_ids = fields.Many2many(
+ comodel_name='account.payment.method',
+ relation='account_journal_inbound_payment_method_rel',
+ column1='journal_id',
+ column2='inbound_payment_method',
+ domain=[('payment_type', '=', 'inbound')],
+ string='Inbound Payment Methods',
+ compute='_compute_inbound_payment_method_ids',
+ store=True,
+ readonly=False,
+ help="Manual: Get paid by cash, check or any other method outside of Odoo.\n"
+ "Electronic: Get paid automatically through a payment acquirer by requesting a transaction"
+ " on a card saved by the customer when buying or subscribing online (payment token).\n"
+ "Batch Deposit: Encase several customer checks at once by generating a batch deposit to"
+ " submit to your bank. When encoding the bank statement in Odoo,you are suggested to"
+ " reconcile the transaction with the batch deposit. Enable this option from the settings."
+ )
+ outbound_payment_method_ids = fields.Many2many(
+ comodel_name='account.payment.method',
+ relation='account_journal_outbound_payment_method_rel',
+ column1='journal_id',
+ column2='outbound_payment_method',
+ domain=[('payment_type', '=', 'outbound')],
+ string='Outbound Payment Methods',
+ compute='_compute_outbound_payment_method_ids',
+ store=True,
+ readonly=False,
+ help="Manual:Pay bill by cash or any other method outside of Odoo.\n"
+ "Check:Pay bill by check and print it from Odoo.\n"
+ "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your"
+ " bank. Enable this option from the settings."
+ )
+ at_least_one_inbound = fields.Boolean(compute='_methods_compute', store=True)
+ at_least_one_outbound = fields.Boolean(compute='_methods_compute', store=True)
+ profit_account_id = fields.Many2one(
+ comodel_name='account.account', check_company=True,
+ help="Used to register a profit when the ending balance of a cash register differs from what the system computes",
+ string='Profit Account',
+ domain=lambda self: "[('deprecated', '=', False), ('company_id', '=', company_id), \
+ ('user_type_id.type', 'not in', ('receivable', 'payable')), \
+ ('user_type_id', 'in', %s)]" % [self.env.ref('account.data_account_type_revenue').id,
+ self.env.ref('account.data_account_type_other_income').id])
+ loss_account_id = fields.Many2one(
+ comodel_name='account.account', check_company=True,
+ help="Used to register a loss when the ending balance of a cash register differs from what the system computes",
+ string='Loss Account',
+ domain=lambda self: "[('deprecated', '=', False), ('company_id', '=', company_id), \
+ ('user_type_id.type', 'not in', ('receivable', 'payable')), \
+ ('user_type_id', '=', %s)]" % self.env.ref('account.data_account_type_expenses').id)
+
+ # Bank journals fields
+ company_partner_id = fields.Many2one('res.partner', related='company_id.partner_id', string='Account Holder', readonly=True, store=False)
+ bank_account_id = fields.Many2one('res.partner.bank',
+ string="Bank Account",
+ ondelete='restrict', copy=False,
+ check_company=True,
+ domain="[('partner_id','=', company_partner_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ bank_statements_source = fields.Selection(selection=_get_bank_statements_available_sources, string='Bank Feeds', default='undefined', help="Defines how the bank statements will be registered")
+ bank_acc_number = fields.Char(related='bank_account_id.acc_number', readonly=False)
+ bank_id = fields.Many2one('res.bank', related='bank_account_id.bank_id', readonly=False)
+
+ # Sale journals fields
+ sale_activity_type_id = fields.Many2one('mail.activity.type', string='Schedule Activity', default=False, help="Activity will be automatically scheduled on payment due date, improving collection process.")
+ sale_activity_user_id = fields.Many2one('res.users', string="Activity User", help="Leave empty to assign the Salesperson of the invoice.")
+ sale_activity_note = fields.Text('Activity Summary')
+
+ # alias configuration for journals
+ alias_id = fields.Many2one('mail.alias', string='Email Alias', help="Send one separate email for each invoice.\n\n"
+ "Any file extension will be accepted.\n\n"
+ "Only PDF and XML files will be interpreted by Odoo", copy=False)
+ alias_domain = fields.Char('Alias domain', compute='_compute_alias_domain', default=_default_alias_domain, compute_sudo=True)
+ alias_name = fields.Char('Alias Name', copy=False, related='alias_id.alias_name', help="It creates draft invoices and bills by sending an email.", readonly=False)
+
+ journal_group_ids = fields.Many2many('account.journal.group',
+ domain="[('company_id', '=', company_id)]",
+ check_company=True,
+ string="Journal Groups")
+
+ secure_sequence_id = fields.Many2one('ir.sequence',
+ help='Sequence to use to ensure the securisation of data',
+ check_company=True,
+ readonly=True, copy=False)
+
+ _sql_constraints = [
+ ('code_company_uniq', 'unique (code, name, company_id)', 'The code and name of the journal must be unique per company !'),
+ ]
+
+ @api.depends('type')
+ def _compute_default_account_type(self):
+ default_account_id_types = {
+ 'bank': 'account.data_account_type_liquidity',
+ 'cash': 'account.data_account_type_liquidity',
+ 'sale': 'account.data_account_type_revenue',
+ 'purchase': 'account.data_account_type_expenses'
+ }
+
+ for journal in self:
+ if journal.type in default_account_id_types:
+ journal.default_account_type = self.env.ref(default_account_id_types[journal.type]).id
+ else:
+ journal.default_account_type = False
+
+ @api.depends('type')
+ def _compute_outbound_payment_method_ids(self):
+ for journal in self:
+ if journal.type in ('bank', 'cash'):
+ journal.outbound_payment_method_ids = self._default_outbound_payment_methods()
+ else:
+ journal.outbound_payment_method_ids = False
+
+ @api.depends('type')
+ def _compute_inbound_payment_method_ids(self):
+ for journal in self:
+ if journal.type in ('bank', 'cash'):
+ journal.inbound_payment_method_ids = self._default_inbound_payment_methods()
+ else:
+ journal.inbound_payment_method_ids = False
+
+ @api.depends('company_id', 'type')
+ def _compute_suspense_account_id(self):
+ for journal in self:
+ if journal.type not in ('bank', 'cash'):
+ journal.suspense_account_id = False
+ elif journal.suspense_account_id:
+ journal.suspense_account_id = journal.suspense_account_id
+ elif journal.company_id.account_journal_suspense_account_id:
+ journal.suspense_account_id = journal.company_id.account_journal_suspense_account_id
+ else:
+ journal.suspense_account_id = False
+
+ def _compute_alias_domain(self):
+ alias_domain = self._default_alias_domain()
+ for record in self:
+ record.alias_domain = alias_domain
+
+ @api.constrains('type_control_ids')
+ def _constrains_type_control_ids(self):
+ self.env['account.move.line'].flush(['account_id', 'journal_id'])
+ self.flush(['type_control_ids'])
+ self._cr.execute("""
+ SELECT aml.id
+ FROM account_move_line aml
+ WHERE aml.journal_id in (%s)
+ AND EXISTS (SELECT 1 FROM journal_account_type_control_rel rel WHERE rel.journal_id = aml.journal_id)
+ AND NOT EXISTS (SELECT 1 FROM account_account acc
+ JOIN journal_account_type_control_rel rel ON acc.user_type_id = rel.type_id
+ WHERE acc.id = aml.account_id AND rel.journal_id = aml.journal_id)
+ """, tuple(self.ids))
+ if self._cr.fetchone():
+ raise ValidationError(_('Some journal items already exist in this journal but with accounts from different types than the allowed ones.'))
+
+ @api.constrains('account_control_ids')
+ def _constrains_account_control_ids(self):
+ self.env['account.move.line'].flush(['account_id', 'journal_id'])
+ self.flush(['account_control_ids'])
+ self._cr.execute("""
+ SELECT aml.id
+ FROM account_move_line aml
+ WHERE aml.journal_id in (%s)
+ AND EXISTS (SELECT 1 FROM journal_account_control_rel rel WHERE rel.journal_id = aml.journal_id)
+ AND NOT EXISTS (SELECT 1 FROM journal_account_control_rel rel WHERE rel.account_id = aml.account_id AND rel.journal_id = aml.journal_id)
+ """, tuple(self.ids))
+ if self._cr.fetchone():
+ raise ValidationError(_('Some journal items already exist in this journal but with other accounts than the allowed ones.'))
+
+ @api.constrains('type', 'bank_account_id')
+ def _check_bank_account(self):
+ for journal in self:
+ if journal.type == 'bank' and journal.bank_account_id:
+ if journal.bank_account_id.company_id and journal.bank_account_id.company_id != journal.company_id:
+ raise ValidationError(_('The bank account of a bank journal must belong to the same company (%s).', journal.company_id.name))
+ # A bank account can belong to a customer/supplier, in which case their partner_id is the customer/supplier.
+ # Or they are part of a bank journal and their partner_id must be the company's partner_id.
+ if journal.bank_account_id.partner_id != journal.company_id.partner_id:
+ raise ValidationError(_('The holder of a journal\'s bank account must be the company (%s).', journal.company_id.name))
+
+ @api.constrains('company_id')
+ def _check_company_consistency(self):
+ if not self:
+ return
+
+ self.flush(['company_id'])
+ self._cr.execute('''
+ SELECT move.id
+ FROM account_move move
+ JOIN account_journal journal ON journal.id = move.journal_id
+ WHERE move.journal_id IN %s
+ AND move.company_id != journal.company_id
+ ''', [tuple(self.ids)])
+ if self._cr.fetchone():
+ raise UserError(_("You can't change the company of your journal since there are some journal entries linked to it."))
+
+ @api.constrains('type', 'default_account_id')
+ def _check_type_default_account_id_type(self):
+ for journal in self:
+ if journal.type in ('sale', 'purchase') and journal.default_account_id.user_type_id.type in ('receivable', 'payable'):
+ raise ValidationError(_("The type of the journal's default credit/debit account shouldn't be 'receivable' or 'payable'."))
+
+ @api.constrains('active')
+ def _check_auto_post_draft_entries(self):
+ for journal in self:
+ pending_moves = self.env['account.move'].search([
+ ('journal_id', '=', journal.id),
+ ('state', '=', 'draft')
+ ], limit=1)
+
+ if pending_moves:
+ raise ValidationError(_("You can not archive a journal containing draft journal entries.\n\n"
+ "To proceed:\n"
+ "1/ click on the top-right button 'Journal Entries' from this journal form\n"
+ "2/ then filter on 'Draft' entries\n"
+ "3/ select them all and post or delete them through the action menu"))
+
+ @api.onchange('type')
+ def _onchange_type(self):
+ self.refund_sequence = self.type in ('sale', 'purchase')
+
+ def _get_alias_values(self, type, alias_name=None):
+ if not alias_name:
+ alias_name = self.name
+ if self.company_id != self.env.ref('base.main_company'):
+ alias_name += '-' + str(self.company_id.name)
+ try:
+ remove_accents(alias_name).encode('ascii')
+ except UnicodeEncodeError:
+ try:
+ remove_accents(self.code).encode('ascii')
+ safe_alias_name = self.code
+ except UnicodeEncodeError:
+ safe_alias_name = self.type
+ _logger.warning("Cannot use '%s' as email alias, fallback to '%s'",
+ alias_name, safe_alias_name)
+ alias_name = safe_alias_name
+ return {
+ 'alias_defaults': {'move_type': type == 'purchase' and 'in_invoice' or 'out_invoice', 'company_id': self.company_id.id, 'journal_id': self.id},
+ 'alias_parent_thread_id': self.id,
+ 'alias_name': alias_name,
+ }
+
+ def unlink(self):
+ bank_accounts = self.env['res.partner.bank'].browse()
+ for bank_account in self.mapped('bank_account_id'):
+ accounts = self.search([('bank_account_id', '=', bank_account.id)])
+ if accounts <= self:
+ bank_accounts += bank_account
+ self.mapped('alias_id').sudo().unlink()
+ ret = super(AccountJournal, self).unlink()
+ bank_accounts.unlink()
+ return ret
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ default = dict(default or {})
+ default.update(
+ code=_("%s (copy)") % (self.code or ''),
+ name=_("%s (copy)") % (self.name or ''))
+ return super(AccountJournal, self).copy(default)
+
+ def _update_mail_alias(self, vals):
+ self.ensure_one()
+ alias_values = self._get_alias_values(type=vals.get('type') or self.type, alias_name=vals.get('alias_name'))
+ if self.alias_id:
+ self.alias_id.sudo().write(alias_values)
+ else:
+ alias_values['alias_model_id'] = self.env['ir.model']._get('account.move').id
+ alias_values['alias_parent_model_id'] = self.env['ir.model']._get('account.journal').id
+ self.alias_id = self.env['mail.alias'].sudo().create(alias_values)
+
+ if vals.get('alias_name'):
+ # remove alias_name to avoid useless write on alias
+ del(vals['alias_name'])
+
+ def write(self, vals):
+ for journal in self:
+ company = journal.company_id
+ if ('company_id' in vals and journal.company_id.id != vals['company_id']):
+ if self.env['account.move'].search([('journal_id', '=', journal.id)], limit=1):
+ raise UserError(_('This journal already contains items, therefore you cannot modify its company.'))
+ company = self.env['res.company'].browse(vals['company_id'])
+ if journal.bank_account_id.company_id and journal.bank_account_id.company_id != company:
+ journal.bank_account_id.write({
+ 'company_id': company.id,
+ 'partner_id': company.partner_id.id,
+ })
+ if 'currency_id' in vals:
+ if journal.bank_account_id:
+ journal.bank_account_id.currency_id = vals['currency_id']
+ if 'bank_account_id' in vals:
+ if not vals.get('bank_account_id'):
+ raise UserError(_('You cannot remove the bank account from the journal once set.'))
+ else:
+ bank_account = self.env['res.partner.bank'].browse(vals['bank_account_id'])
+ if bank_account.partner_id != company.partner_id:
+ raise UserError(_("The partners of the journal's company and the related bank account mismatch."))
+ if 'alias_name' in vals:
+ journal._update_mail_alias(vals)
+ if 'restrict_mode_hash_table' in vals and not vals.get('restrict_mode_hash_table'):
+ journal_entry = self.env['account.move'].search([('journal_id', '=', self.id), ('state', '=', 'posted'), ('secure_sequence_number', '!=', 0)], limit=1)
+ if len(journal_entry) > 0:
+ field_string = self._fields['restrict_mode_hash_table'].get_description(self.env)['string']
+ raise UserError(_("You cannot modify the field %s of a journal that already has accounting entries.", field_string))
+ result = super(AccountJournal, self).write(vals)
+
+ # Ensure the liquidity accounts are sharing the same foreign currency.
+ if 'currency_id' in vals:
+ for journal in self.filtered(lambda journal: journal.type in ('bank', 'cash')):
+ journal.default_account_id.currency_id = journal.currency_id
+
+ # Create the bank_account_id if necessary
+ if 'bank_acc_number' in vals:
+ for journal in self.filtered(lambda r: r.type == 'bank' and not r.bank_account_id):
+ journal.set_bank_account(vals.get('bank_acc_number'), vals.get('bank_id'))
+ for record in self:
+ if record.restrict_mode_hash_table and not record.secure_sequence_id:
+ record._create_secure_sequence(['secure_sequence_id'])
+
+ return result
+
+ @api.model
+ def get_next_bank_cash_default_code(self, journal_type, company):
+ journal_code_base = (journal_type == 'cash' and 'CSH' or 'BNK')
+ journals = self.env['account.journal'].search([('code', 'like', journal_code_base + '%'), ('company_id', '=', company.id)])
+ for num in range(1, 100):
+ # journal_code has a maximal size of 5, hence we can enforce the boundary num < 100
+ journal_code = journal_code_base + str(num)
+ if journal_code not in journals.mapped('code'):
+ return journal_code
+
+ @api.model
+ def _prepare_liquidity_account_vals(self, company, code, vals):
+ return {
+ 'name': vals.get('name'),
+ 'code': code,
+ 'user_type_id': self.env.ref('account.data_account_type_liquidity').id,
+ 'currency_id': vals.get('currency_id'),
+ 'company_id': company.id,
+ }
+
+ @api.model
+ def _fill_missing_values(self, vals):
+ journal_type = vals.get('type')
+
+ # 'type' field is required.
+ if not journal_type:
+ return
+
+ # === Fill missing company ===
+ company = self.env['res.company'].browse(vals['company_id']) if vals.get('company_id') else self.env.company
+ vals['company_id'] = company.id
+
+ # Don't get the digits on 'chart_template_id' since the chart template could be a custom one.
+ random_account = self.env['account.account'].search([('company_id', '=', company.id)], limit=1)
+ digits = len(random_account.code) if random_account else 6
+
+ liquidity_type = self.env.ref('account.data_account_type_liquidity')
+ current_assets_type = self.env.ref('account.data_account_type_current_assets')
+
+ if journal_type in ('bank', 'cash'):
+ has_liquidity_accounts = vals.get('default_account_id')
+ has_payment_accounts = vals.get('payment_debit_account_id') or vals.get('payment_credit_account_id')
+ has_profit_account = vals.get('profit_account_id')
+ has_loss_account = vals.get('loss_account_id')
+
+ if journal_type == 'bank':
+ liquidity_account_prefix = company.bank_account_code_prefix or ''
+ else:
+ liquidity_account_prefix = company.cash_account_code_prefix or company.bank_account_code_prefix or ''
+
+ # === Fill missing name ===
+ vals['name'] = vals.get('name') or vals.get('bank_acc_number')
+
+ # === Fill missing code ===
+ if 'code' not in vals:
+ vals['code'] = self.get_next_bank_cash_default_code(journal_type, company)
+ if not vals['code']:
+ raise UserError(_("Cannot generate an unused journal code. Please fill the 'Shortcode' field."))
+
+ # === Fill missing accounts ===
+ if not has_liquidity_accounts:
+ default_account_code = self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix)
+ default_account_vals = self._prepare_liquidity_account_vals(company, default_account_code, vals)
+ vals['default_account_id'] = self.env['account.account'].create(default_account_vals).id
+ if not has_payment_accounts:
+ vals['payment_debit_account_id'] = self.env['account.account'].create({
+ 'name': _("Outstanding Receipts"),
+ 'code': self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix),
+ 'reconcile': True,
+ 'user_type_id': current_assets_type.id,
+ 'company_id': company.id,
+ }).id
+ vals['payment_credit_account_id'] = self.env['account.account'].create({
+ 'name': _("Outstanding Payments"),
+ 'code': self.env['account.account']._search_new_account_code(company, digits, liquidity_account_prefix),
+ 'reconcile': True,
+ 'user_type_id': current_assets_type.id,
+ 'company_id': company.id,
+ }).id
+ if journal_type == 'cash' and not has_profit_account:
+ vals['profit_account_id'] = company.default_cash_difference_income_account_id.id
+ if journal_type == 'cash' and not has_loss_account:
+ vals['loss_account_id'] = company.default_cash_difference_expense_account_id.id
+
+ # === Fill missing refund_sequence ===
+ if 'refund_sequence' not in vals:
+ vals['refund_sequence'] = vals['type'] in ('sale', 'purchase')
+
+ @api.model
+ def create(self, vals):
+ # OVERRIDE
+ self._fill_missing_values(vals)
+
+ journal = super(AccountJournal, self.with_context(mail_create_nolog=True)).create(vals)
+
+ if 'alias_name' in vals:
+ journal._update_mail_alias(vals)
+
+ # Create the bank_account_id if necessary
+ if journal.type == 'bank' and not journal.bank_account_id and vals.get('bank_acc_number'):
+ journal.set_bank_account(vals.get('bank_acc_number'), vals.get('bank_id'))
+
+ return journal
+
+ def set_bank_account(self, acc_number, bank_id=None):
+ """ Create a res.partner.bank (if not exists) and set it as value of the field bank_account_id """
+ self.ensure_one()
+ res_partner_bank = self.env['res.partner.bank'].search([('sanitized_acc_number', '=', sanitize_account_number(acc_number)),
+ ('company_id', '=', self.company_id.id)], limit=1)
+ if res_partner_bank:
+ self.bank_account_id = res_partner_bank.id
+ else:
+ self.bank_account_id = self.env['res.partner.bank'].create({
+ 'acc_number': acc_number,
+ 'bank_id': bank_id,
+ 'company_id': self.company_id.id,
+ 'currency_id': self.currency_id.id,
+ 'partner_id': self.company_id.partner_id.id,
+ }).id
+
+ def name_get(self):
+ res = []
+ for journal in self:
+ name = journal.name
+ if journal.currency_id and journal.currency_id != journal.company_id.currency_id:
+ name = "%s (%s)" % (name, journal.currency_id.name)
+ res += [(journal.id, name)]
+ return res
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ args = args or []
+
+ if operator == 'ilike' and not (name or '').strip():
+ domain = []
+ else:
+ connector = '&' if operator in expression.NEGATIVE_TERM_OPERATORS else '|'
+ domain = [connector, ('code', operator, name), ('name', operator, name)]
+ return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
+
+ @api.depends('inbound_payment_method_ids', 'outbound_payment_method_ids')
+ def _methods_compute(self):
+ for journal in self:
+ journal.at_least_one_inbound = bool(len(journal.inbound_payment_method_ids))
+ journal.at_least_one_outbound = bool(len(journal.outbound_payment_method_ids))
+
+ def action_configure_bank_journal(self):
+ """ This function is called by the "configure" button of bank journals,
+ visible on dashboard if no bank statement source has been defined yet
+ """
+ # We simply call the setup bar function.
+ return self.env['res.company'].setting_init_bank_account_action()
+
+ def create_invoice_from_attachment(self, attachment_ids=[]):
+ ''' Create the invoices from files.
+ :return: A action redirecting to account.move tree/form view.
+ '''
+ attachments = self.env['ir.attachment'].browse(attachment_ids)
+ if not attachments:
+ raise UserError(_("No attachment was provided"))
+
+ invoices = self.env['account.move']
+ for attachment in attachments:
+ attachment.write({'res_model': 'mail.compose.message'})
+ decoders = self.env['account.move']._get_create_invoice_from_attachment_decoders()
+ invoice = False
+ for decoder in sorted(decoders, key=lambda d: d[0]):
+ invoice = decoder[1](attachment)
+ if invoice:
+ break
+ if not invoice:
+ invoice = self.env['account.move'].create({})
+ invoice.with_context(no_new_invoice=True).message_post(attachment_ids=[attachment.id])
+ invoices += invoice
+
+ action_vals = {
+ 'name': _('Generated Documents'),
+ 'domain': [('id', 'in', invoices.ids)],
+ 'res_model': 'account.move',
+ 'views': [[False, "tree"], [False, "form"]],
+ 'type': 'ir.actions.act_window',
+ 'context': self._context
+ }
+ if len(invoices) == 1:
+ action_vals.update({'res_id': invoices[0].id, 'view_mode': 'form'})
+ else:
+ action_vals['view_mode'] = 'tree,form'
+ return action_vals
+
+ def _create_invoice_from_single_attachment(self, attachment):
+ """ Creates an invoice and post the attachment. If the related modules
+ are installed, it will trigger OCR or the import from the EDI.
+ DEPRECATED : use create_invoice_from_attachment instead
+
+ :returns: the created invoice.
+ """
+ invoice_action = self.create_invoice_from_attachment(attachment.ids)
+ return self.env['account.move'].browse(invoice_action['res_id'])
+
+ def _create_secure_sequence(self, sequence_fields):
+ """This function creates a no_gap sequence on each journal in self that will ensure
+ a unique number is given to all posted account.move in such a way that we can always
+ find the previous move of a journal entry on a specific journal.
+ """
+ for journal in self:
+ vals_write = {}
+ for seq_field in sequence_fields:
+ if not journal[seq_field]:
+ vals = {
+ 'name': _('Securisation of %s - %s') % (seq_field, journal.name),
+ 'code': 'SECUR%s-%s' % (journal.id, seq_field),
+ 'implementation': 'no_gap',
+ 'prefix': '',
+ 'suffix': '',
+ 'padding': 0,
+ 'company_id': journal.company_id.id}
+ seq = self.env['ir.sequence'].create(vals)
+ vals_write[seq_field] = seq.id
+ if vals_write:
+ journal.write(vals_write)
+
+ # -------------------------------------------------------------------------
+ # REPORTING METHODS
+ # -------------------------------------------------------------------------
+
+ def _get_journal_bank_account_balance(self, domain=None):
+ ''' Get the bank balance of the current journal by filtering the journal items using the journal's accounts.
+
+ /!\ The current journal is not part of the applied domain. This is the expected behavior since we only want
+ a logic based on accounts.
+
+ :param domain: An additional domain to be applied on the account.move.line model.
+ :return: Tuple having balance expressed in journal's currency
+ along with the total number of move lines having the same account as of the journal's default account.
+ '''
+ self.ensure_one()
+ self.env['account.move.line'].check_access_rights('read')
+
+ if not self.default_account_id:
+ return 0.0, 0
+
+ domain = (domain or []) + [
+ ('account_id', 'in', tuple(self.default_account_id.ids)),
+ ('display_type', 'not in', ('line_section', 'line_note')),
+ ('move_id.state', '!=', 'cancel'),
+ ]
+ query = self.env['account.move.line']._where_calc(domain)
+ tables, where_clause, where_params = query.get_sql()
+
+ query = '''
+ SELECT
+ COUNT(account_move_line.id) AS nb_lines,
+ COALESCE(SUM(account_move_line.balance), 0.0),
+ COALESCE(SUM(account_move_line.amount_currency), 0.0)
+ FROM ''' + tables + '''
+ WHERE ''' + where_clause + '''
+ '''
+
+ company_currency = self.company_id.currency_id
+ journal_currency = self.currency_id if self.currency_id and self.currency_id != company_currency else False
+
+ self._cr.execute(query, where_params)
+ nb_lines, balance, amount_currency = self._cr.fetchone()
+ return amount_currency if journal_currency else balance, nb_lines
+
+ def _get_journal_outstanding_payments_account_balance(self, domain=None, date=None):
+ ''' Get the outstanding payments balance of the current journal by filtering the journal items using the
+ journal's accounts.
+
+ :param domain: An additional domain to be applied on the account.move.line model.
+ :param date: The date to be used when performing the currency conversions.
+ :return: The balance expressed in the journal's currency.
+ '''
+ self.ensure_one()
+ self.env['account.move.line'].check_access_rights('read')
+ conversion_date = date or fields.Date.context_today(self)
+
+ accounts = self.payment_debit_account_id + self.payment_credit_account_id
+ if not accounts:
+ return 0.0, 0
+
+ # Allow user managing payments without any statement lines.
+ # In that case, the user manages transactions only using the register payment wizard.
+ if self.default_account_id in accounts:
+ return 0.0, 0
+
+ domain = (domain or []) + [
+ ('account_id', 'in', tuple(accounts.ids)),
+ ('display_type', 'not in', ('line_section', 'line_note')),
+ ('move_id.state', '!=', 'cancel'),
+ ('reconciled', '=', False),
+ ('journal_id', '=', self.id),
+ ]
+ query = self.env['account.move.line']._where_calc(domain)
+ tables, where_clause, where_params = query.get_sql()
+
+ self._cr.execute('''
+ SELECT
+ COUNT(account_move_line.id) AS nb_lines,
+ account_move_line.currency_id,
+ account.reconcile AS is_account_reconcile,
+ SUM(account_move_line.amount_residual) AS amount_residual,
+ SUM(account_move_line.balance) AS balance,
+ SUM(account_move_line.amount_residual_currency) AS amount_residual_currency,
+ SUM(account_move_line.amount_currency) AS amount_currency
+ FROM ''' + tables + '''
+ JOIN account_account account ON account.id = account_move_line.account_id
+ WHERE ''' + where_clause + '''
+ GROUP BY account_move_line.currency_id, account.reconcile
+ ''', where_params)
+
+ company_currency = self.company_id.currency_id
+ journal_currency = self.currency_id if self.currency_id and self.currency_id != company_currency else False
+ balance_currency = journal_currency or company_currency
+
+ total_balance = 0.0
+ nb_lines = 0
+ for res in self._cr.dictfetchall():
+ nb_lines += res['nb_lines']
+
+ amount_currency = res['amount_residual_currency'] if res['is_account_reconcile'] else res['amount_currency']
+ balance = res['amount_residual'] if res['is_account_reconcile'] else res['balance']
+
+ if res['currency_id'] and journal_currency and res['currency_id'] == journal_currency.id:
+ total_balance += amount_currency
+ elif journal_currency:
+ total_balance += company_currency._convert(balance, balance_currency, self.company_id, conversion_date)
+ else:
+ total_balance += balance
+ return total_balance, nb_lines
+
+ def _get_last_bank_statement(self, domain=None):
+ ''' Retrieve the last bank statement created using this journal.
+ :param domain: An additional domain to be applied on the account.bank.statement model.
+ :return: An account.bank.statement record or an empty recordset.
+ '''
+ self.ensure_one()
+ last_statement_domain = (domain or []) + [('journal_id', '=', self.id)]
+ last_st_line = self.env['account.bank.statement.line'].search(last_statement_domain, order='date desc, id desc', limit=1)
+ return last_st_line.statement_id
diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py
new file mode 100644
index 00000000..53d922dc
--- /dev/null
+++ b/addons/account/models/account_journal_dashboard.py
@@ -0,0 +1,564 @@
+import json
+from datetime import datetime, timedelta
+
+from babel.dates import format_datetime, format_date
+from odoo import models, api, _, fields
+from odoo.osv import expression
+from odoo.release import version
+from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DF
+from odoo.tools.misc import formatLang, format_date as odoo_format_date, get_lang
+import random
+
+import ast
+
+
+class account_journal(models.Model):
+ _inherit = "account.journal"
+
+ def _kanban_dashboard(self):
+ for journal in self:
+ journal.kanban_dashboard = json.dumps(journal.get_journal_dashboard_datas())
+
+ def _kanban_dashboard_graph(self):
+ for journal in self:
+ if (journal.type in ['sale', 'purchase']):
+ journal.kanban_dashboard_graph = json.dumps(journal.get_bar_graph_datas())
+ elif (journal.type in ['cash', 'bank']):
+ journal.kanban_dashboard_graph = json.dumps(journal.get_line_graph_datas())
+ else:
+ journal.kanban_dashboard_graph = False
+
+ def _get_json_activity_data(self):
+ for journal in self:
+ activities = []
+ # search activity on move on the journal
+ sql_query = '''
+ SELECT act.id,
+ act.res_id,
+ act.res_model,
+ act.summary,
+ act_type.name as act_type_name,
+ act_type.category as activity_category,
+ act.date_deadline,
+ m.date,
+ m.ref,
+ CASE WHEN act.date_deadline < CURRENT_DATE THEN 'late' ELSE 'future' END as status
+ FROM account_move m
+ LEFT JOIN mail_activity act ON act.res_id = m.id
+ LEFT JOIN mail_activity_type act_type ON act.activity_type_id = act_type.id
+ WHERE act.res_model = 'account.move'
+ AND m.journal_id = %s
+ '''
+ self.env.cr.execute(sql_query, (journal.id,))
+ for activity in self.env.cr.dictfetchall():
+ act = {
+ 'id': activity.get('id'),
+ 'res_id': activity.get('res_id'),
+ 'res_model': activity.get('res_model'),
+ 'status': activity.get('status'),
+ 'name': (activity.get('summary') or activity.get('act_type_name')),
+ 'activity_category': activity.get('activity_category'),
+ 'date': odoo_format_date(self.env, activity.get('date_deadline'))
+ }
+ if activity.get('activity_category') == 'tax_report' and activity.get('res_model') == 'account.move':
+ act['name'] = activity.get('ref')
+
+ activities.append(act)
+ journal.json_activity_data = json.dumps({'activities': activities})
+
+ kanban_dashboard = fields.Text(compute='_kanban_dashboard')
+ kanban_dashboard_graph = fields.Text(compute='_kanban_dashboard_graph')
+ json_activity_data = fields.Text(compute='_get_json_activity_data')
+ show_on_dashboard = fields.Boolean(string='Show journal on dashboard', help="Whether this journal should be displayed on the dashboard or not", default=True)
+ color = fields.Integer("Color Index", default=0)
+
+ def _graph_title_and_key(self):
+ if self.type in ['sale', 'purchase']:
+ return ['', _('Residual amount')]
+ elif self.type == 'cash':
+ return ['', _('Cash: Balance')]
+ elif self.type == 'bank':
+ return ['', _('Bank: Balance')]
+
+ # Below method is used to get data of bank and cash statemens
+ def get_line_graph_datas(self):
+ """Computes the data used to display the graph for bank and cash journals in the accounting dashboard"""
+ currency = self.currency_id or self.company_id.currency_id
+
+ def build_graph_data(date, amount):
+ #display date in locale format
+ name = format_date(date, 'd LLLL Y', locale=locale)
+ short_name = format_date(date, 'd MMM', locale=locale)
+ return {'x':short_name,'y': amount, 'name':name}
+
+ self.ensure_one()
+ BankStatement = self.env['account.bank.statement']
+ data = []
+ today = datetime.today()
+ last_month = today + timedelta(days=-30)
+ locale = get_lang(self.env).code
+
+ #starting point of the graph is the last statement
+ last_stmt = self._get_last_bank_statement(domain=[('move_id.state', '=', 'posted')])
+
+ last_balance = last_stmt and last_stmt.balance_end_real or 0
+ data.append(build_graph_data(today, last_balance))
+
+ #then we subtract the total amount of bank statement lines per day to get the previous points
+ #(graph is drawn backward)
+ date = today
+ amount = last_balance
+ query = '''
+ SELECT move.date, sum(st_line.amount) as amount
+ FROM account_bank_statement_line st_line
+ JOIN account_move move ON move.id = st_line.move_id
+ WHERE move.journal_id = %s
+ AND move.date > %s
+ AND move.date <= %s
+ GROUP BY move.date
+ ORDER BY move.date desc
+ '''
+ self.env.cr.execute(query, (self.id, last_month, today))
+ query_result = self.env.cr.dictfetchall()
+ for val in query_result:
+ date = val['date']
+ if date != today.strftime(DF): # make sure the last point in the graph is today
+ data[:0] = [build_graph_data(date, amount)]
+ amount = currency.round(amount - val['amount'])
+
+ # make sure the graph starts 1 month ago
+ if date.strftime(DF) != last_month.strftime(DF):
+ data[:0] = [build_graph_data(last_month, amount)]
+
+ [graph_title, graph_key] = self._graph_title_and_key()
+ color = '#875A7B' if 'e' in version else '#7c7bad'
+
+ is_sample_data = not last_stmt and len(query_result) == 0
+ if is_sample_data:
+ data = []
+ for i in range(30, 0, -5):
+ current_date = today + timedelta(days=-i)
+ data.append(build_graph_data(current_date, random.randint(-5, 15)))
+
+ return [{'values': data, 'title': graph_title, 'key': graph_key, 'area': True, 'color': color, 'is_sample_data': is_sample_data}]
+
+ def get_bar_graph_datas(self):
+ data = []
+ today = fields.Datetime.now(self)
+ data.append({'label': _('Due'), 'value':0.0, 'type': 'past'})
+ day_of_week = int(format_datetime(today, 'e', locale=get_lang(self.env).code))
+ first_day_of_week = today + timedelta(days=-day_of_week+1)
+ for i in range(-1,4):
+ if i==0:
+ label = _('This Week')
+ elif i==3:
+ label = _('Not Due')
+ else:
+ start_week = first_day_of_week + timedelta(days=i*7)
+ end_week = start_week + timedelta(days=6)
+ if start_week.month == end_week.month:
+ label = str(start_week.day) + '-' + str(end_week.day) + ' ' + format_date(end_week, 'MMM', locale=get_lang(self.env).code)
+ else:
+ label = format_date(start_week, 'd MMM', locale=get_lang(self.env).code) + '-' + format_date(end_week, 'd MMM', locale=get_lang(self.env).code)
+ data.append({'label':label,'value':0.0, 'type': 'past' if i<0 else 'future'})
+
+ # Build SQL query to find amount aggregated by week
+ (select_sql_clause, query_args) = self._get_bar_graph_select_query()
+ query = ''
+ start_date = (first_day_of_week + timedelta(days=-7))
+ for i in range(0,6):
+ if i == 0:
+ query += "("+select_sql_clause+" and invoice_date_due < '"+start_date.strftime(DF)+"')"
+ elif i == 5:
+ query += " UNION ALL ("+select_sql_clause+" and invoice_date_due >= '"+start_date.strftime(DF)+"')"
+ else:
+ next_date = start_date + timedelta(days=7)
+ query += " UNION ALL ("+select_sql_clause+" and invoice_date_due >= '"+start_date.strftime(DF)+"' and invoice_date_due < '"+next_date.strftime(DF)+"')"
+ start_date = next_date
+
+ self.env.cr.execute(query, query_args)
+ query_results = self.env.cr.dictfetchall()
+ is_sample_data = True
+ for index in range(0, len(query_results)):
+ if query_results[index].get('aggr_date') != None:
+ is_sample_data = False
+ data[index]['value'] = query_results[index].get('total')
+
+ [graph_title, graph_key] = self._graph_title_and_key()
+
+ if is_sample_data:
+ for index in range(0, len(query_results)):
+ data[index]['type'] = 'o_sample_data'
+ # we use unrealistic values for the sample data
+ data[index]['value'] = random.randint(0, 20)
+ graph_key = _('Sample data')
+
+ return [{'values': data, 'title': graph_title, 'key': graph_key, 'is_sample_data': is_sample_data}]
+
+ def _get_bar_graph_select_query(self):
+ """
+ Returns a tuple containing the base SELECT SQL query used to gather
+ the bar graph's data as its first element, and the arguments dictionary
+ for it as its second.
+ """
+ sign = '' if self.type == 'sale' else '-'
+ return ('''
+ SELECT
+ ''' + sign + ''' + SUM(move.amount_residual_signed) AS total,
+ MIN(invoice_date_due) AS aggr_date
+ FROM account_move move
+ WHERE move.journal_id = %(journal_id)s
+ AND move.state = 'posted'
+ AND move.payment_state in ('not_paid', 'partial')
+ AND move.move_type IN %(invoice_types)s
+ ''', {
+ 'invoice_types': tuple(self.env['account.move'].get_invoice_types(True)),
+ 'journal_id': self.id
+ })
+
+ def get_journal_dashboard_datas(self):
+ currency = self.currency_id or self.company_id.currency_id
+ number_to_reconcile = number_to_check = last_balance = 0
+ has_at_least_one_statement = False
+ bank_account_balance = nb_lines_bank_account_balance = 0
+ outstanding_pay_account_balance = nb_lines_outstanding_pay_account_balance = 0
+ title = ''
+ number_draft = number_waiting = number_late = to_check_balance = 0
+ sum_draft = sum_waiting = sum_late = 0.0
+ if self.type in ('bank', 'cash'):
+ last_statement = self._get_last_bank_statement(
+ domain=[('move_id.state', '=', 'posted')])
+ last_balance = last_statement.balance_end
+ has_at_least_one_statement = bool(last_statement)
+ bank_account_balance, nb_lines_bank_account_balance = self._get_journal_bank_account_balance(
+ domain=[('move_id.state', '=', 'posted')])
+ outstanding_pay_account_balance, nb_lines_outstanding_pay_account_balance = self._get_journal_outstanding_payments_account_balance(
+ domain=[('move_id.state', '=', 'posted')])
+
+ self._cr.execute('''
+ SELECT COUNT(st_line.id)
+ FROM account_bank_statement_line st_line
+ JOIN account_move st_line_move ON st_line_move.id = st_line.move_id
+ JOIN account_bank_statement st ON st_line.statement_id = st.id
+ WHERE st_line_move.journal_id IN %s
+ AND st.state = 'posted'
+ AND NOT st_line.is_reconciled
+ ''', [tuple(self.ids)])
+ number_to_reconcile = self.env.cr.fetchone()[0]
+
+ to_check_ids = self.to_check_ids()
+ number_to_check = len(to_check_ids)
+ to_check_balance = sum([r.amount for r in to_check_ids])
+ #TODO need to check if all invoices are in the same currency than the journal!!!!
+ elif self.type in ['sale', 'purchase']:
+ title = _('Bills to pay') if self.type == 'purchase' else _('Invoices owed to you')
+ self.env['account.move'].flush(['amount_residual', 'currency_id', 'move_type', 'invoice_date', 'company_id', 'journal_id', 'date', 'state', 'payment_state'])
+
+ (query, query_args) = self._get_open_bills_to_pay_query()
+ self.env.cr.execute(query, query_args)
+ query_results_to_pay = self.env.cr.dictfetchall()
+
+ (query, query_args) = self._get_draft_bills_query()
+ self.env.cr.execute(query, query_args)
+ query_results_drafts = self.env.cr.dictfetchall()
+
+ today = fields.Date.context_today(self)
+ query = '''
+ SELECT
+ (CASE WHEN move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * amount_residual AS amount_total,
+ currency_id AS currency,
+ move_type,
+ invoice_date,
+ company_id
+ FROM account_move move
+ WHERE journal_id = %s
+ AND invoice_date_due <= %s
+ AND state = 'posted'
+ AND payment_state in ('not_paid', 'partial')
+ AND move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt');
+ '''
+ self.env.cr.execute(query, (self.id, today))
+ late_query_results = self.env.cr.dictfetchall()
+ curr_cache = {}
+ (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency, curr_cache=curr_cache)
+ (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency, curr_cache=curr_cache)
+ (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency, curr_cache=curr_cache)
+ read = self.env['account.move'].read_group([('journal_id', '=', self.id), ('to_check', '=', True)], ['amount_total'], 'journal_id', lazy=False)
+ if read:
+ number_to_check = read[0]['__count']
+ to_check_balance = read[0]['amount_total']
+ elif self.type == 'general':
+ read = self.env['account.move'].read_group([('journal_id', '=', self.id), ('to_check', '=', True)], ['amount_total'], 'journal_id', lazy=False)
+ if read:
+ number_to_check = read[0]['__count']
+ to_check_balance = read[0]['amount_total']
+
+ is_sample_data = self.kanban_dashboard_graph and any(data.get('is_sample_data', False) for data in json.loads(self.kanban_dashboard_graph))
+
+ return {
+ 'number_to_check': number_to_check,
+ 'to_check_balance': formatLang(self.env, to_check_balance, currency_obj=currency),
+ 'number_to_reconcile': number_to_reconcile,
+ 'account_balance': formatLang(self.env, currency.round(bank_account_balance), currency_obj=currency),
+ 'has_at_least_one_statement': has_at_least_one_statement,
+ 'nb_lines_bank_account_balance': nb_lines_bank_account_balance,
+ 'outstanding_pay_account_balance': formatLang(self.env, currency.round(outstanding_pay_account_balance), currency_obj=currency),
+ 'nb_lines_outstanding_pay_account_balance': nb_lines_outstanding_pay_account_balance,
+ 'last_balance': formatLang(self.env, currency.round(last_balance) + 0.0, currency_obj=currency),
+ 'number_draft': number_draft,
+ 'number_waiting': number_waiting,
+ 'number_late': number_late,
+ 'sum_draft': formatLang(self.env, currency.round(sum_draft) + 0.0, currency_obj=currency),
+ 'sum_waiting': formatLang(self.env, currency.round(sum_waiting) + 0.0, currency_obj=currency),
+ 'sum_late': formatLang(self.env, currency.round(sum_late) + 0.0, currency_obj=currency),
+ 'currency_id': currency.id,
+ 'bank_statements_source': self.bank_statements_source,
+ 'title': title,
+ 'is_sample_data': is_sample_data,
+ 'company_count': len(self.env.companies)
+ }
+
+ def _get_open_bills_to_pay_query(self):
+ """
+ Returns a tuple containing the SQL query used to gather the open bills
+ data as its first element, and the arguments dictionary to use to run
+ it as its second.
+ """
+ return ('''
+ SELECT
+ (CASE WHEN move.move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * move.amount_residual AS amount_total,
+ move.currency_id AS currency,
+ move.move_type,
+ move.invoice_date,
+ move.company_id
+ FROM account_move move
+ WHERE move.journal_id = %(journal_id)s
+ AND move.state = 'posted'
+ AND move.payment_state in ('not_paid', 'partial')
+ AND move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt');
+ ''', {'journal_id': self.id})
+
+ def _get_draft_bills_query(self):
+ """
+ Returns a tuple containing as its first element the SQL query used to
+ gather the bills in draft state data, and the arguments
+ dictionary to use to run it as its second.
+ """
+ return ('''
+ SELECT
+ (CASE WHEN move.move_type IN ('out_refund', 'in_refund') THEN -1 ELSE 1 END) * move.amount_total AS amount_total,
+ move.currency_id AS currency,
+ move.move_type,
+ move.invoice_date,
+ move.company_id
+ FROM account_move move
+ WHERE move.journal_id = %(journal_id)s
+ AND move.state = 'draft'
+ AND move.payment_state in ('not_paid', 'partial')
+ AND move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt');
+ ''', {'journal_id': self.id})
+
+ def _count_results_and_sum_amounts(self, results_dict, target_currency, curr_cache=None):
+ """ Loops on a query result to count the total number of invoices and sum
+ their amount_total field (expressed in the given target currency).
+ amount_total must be signed !
+ """
+ rslt_count = 0
+ rslt_sum = 0.0
+ # Create a cache with currency rates to avoid unnecessary SQL requests. Do not copy
+ # curr_cache on purpose, so the dictionary is modified and can be re-used for subsequent
+ # calls of the method.
+ curr_cache = {} if curr_cache is None else curr_cache
+ for result in results_dict:
+ cur = self.env['res.currency'].browse(result.get('currency'))
+ company = self.env['res.company'].browse(result.get('company_id')) or self.env.company
+ rslt_count += 1
+ date = result.get('invoice_date') or fields.Date.context_today(self)
+
+ amount = result.get('amount_total', 0) or 0
+ if cur != target_currency:
+ key = (cur, target_currency, company, date)
+ # Using setdefault will call _get_conversion_rate, so we explicitly check the
+ # existence of the key in the cache instead.
+ if key not in curr_cache:
+ curr_cache[key] = self.env['res.currency']._get_conversion_rate(*key)
+ amount *= curr_cache[key]
+ rslt_sum += target_currency.round(amount)
+ return (rslt_count, rslt_sum)
+
+ def action_create_new(self):
+ ctx = self._context.copy()
+ ctx['default_journal_id'] = self.id
+ if self.type == 'sale':
+ ctx['default_move_type'] = 'out_refund' if ctx.get('refund') else 'out_invoice'
+ elif self.type == 'purchase':
+ ctx['default_move_type'] = 'in_refund' if ctx.get('refund') else 'in_invoice'
+ else:
+ ctx['default_move_type'] = 'entry'
+ ctx['view_no_maturity'] = True
+ return {
+ 'name': _('Create invoice/bill'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'account.move',
+ 'view_id': self.env.ref('account.view_move_form').id,
+ 'context': ctx,
+ }
+
+ def create_cash_statement(self):
+ ctx = self._context.copy()
+ ctx.update({'journal_id': self.id, 'default_journal_id': self.id, 'default_journal_type': 'cash'})
+ open_statements = self.env['account.bank.statement'].search([('journal_id', '=', self.id), ('state', '=', 'open')])
+ action = {
+ 'name': _('Create cash statement'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'account.bank.statement',
+ 'context': ctx,
+ }
+ if len(open_statements) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': open_statements.id,
+ })
+ elif len(open_statements) > 1:
+ action.update({
+ 'view_mode': 'tree,form',
+ 'domain': [('id', 'in', open_statements.ids)],
+ })
+ return action
+
+ def to_check_ids(self):
+ self.ensure_one()
+ domain = self.env['account.move.line']._get_suspense_moves_domain()
+ domain.append(('journal_id', '=', self.id))
+ statement_line_ids = self.env['account.move.line'].search(domain).mapped('statement_line_id')
+ return statement_line_ids
+
+ def _select_action_to_open(self):
+ self.ensure_one()
+ if self._context.get('action_name'):
+ return self._context.get('action_name')
+ elif self.type == 'bank':
+ return 'action_bank_statement_tree'
+ elif self.type == 'cash':
+ return 'action_view_bank_statement_tree'
+ elif self.type == 'sale':
+ return 'action_move_out_invoice_type'
+ elif self.type == 'purchase':
+ return 'action_move_in_invoice_type'
+ else:
+ return 'action_move_journal_line'
+
+ def open_action(self):
+ """return action based on type for related journals"""
+ self.ensure_one()
+ action_name = self._select_action_to_open()
+
+ # Set 'account.' prefix if missing.
+ if not action_name.startswith("account."):
+ action_name = 'account.%s' % action_name
+
+ action = self.env["ir.actions.act_window"]._for_xml_id(action_name)
+
+ context = self._context.copy()
+ if 'context' in action and type(action['context']) == str:
+ context.update(ast.literal_eval(action['context']))
+ else:
+ context.update(action.get('context', {}))
+ action['context'] = context
+ action['context'].update({
+ 'default_journal_id': self.id,
+ 'search_default_journal_id': self.id,
+ })
+
+ domain_type_field = action['res_model'] == 'account.move.line' and 'move_id.move_type' or 'move_type' # The model can be either account.move or account.move.line
+
+ # Override the domain only if the action was not explicitly specified in order to keep the
+ # original action domain.
+ if not self._context.get('action_name'):
+ if self.type == 'sale':
+ action['domain'] = [(domain_type_field, 'in', ('out_invoice', 'out_refund', 'out_receipt'))]
+ elif self.type == 'purchase':
+ action['domain'] = [(domain_type_field, 'in', ('in_invoice', 'in_refund', 'in_receipt', 'entry'))]
+
+ return action
+
+ def open_spend_money(self):
+ return self.open_payments_action('outbound')
+
+ def open_collect_money(self):
+ return self.open_payments_action('inbound')
+
+ def open_transfer_money(self):
+ return self.open_payments_action('transfer')
+
+ def open_payments_action(self, payment_type, mode='tree'):
+ if payment_type == 'outbound':
+ action_ref = 'account.action_account_payments_payable'
+ elif payment_type == 'transfer':
+ action_ref = 'account.action_account_payments_transfer'
+ else:
+ action_ref = 'account.action_account_payments'
+ action = self.env['ir.actions.act_window']._for_xml_id(action_ref)
+ action['context'] = dict(ast.literal_eval(action.get('context')), default_journal_id=self.id, search_default_journal_id=self.id)
+ if payment_type == 'transfer':
+ action['context'].update({
+ 'default_partner_id': self.company_id.partner_id.id,
+ 'default_is_internal_transfer': True,
+ })
+ if mode == 'form':
+ action['views'] = [[False, 'form']]
+ return action
+
+ def open_action_with_context(self):
+ action_name = self.env.context.get('action_name', False)
+ if not action_name:
+ return False
+ ctx = dict(self.env.context, default_journal_id=self.id)
+ if ctx.get('search_default_journal', False):
+ ctx.update(search_default_journal_id=self.id)
+ ctx['search_default_journal'] = False # otherwise it will do a useless groupby in bank statements
+ ctx.pop('group_by', None)
+ action = self.env['ir.actions.act_window']._for_xml_id(f"account.{action_name}")
+ action['context'] = ctx
+ if ctx.get('use_domain', False):
+ action['domain'] = isinstance(ctx['use_domain'], list) and ctx['use_domain'] or ['|', ('journal_id', '=', self.id), ('journal_id', '=', False)]
+ action['name'] = _(
+ "%(action)s for journal %(journal)s",
+ action=action["name"],
+ journal=self.name,
+ )
+ return action
+
+ def create_bank_statement(self):
+ """return action to create a bank statements. This button should be called only on journals with type =='bank'"""
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_bank_statement_tree")
+ action.update({
+ 'views': [[False, 'form']],
+ 'context': "{'default_journal_id': " + str(self.id) + "}",
+ })
+ return action
+
+ def create_customer_payment(self):
+ """return action to create a customer payment"""
+ return self.open_payments_action('inbound', mode='form')
+
+ def create_supplier_payment(self):
+ """return action to create a supplier payment"""
+ return self.open_payments_action('outbound', mode='form')
+
+ def create_internal_transfer(self):
+ """return action to create a internal transfer"""
+ return self.open_payments_action('transfer', mode='form')
+
+ #####################
+ # Setup Steps Stuff #
+ #####################
+ def mark_bank_setup_as_done_action(self):
+ """ Marks the 'bank setup' step as done in the setup bar and in the company."""
+ self.company_id.sudo().set_onboarding_step_done('account_setup_bank_data_state')
+
+ def unmark_bank_setup_as_done_action(self):
+ """ Marks the 'bank setup' step as not done in the setup bar and in the company."""
+ self.company_id.account_setup_bank_data_state = 'not_done'
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
new file mode 100644
index 00000000..477b8171
--- /dev/null
+++ b/addons/account/models/account_move.py
@@ -0,0 +1,5082 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.exceptions import RedirectWarning, UserError, ValidationError, AccessError
+from odoo.tools import float_compare, date_utils, email_split, email_re
+from odoo.tools.misc import formatLang, format_date, get_lang
+
+from datetime import date, timedelta
+from collections import defaultdict
+from itertools import zip_longest
+from hashlib import sha256
+from json import dumps
+
+import ast
+import json
+import re
+import warnings
+
+#forbidden fields
+INTEGRITY_HASH_MOVE_FIELDS = ('date', 'journal_id', 'company_id')
+INTEGRITY_HASH_LINE_FIELDS = ('debit', 'credit', 'account_id', 'partner_id')
+
+
+def calc_check_digits(number):
+ """Calculate the extra digits that should be appended to the number to make it a valid number.
+ Source: python-stdnum iso7064.mod_97_10.calc_check_digits
+ """
+ number_base10 = ''.join(str(int(x, 36)) for x in number)
+ checksum = int(number_base10) % 97
+ return '%02d' % ((98 - 100 * checksum) % 97)
+
+
+class AccountMove(models.Model):
+ _name = "account.move"
+ _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'sequence.mixin']
+ _description = "Journal Entry"
+ _order = 'date desc, name desc, id desc'
+ _mail_post_access = 'read'
+ _check_company_auto = True
+ _sequence_index = "journal_id"
+
+ @property
+ def _sequence_monthly_regex(self):
+ return self.journal_id.sequence_override_regex or super()._sequence_monthly_regex
+
+ @property
+ def _sequence_yearly_regex(self):
+ return self.journal_id.sequence_override_regex or super()._sequence_yearly_regex
+
+ @property
+ def _sequence_fixed_regex(self):
+ return self.journal_id.sequence_override_regex or super()._sequence_fixed_regex
+
+ @api.model
+ def _search_default_journal(self, journal_types):
+ company_id = self._context.get('default_company_id', self.env.company.id)
+ domain = [('company_id', '=', company_id), ('type', 'in', journal_types)]
+
+ journal = None
+ if self._context.get('default_currency_id'):
+ currency_domain = domain + [('currency_id', '=', self._context['default_currency_id'])]
+ journal = self.env['account.journal'].search(currency_domain, limit=1)
+
+ if not journal:
+ journal = self.env['account.journal'].search(domain, limit=1)
+
+ if not journal:
+ company = self.env['res.company'].browse(company_id)
+
+ error_msg = _(
+ "No journal could be found in company %(company_name)s for any of those types: %(journal_types)s",
+ company_name=company.display_name,
+ journal_types=', '.join(journal_types),
+ )
+ raise UserError(error_msg)
+
+ return journal
+
+ @api.model
+ def _get_default_journal(self):
+ ''' Get the default journal.
+ It could either be passed through the context using the 'default_journal_id' key containing its id,
+ either be determined by the default type.
+ '''
+ move_type = self._context.get('default_move_type', 'entry')
+ if move_type in self.get_sale_types(include_receipts=True):
+ journal_types = ['sale']
+ elif move_type in self.get_purchase_types(include_receipts=True):
+ journal_types = ['purchase']
+ else:
+ journal_types = self._context.get('default_move_journal_types', ['general'])
+
+ if self._context.get('default_journal_id'):
+ journal = self.env['account.journal'].browse(self._context['default_journal_id'])
+
+ if move_type != 'entry' and journal.type not in journal_types:
+ raise UserError(_(
+ "Cannot create an invoice of type %(move_type)s with a journal having %(journal_type)s as type.",
+ move_type=move_type,
+ journal_type=journal.type,
+ ))
+ else:
+ journal = self._search_default_journal(journal_types)
+
+ return journal
+
+ # TODO remove in master
+ @api.model
+ def _get_default_invoice_date(self):
+ warnings.warn("Method '_get_default_invoice_date()' is deprecated and has been removed.", DeprecationWarning)
+ return fields.Date.context_today(self) if self._context.get('default_move_type', 'entry') in self.get_purchase_types(include_receipts=True) else False
+
+ @api.model
+ def _get_default_currency(self):
+ ''' Get the default currency from either the journal, either the default journal's company. '''
+ journal = self._get_default_journal()
+ return journal.currency_id or journal.company_id.currency_id
+
+ @api.model
+ def _get_default_invoice_incoterm(self):
+ ''' Get the default incoterm for invoice. '''
+ return self.env.company.incoterm_id
+
+ # ==== Business fields ====
+ name = fields.Char(string='Number', copy=False, compute='_compute_name', readonly=False, store=True, index=True, tracking=True)
+ highest_name = fields.Char(compute='_compute_highest_name')
+ show_name_warning = fields.Boolean(store=False)
+ date = fields.Date(
+ string='Date',
+ required=True,
+ index=True,
+ readonly=True,
+ states={'draft': [('readonly', False)]},
+ copy=False,
+ default=fields.Date.context_today
+ )
+ ref = fields.Char(string='Reference', copy=False, tracking=True)
+ narration = fields.Text(string='Terms and Conditions')
+ state = fields.Selection(selection=[
+ ('draft', 'Draft'),
+ ('posted', 'Posted'),
+ ('cancel', 'Cancelled'),
+ ], string='Status', required=True, readonly=True, copy=False, tracking=True,
+ default='draft')
+ posted_before = fields.Boolean(help="Technical field for knowing if the move has been posted before", copy=False)
+ move_type = fields.Selection(selection=[
+ ('entry', 'Journal Entry'),
+ ('out_invoice', 'Customer Invoice'),
+ ('out_refund', 'Customer Credit Note'),
+ ('in_invoice', 'Vendor Bill'),
+ ('in_refund', 'Vendor Credit Note'),
+ ('out_receipt', 'Sales Receipt'),
+ ('in_receipt', 'Purchase Receipt'),
+ ], string='Type', required=True, store=True, index=True, readonly=True, tracking=True,
+ default="entry", change_default=True)
+ type_name = fields.Char('Type Name', compute='_compute_type_name')
+ to_check = fields.Boolean(string='To Check', default=False,
+ help='If this checkbox is ticked, it means that the user was not sure of all the related information at the time of the creation of the move and that the move needs to be checked again.')
+ journal_id = fields.Many2one('account.journal', string='Journal', required=True, readonly=True,
+ states={'draft': [('readonly', False)]},
+ check_company=True, domain="[('id', 'in', suitable_journal_ids)]",
+ default=_get_default_journal)
+ suitable_journal_ids = fields.Many2many('account.journal', compute='_compute_suitable_journal_ids')
+ company_id = fields.Many2one(comodel_name='res.company', string='Company',
+ store=True, readonly=True,
+ compute='_compute_company_id')
+ company_currency_id = fields.Many2one(string='Company Currency', readonly=True,
+ related='company_id.currency_id')
+ currency_id = fields.Many2one('res.currency', store=True, readonly=True, tracking=True, required=True,
+ states={'draft': [('readonly', False)]},
+ string='Currency',
+ default=_get_default_currency)
+ line_ids = fields.One2many('account.move.line', 'move_id', string='Journal Items', copy=True, readonly=True,
+ states={'draft': [('readonly', False)]})
+ partner_id = fields.Many2one('res.partner', readonly=True, tracking=True,
+ states={'draft': [('readonly', False)]},
+ check_company=True,
+ string='Partner', change_default=True)
+ commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', store=True, readonly=True,
+ compute='_compute_commercial_partner_id')
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
+ user_id = fields.Many2one(string='User', related='invoice_user_id',
+ help='Technical field used to fit the generic behavior in mail templates.')
+ is_move_sent = fields.Boolean(
+ readonly=True,
+ default=False,
+ copy=False,
+ tracking=True,
+ help="It indicates that the invoice/payment has been sent.",
+ )
+ partner_bank_id = fields.Many2one('res.partner.bank', string='Recipient Bank',
+ help='Bank Account Number to which the invoice will be paid. A Company bank account if this is a Customer Invoice or Vendor Credit Note, otherwise a Partner bank account number.',
+ check_company=True)
+ payment_reference = fields.Char(string='Payment Reference', index=True, copy=False,
+ help="The payment reference to set on journal items.")
+ payment_id = fields.Many2one(
+ index=True,
+ comodel_name='account.payment',
+ string="Payment", copy=False, check_company=True)
+ statement_line_id = fields.Many2one(
+ comodel_name='account.bank.statement.line',
+ string="Statement Line", copy=False, check_company=True)
+
+ # === Amount fields ===
+ amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, tracking=True,
+ compute='_compute_amount')
+ amount_tax = fields.Monetary(string='Tax', store=True, readonly=True,
+ compute='_compute_amount')
+ amount_total = fields.Monetary(string='Total', store=True, readonly=True,
+ compute='_compute_amount',
+ inverse='_inverse_amount_total')
+ amount_residual = fields.Monetary(string='Amount Due', store=True,
+ compute='_compute_amount')
+ amount_untaxed_signed = fields.Monetary(string='Untaxed Amount Signed', store=True, readonly=True,
+ compute='_compute_amount', currency_field='company_currency_id')
+ amount_tax_signed = fields.Monetary(string='Tax Signed', store=True, readonly=True,
+ compute='_compute_amount', currency_field='company_currency_id')
+ amount_total_signed = fields.Monetary(string='Total Signed', store=True, readonly=True,
+ compute='_compute_amount', currency_field='company_currency_id')
+ amount_residual_signed = fields.Monetary(string='Amount Due Signed', store=True,
+ compute='_compute_amount', currency_field='company_currency_id')
+ amount_by_group = fields.Binary(string="Tax amount by group",
+ compute='_compute_invoice_taxes_by_group',
+ help='Edit Tax amounts if you encounter rounding issues.')
+ payment_state = fields.Selection(selection=[
+ ('not_paid', 'Not Paid'),
+ ('in_payment', 'In Payment'),
+ ('paid', 'Paid'),
+ ('partial', 'Partially Paid'),
+ ('reversed', 'Reversed'),
+ ('invoicing_legacy', 'Invoicing App Legacy')],
+ string="Payment Status", store=True, readonly=True, copy=False, tracking=True,
+ compute='_compute_amount')
+
+ # ==== Cash basis feature fields ====
+ 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.")
+ 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, copy=False,
+ help='If this checkbox is ticked, this entry will be automatically posted at its date.')
+
+ # ==== Reverse feature fields ====
+ reversed_entry_id = fields.Many2one('account.move', string="Reversal of", readonly=True, copy=False,
+ check_company=True)
+ reversal_move_id = fields.One2many('account.move', 'reversed_entry_id')
+
+ # =========================================================
+ # Invoice related fields
+ # =========================================================
+
+ # ==== Business fields ====
+ fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', readonly=True,
+ states={'draft': [('readonly', False)]},
+ check_company=True,
+ domain="[('company_id', '=', company_id)]", ondelete="restrict",
+ help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices. "
+ "The default value comes from the customer.")
+ invoice_user_id = fields.Many2one('res.users', copy=False, tracking=True,
+ string='Salesperson',
+ default=lambda self: self.env.user)
+ invoice_date = fields.Date(string='Invoice/Bill Date', readonly=True, index=True, copy=False,
+ states={'draft': [('readonly', False)]})
+ invoice_date_due = fields.Date(string='Due Date', readonly=True, index=True, copy=False,
+ states={'draft': [('readonly', False)]})
+ invoice_origin = fields.Char(string='Origin', readonly=True, tracking=True,
+ help="The document(s) that generated the invoice.")
+ invoice_payment_term_id = fields.Many2one('account.payment.term', string='Payment Terms',
+ check_company=True,
+ readonly=True, states={'draft': [('readonly', False)]})
+ # /!\ invoice_line_ids is just a subset of line_ids.
+ invoice_line_ids = fields.One2many('account.move.line', 'move_id', string='Invoice lines',
+ copy=False, readonly=True,
+ domain=[('exclude_from_invoice_tab', '=', False)],
+ states={'draft': [('readonly', False)]})
+ invoice_incoterm_id = fields.Many2one('account.incoterms', string='Incoterm',
+ default=_get_default_invoice_incoterm,
+ help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
+ display_qr_code = fields.Boolean(string="Display QR-code", related='company_id.qr_code')
+ qr_code_method = fields.Selection(string="Payment QR-code",
+ selection=lambda self: self.env['res.partner.bank'].get_available_qr_methods_in_sequence(),
+ help="Type of QR-code to be generated for the payment of this invoice, when printing it. If left blank, the first available and usable method will be used.")
+
+ # ==== 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_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,
+ check_company=True,
+ string='Vendor Bill',
+ help="Auto-complete from a past bill.")
+ invoice_source_email = fields.Char(string='Source Email', tracking=True)
+ invoice_partner_display_name = fields.Char(compute='_compute_invoice_partner_display_info', store=True)
+
+ # ==== Cash rounding fields ====
+ invoice_cash_rounding_id = fields.Many2one('account.cash.rounding', string='Cash Rounding Method',
+ readonly=True, states={'draft': [('readonly', False)]},
+ help='Defines the smallest coinage of the currency that can be used to pay by cash.')
+
+ # ==== Display purpose fields ====
+ invoice_filter_type_domain = fields.Char(compute='_compute_invoice_filter_type_domain',
+ help="Technical field used to have a dynamic domain on journal / taxes in the form view.")
+ bank_partner_id = fields.Many2one('res.partner', help='Technical field to get the domain on the bank', compute='_compute_bank_partner_id')
+ invoice_has_matching_suspense_amount = fields.Boolean(compute='_compute_has_matching_suspense_amount',
+ groups='account.group_account_invoice,account.group_account_readonly',
+ help="Technical field used to display an alert on invoices if there is at least a matching amount in any supsense account.")
+ tax_lock_date_message = fields.Char(
+ compute='_compute_tax_lock_date_message',
+ help="Technical field used to display a message when the invoice's accounting date is prior of the tax lock date.")
+ # Technical field to hide Reconciled Entries stat button
+ has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries")
+ show_reset_to_draft_button = fields.Boolean(compute='_compute_show_reset_to_draft_button')
+
+ # ==== Hash Fields ====
+ restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table')
+ secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False)
+ inalterable_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False)
+ string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True)
+
+ @api.model
+ def _field_will_change(self, record, vals, field_name):
+ if field_name not in vals:
+ return False
+ field = record._fields[field_name]
+ if field.type == 'many2one':
+ return record[field_name].id != vals[field_name]
+ if field.type == 'many2many':
+ current_ids = set(record[field_name].ids)
+ after_write_ids = set(record.new({field_name: vals[field_name]})[field_name].ids)
+ return current_ids != after_write_ids
+ if field.type == 'one2many':
+ return True
+ if field.type == 'monetary' and record[field.currency_field]:
+ return not record[field.currency_field].is_zero(record[field_name] - vals[field_name])
+ if field.type == 'float':
+ record_value = field.convert_to_cache(record[field_name], record)
+ to_write_value = field.convert_to_cache(vals[field_name], record)
+ return record_value != to_write_value
+ return record[field_name] != vals[field_name]
+
+ @api.model
+ def _cleanup_write_orm_values(self, record, vals):
+ cleaned_vals = dict(vals)
+ for field_name, value in vals.items():
+ if not self._field_will_change(record, vals, field_name):
+ del cleaned_vals[field_name]
+ return cleaned_vals
+
+ # -------------------------------------------------------------------------
+ # ONCHANGE METHODS
+ # -------------------------------------------------------------------------
+
+ def _get_accounting_date(self, invoice_date, has_tax):
+ """Get correct accounting date for previous periods, taking tax lock date into account.
+
+ When registering an invoice in the past, we still want the sequence to be increasing.
+ We then take the last day of the period, depending on the sequence format.
+ If there is a tax lock date and there are taxes involved, we register the invoice at the
+ last date of the first open period.
+
+ :param invoice_date (datetime.date): The invoice date
+ :param has_tax (bool): Iff any taxes are involved in the lines of the invoice
+ :return (datetime.date):
+ """
+ tax_lock_date = self.company_id.tax_lock_date
+ today = fields.Date.today()
+ if invoice_date and tax_lock_date and has_tax and invoice_date <= tax_lock_date:
+ invoice_date = tax_lock_date + timedelta(days=1)
+
+ if self.is_sale_document(include_receipts=True):
+ return invoice_date
+ elif self.is_purchase_document(include_receipts=True):
+ highest_name = self.highest_name or self._get_last_sequence(relaxed=True)
+ number_reset = self._deduce_sequence_number_reset(highest_name)
+ if not highest_name or number_reset == 'month':
+ if (today.year, today.month) > (invoice_date.year, invoice_date.month):
+ return date_utils.get_month(invoice_date)[1]
+ else:
+ return max(invoice_date, today)
+ elif number_reset == 'year':
+ if today.year > invoice_date.year:
+ return date(invoice_date.year, 12, 31)
+ else:
+ return max(invoice_date, today)
+ return invoice_date
+
+ @api.onchange('invoice_date', 'highest_name', 'company_id')
+ def _onchange_invoice_date(self):
+ if self.invoice_date:
+ if not self.invoice_payment_term_id and (not self.invoice_date_due or self.invoice_date_due < self.invoice_date):
+ self.invoice_date_due = self.invoice_date
+
+ has_tax = bool(self.line_ids.tax_ids or self.line_ids.tax_tag_ids)
+ accounting_date = self._get_accounting_date(self.invoice_date, has_tax)
+ if accounting_date != self.date:
+ self.date = accounting_date
+ self._onchange_currency()
+
+ @api.onchange('journal_id')
+ def _onchange_journal(self):
+ if self.journal_id and self.journal_id.currency_id:
+ new_currency = self.journal_id.currency_id
+ if new_currency != self.currency_id:
+ self.currency_id = new_currency
+ self._onchange_currency()
+ if self.state == 'draft' and self._get_last_sequence() and self.name and self.name != '/':
+ self.name = '/'
+
+ @api.onchange('partner_id')
+ def _onchange_partner_id(self):
+ self = self.with_company(self.journal_id.company_id)
+
+ warning = {}
+ if self.partner_id:
+ rec_account = self.partner_id.property_account_receivable_id
+ pay_account = self.partner_id.property_account_payable_id
+ if not rec_account and not pay_account:
+ action = self.env.ref('account.action_account_config')
+ msg = _('Cannot find a chart of accounts for this company, You should configure it. \nPlease go to Account Configuration.')
+ raise RedirectWarning(msg, action.id, _('Go to the configuration panel'))
+ p = self.partner_id
+ if p.invoice_warn == 'no-message' and p.parent_id:
+ p = p.parent_id
+ if p.invoice_warn and p.invoice_warn != 'no-message':
+ # Block if partner only has warning but parent company is blocked
+ if p.invoice_warn != 'block' and p.parent_id and p.parent_id.invoice_warn == 'block':
+ p = p.parent_id
+ warning = {
+ 'title': _("Warning for %s", p.name),
+ 'message': p.invoice_warn_msg
+ }
+ if p.invoice_warn == 'block':
+ self.partner_id = False
+ return {'warning': warning}
+
+ if self.is_sale_document(include_receipts=True) and self.partner_id:
+ self.invoice_payment_term_id = self.partner_id.property_payment_term_id or self.invoice_payment_term_id
+ new_term_account = self.partner_id.commercial_partner_id.property_account_receivable_id
+ elif self.is_purchase_document(include_receipts=True) and self.partner_id:
+ self.invoice_payment_term_id = self.partner_id.property_supplier_payment_term_id or self.invoice_payment_term_id
+ new_term_account = self.partner_id.commercial_partner_id.property_account_payable_id
+ else:
+ new_term_account = None
+
+ for line in self.line_ids:
+ line.partner_id = self.partner_id.commercial_partner_id
+
+ if new_term_account and line.account_id.user_type_id.type in ('receivable', 'payable'):
+ line.account_id = new_term_account
+
+ self._compute_bank_partner_id()
+ self.partner_bank_id = self.bank_partner_id.bank_ids and self.bank_partner_id.bank_ids[0]
+
+ # Find the new fiscal position.
+ delivery_partner_id = self._get_invoice_delivery_partner_id()
+ self.fiscal_position_id = self.env['account.fiscal.position'].get_fiscal_position(
+ self.partner_id.id, delivery_id=delivery_partner_id)
+ self._recompute_dynamic_lines()
+ if warning:
+ return {'warning': warning}
+
+ @api.onchange('date', 'currency_id')
+ def _onchange_currency(self):
+ currency = self.currency_id or self.company_id.currency_id
+
+ if self.is_invoice(include_receipts=True):
+ for line in self._get_lines_onchange_currency():
+ line.currency_id = currency
+ line._onchange_currency()
+ else:
+ for line in self.line_ids:
+ line._onchange_currency()
+
+ self._recompute_dynamic_lines(recompute_tax_base_amount=True)
+
+ @api.onchange('payment_reference')
+ def _onchange_payment_reference(self):
+ for line in self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')):
+ line.name = self.payment_reference or ''
+
+ @api.onchange('invoice_vendor_bill_id')
+ def _onchange_invoice_vendor_bill(self):
+ if self.invoice_vendor_bill_id:
+ # Copy invoice lines.
+ for line in self.invoice_vendor_bill_id.invoice_line_ids:
+ copied_vals = line.copy_data()[0]
+ copied_vals['move_id'] = self.id
+ new_line = self.env['account.move.line'].new(copied_vals)
+ new_line.recompute_tax_line = True
+
+ # Copy payment terms.
+ self.invoice_payment_term_id = self.invoice_vendor_bill_id.invoice_payment_term_id
+
+ # Copy currency.
+ if self.currency_id != self.invoice_vendor_bill_id.currency_id:
+ self.currency_id = self.invoice_vendor_bill_id.currency_id
+
+ # Reset
+ self.invoice_vendor_bill_id = False
+ self._recompute_dynamic_lines()
+
+ @api.onchange('move_type')
+ def _onchange_type(self):
+ ''' Onchange made to filter the partners depending of the type. '''
+ if self.is_sale_document(include_receipts=True):
+ if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'):
+ self.narration = self.company_id.invoice_terms or self.env.company.invoice_terms
+
+ @api.onchange('invoice_line_ids')
+ def _onchange_invoice_line_ids(self):
+ current_invoice_lines = self.line_ids.filtered(lambda line: not line.exclude_from_invoice_tab)
+ others_lines = self.line_ids - current_invoice_lines
+ if others_lines and current_invoice_lines - self.invoice_line_ids:
+ others_lines[0].recompute_tax_line = True
+ self.line_ids = others_lines + self.invoice_line_ids
+ self._onchange_recompute_dynamic_lines()
+
+ @api.onchange('line_ids', 'invoice_payment_term_id', 'invoice_date_due', 'invoice_cash_rounding_id', 'invoice_vendor_bill_id')
+ def _onchange_recompute_dynamic_lines(self):
+ self._recompute_dynamic_lines()
+
+ @api.model
+ def _get_tax_grouping_key_from_tax_line(self, tax_line):
+ ''' Create the dictionary based on a tax line that will be used as key to group taxes together.
+ /!\ Must be consistent with '_get_tax_grouping_key_from_base_line'.
+ :param tax_line: An account.move.line being a tax line (with 'tax_repartition_line_id' set then).
+ :return: A dictionary containing all fields on which the tax will be grouped.
+ '''
+ return {
+ 'tax_repartition_line_id': tax_line.tax_repartition_line_id.id,
+ 'account_id': tax_line.account_id.id,
+ 'currency_id': tax_line.currency_id.id,
+ 'analytic_tag_ids': [(6, 0, tax_line.tax_line_id.analytic and tax_line.analytic_tag_ids.ids or [])],
+ 'analytic_account_id': tax_line.tax_line_id.analytic and tax_line.analytic_account_id.id,
+ 'tax_ids': [(6, 0, tax_line.tax_ids.ids)],
+ 'tax_tag_ids': [(6, 0, tax_line.tax_tag_ids.ids)],
+ }
+
+ @api.model
+ def _get_tax_grouping_key_from_base_line(self, base_line, tax_vals):
+ ''' Create the dictionary based on a base line that will be used as key to group taxes together.
+ /!\ Must be consistent with '_get_tax_grouping_key_from_tax_line'.
+ :param base_line: An account.move.line being a base line (that could contains something in 'tax_ids').
+ :param tax_vals: An element of compute_all(...)['taxes'].
+ :return: A dictionary containing all fields on which the tax will be grouped.
+ '''
+ tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id'])
+ account = base_line._get_default_tax_account(tax_repartition_line) or base_line.account_id
+ return {
+ 'tax_repartition_line_id': tax_vals['tax_repartition_line_id'],
+ 'account_id': account.id,
+ 'currency_id': base_line.currency_id.id,
+ 'analytic_tag_ids': [(6, 0, tax_vals['analytic'] and base_line.analytic_tag_ids.ids or [])],
+ 'analytic_account_id': tax_vals['analytic'] and base_line.analytic_account_id.id,
+ 'tax_ids': [(6, 0, tax_vals['tax_ids'])],
+ 'tax_tag_ids': [(6, 0, tax_vals['tag_ids'])],
+ }
+
+ def _get_tax_force_sign(self):
+ """ The sign must be forced to a negative sign in case the balance is on credit
+ to avoid negatif taxes amount.
+ Example - Customer Invoice :
+ Fixed Tax | unit price | discount | amount_tax | amount_total |
+ -------------------------------------------------------------------------
+ 0.67 | 115 | 100% | - 0.67 | 0
+ -------------------------------------------------------------------------"""
+ self.ensure_one()
+ return -1 if self.move_type in ('out_invoice', 'in_refund', 'out_receipt') else 1
+
+ def _recompute_tax_lines(self, recompute_tax_base_amount=False):
+ ''' Compute the dynamic tax lines of the journal entry.
+
+ :param lines_map: The line_ids dispatched by type containing:
+ * base_lines: The lines having a tax_ids set.
+ * tax_lines: The lines having a tax_line_id set.
+ * terms_lines: The lines generated by the payment terms of the invoice.
+ * rounding_lines: The cash rounding lines of the invoice.
+ '''
+ self.ensure_one()
+ in_draft_mode = self != self._origin
+
+ def _serialize_tax_grouping_key(grouping_dict):
+ ''' Serialize the dictionary values to be used in the taxes_map.
+ :param grouping_dict: The values returned by '_get_tax_grouping_key_from_tax_line' or '_get_tax_grouping_key_from_base_line'.
+ :return: A string representing the values.
+ '''
+ return '-'.join(str(v) for v in grouping_dict.values())
+
+ def _compute_base_line_taxes(base_line):
+ ''' Compute taxes amounts both in company currency / foreign currency as the ratio between
+ amount_currency & balance could not be the same as the expected currency rate.
+ The 'amount_currency' value will be set on compute_all(...)['taxes'] in multi-currency.
+ :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(include_receipts=True):
+ handle_price_include = True
+ sign = -1 if move.is_inbound() else 1
+ quantity = base_line.quantity
+ is_refund = move.move_type in ('out_refund', 'in_refund')
+ price_unit_wo_discount = sign * base_line.price_unit * (1 - (base_line.discount / 100.0))
+ else:
+ handle_price_include = False
+ quantity = 1.0
+ tax_type = base_line.tax_ids[0].type_tax_use if base_line.tax_ids else None
+ is_refund = (tax_type == 'sale' and base_line.debit) or (tax_type == 'purchase' and base_line.credit)
+ price_unit_wo_discount = base_line.amount_currency
+
+ balance_taxes_res = base_line.tax_ids._origin.with_context(force_sign=move._get_tax_force_sign()).compute_all(
+ price_unit_wo_discount,
+ currency=base_line.currency_id,
+ quantity=quantity,
+ product=base_line.product_id,
+ partner=base_line.partner_id,
+ is_refund=is_refund,
+ handle_price_include=handle_price_include,
+ )
+
+ if move.move_type == 'entry':
+ repartition_field = is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids'
+ repartition_tags = base_line.tax_ids.flatten_taxes_hierarchy().mapped(repartition_field).filtered(lambda x: x.repartition_type == 'base').tag_ids
+ tags_need_inversion = (tax_type == 'sale' and not is_refund) or (tax_type == 'purchase' and is_refund)
+ if tags_need_inversion:
+ balance_taxes_res['base_tags'] = base_line._revert_signed_tags(repartition_tags).ids
+ for tax_res in balance_taxes_res['taxes']:
+ tax_res['tag_ids'] = base_line._revert_signed_tags(self.env['account.account.tag'].browse(tax_res['tag_ids'])).ids
+
+ return balance_taxes_res
+
+ taxes_map = {}
+
+ # ==== Add tax lines ====
+ to_remove = self.env['account.move.line']
+ for line in self.line_ids.filtered('tax_repartition_line_id'):
+ grouping_dict = self._get_tax_grouping_key_from_tax_line(line)
+ grouping_key = _serialize_tax_grouping_key(grouping_dict)
+ if grouping_key in taxes_map:
+ # A line with the same key does already exist, we only need one
+ # to modify it; we have to drop this one.
+ to_remove += line
+ else:
+ taxes_map[grouping_key] = {
+ 'tax_line': line,
+ 'amount': 0.0,
+ 'tax_base_amount': 0.0,
+ 'grouping_dict': False,
+ }
+ if not recompute_tax_base_amount:
+ self.line_ids -= to_remove
+
+ # ==== Mount base lines ====
+ for line in self.line_ids.filtered(lambda line: not line.tax_repartition_line_id):
+ # Don't call compute_all if there is no tax.
+ if not line.tax_ids:
+ if not recompute_tax_base_amount:
+ line.tax_tag_ids = [(5, 0, 0)]
+ continue
+
+ compute_all_vals = _compute_base_line_taxes(line)
+
+ # Assign tags on base line
+ if not recompute_tax_base_amount:
+ line.tax_tag_ids = compute_all_vals['base_tags'] or [(5, 0, 0)]
+
+ tax_exigible = True
+ for tax_vals in compute_all_vals['taxes']:
+ grouping_dict = self._get_tax_grouping_key_from_base_line(line, tax_vals)
+ grouping_key = _serialize_tax_grouping_key(grouping_dict)
+
+ tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id'])
+ tax = tax_repartition_line.invoice_tax_id or tax_repartition_line.refund_tax_id
+
+ if tax.tax_exigibility == 'on_payment':
+ tax_exigible = False
+
+ taxes_map_entry = taxes_map.setdefault(grouping_key, {
+ 'tax_line': None,
+ 'amount': 0.0,
+ 'tax_base_amount': 0.0,
+ 'grouping_dict': False,
+ })
+ taxes_map_entry['amount'] += tax_vals['amount']
+ taxes_map_entry['tax_base_amount'] += self._get_base_amount_to_display(tax_vals['base'], tax_repartition_line, tax_vals['group'])
+ taxes_map_entry['grouping_dict'] = grouping_dict
+ if not recompute_tax_base_amount:
+ line.tax_exigible = tax_exigible
+
+ # ==== Process taxes_map ====
+ for taxes_map_entry in taxes_map.values():
+ # The tax line is no longer used in any base lines, drop it.
+ if taxes_map_entry['tax_line'] and not taxes_map_entry['grouping_dict']:
+ if not recompute_tax_base_amount:
+ self.line_ids -= taxes_map_entry['tax_line']
+ continue
+
+ currency = self.env['res.currency'].browse(taxes_map_entry['grouping_dict']['currency_id'])
+
+ # Don't create tax lines with zero balance.
+ if currency.is_zero(taxes_map_entry['amount']):
+ if taxes_map_entry['tax_line'] and not recompute_tax_base_amount:
+ self.line_ids -= taxes_map_entry['tax_line']
+ continue
+
+ # tax_base_amount field is expressed using the company currency.
+ tax_base_amount = currency._convert(taxes_map_entry['tax_base_amount'], self.company_currency_id, self.company_id, self.date or fields.Date.context_today(self))
+
+ # Recompute only the tax_base_amount.
+ if recompute_tax_base_amount:
+ if taxes_map_entry['tax_line']:
+ taxes_map_entry['tax_line'].tax_base_amount = tax_base_amount
+ continue
+
+ balance = currency._convert(
+ taxes_map_entry['amount'],
+ self.company_currency_id,
+ self.company_id,
+ self.date or fields.Date.context_today(self),
+ )
+ to_write_on_line = {
+ 'amount_currency': taxes_map_entry['amount'],
+ 'currency_id': taxes_map_entry['grouping_dict']['currency_id'],
+ 'debit': balance > 0.0 and balance or 0.0,
+ 'credit': balance < 0.0 and -balance or 0.0,
+ 'tax_base_amount': tax_base_amount,
+ }
+
+ if taxes_map_entry['tax_line']:
+ # Update an existing tax line.
+ taxes_map_entry['tax_line'].update(to_write_on_line)
+ else:
+ create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create
+ tax_repartition_line_id = taxes_map_entry['grouping_dict']['tax_repartition_line_id']
+ tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_repartition_line_id)
+ tax = tax_repartition_line.invoice_tax_id or tax_repartition_line.refund_tax_id
+ taxes_map_entry['tax_line'] = create_method({
+ **to_write_on_line,
+ 'name': tax.name,
+ 'move_id': self.id,
+ 'partner_id': line.partner_id.id,
+ 'company_id': line.company_id.id,
+ 'company_currency_id': line.company_currency_id.id,
+ 'tax_base_amount': tax_base_amount,
+ 'exclude_from_invoice_tab': True,
+ 'tax_exigible': tax.tax_exigibility == 'on_invoice',
+ **taxes_map_entry['grouping_dict'],
+ })
+
+ if in_draft_mode:
+ taxes_map_entry['tax_line'].update(taxes_map_entry['tax_line']._get_fields_onchange_balance(force_computation=True))
+
+ @api.model
+ def _get_base_amount_to_display(self, base_amount, tax_rep_ln, parent_tax_group=None):
+ """ The base amount returned for taxes by compute_all has is the balance
+ of the base line. For inbound operations, positive sign is on credit, so
+ we need to invert the sign of this amount before displaying it.
+ """
+ source_tax = parent_tax_group or tax_rep_ln.invoice_tax_id or tax_rep_ln.refund_tax_id
+ if (tax_rep_ln.invoice_tax_id and source_tax.type_tax_use == 'sale') \
+ or (tax_rep_ln.refund_tax_id and source_tax.type_tax_use == 'purchase'):
+ return -base_amount
+ return base_amount
+
+ def update_lines_tax_exigibility(self):
+ if all(account.user_type_id.type not in {'payable', 'receivable'} for account in self.mapped('line_ids.account_id')):
+ self.line_ids.write({'tax_exigible': True})
+ else:
+ tax_lines_caba = self.line_ids.filtered(lambda x: x.tax_line_id.tax_exigibility == 'on_payment')
+ base_lines_caba = self.line_ids.filtered(lambda x: any(tax.tax_exigibility == 'on_payment'
+ or (tax.amount_type == 'group'
+ and 'on_payment' in tax.mapped('children_tax_ids.tax_exigibility'))
+ for tax in x.tax_ids))
+ caba_lines = tax_lines_caba + base_lines_caba
+ caba_lines.write({'tax_exigible': False})
+ (self.line_ids - caba_lines).write({'tax_exigible': True})
+
+ def _recompute_cash_rounding_lines(self):
+ ''' Handle the cash rounding feature on invoices.
+
+ In some countries, the smallest coins do not exist. For example, in Switzerland, there is no coin for 0.01 CHF.
+ For this reason, if invoices are paid in cash, you have to round their total amount to the smallest coin that
+ exists in the currency. For the CHF, the smallest coin is 0.05 CHF.
+
+ There are two strategies for the rounding:
+
+ 1) Add a line on the invoice for the rounding: The cash rounding line is added as a new invoice line.
+ 2) Add the rounding in the biggest tax amount: The cash rounding line is added as a new tax line on the tax
+ having the biggest balance.
+ '''
+ self.ensure_one()
+ in_draft_mode = self != self._origin
+
+ def _compute_cash_rounding(self, total_amount_currency):
+ ''' Compute the amount differences due to the cash rounding.
+ :param self: The current account.move record.
+ :param total_amount_currency: The invoice's total in invoice's currency.
+ :return: The amount differences both in company's currency & invoice's currency.
+ '''
+ difference = self.invoice_cash_rounding_id.compute_difference(self.currency_id, total_amount_currency)
+ if self.currency_id == self.company_id.currency_id:
+ diff_amount_currency = diff_balance = difference
+ else:
+ diff_amount_currency = difference
+ diff_balance = self.currency_id._convert(diff_amount_currency, self.company_id.currency_id, self.company_id, self.date)
+ return diff_balance, diff_amount_currency
+
+ def _apply_cash_rounding(self, diff_balance, diff_amount_currency, cash_rounding_line):
+ ''' Apply the cash rounding.
+ :param self: The current account.move record.
+ :param diff_balance: The computed balance to set on the new rounding line.
+ :param diff_amount_currency: The computed amount in invoice's currency to set on the new rounding line.
+ :param cash_rounding_line: The existing cash rounding line.
+ :return: The newly created rounding line.
+ '''
+ rounding_line_vals = {
+ 'debit': diff_balance > 0.0 and diff_balance or 0.0,
+ 'credit': diff_balance < 0.0 and -diff_balance or 0.0,
+ 'quantity': 1.0,
+ 'amount_currency': diff_amount_currency,
+ 'partner_id': self.partner_id.id,
+ 'move_id': self.id,
+ 'currency_id': self.currency_id.id,
+ 'company_id': self.company_id.id,
+ 'company_currency_id': self.company_id.currency_id.id,
+ 'is_rounding_line': True,
+ 'sequence': 9999,
+ }
+
+ if self.invoice_cash_rounding_id.strategy == 'biggest_tax':
+ biggest_tax_line = None
+ for tax_line in self.line_ids.filtered('tax_repartition_line_id'):
+ if not biggest_tax_line or tax_line.price_subtotal > biggest_tax_line.price_subtotal:
+ biggest_tax_line = tax_line
+
+ # No tax found.
+ if not biggest_tax_line:
+ return
+
+ rounding_line_vals.update({
+ 'name': _('%s (rounding)', biggest_tax_line.name),
+ 'account_id': biggest_tax_line.account_id.id,
+ 'tax_repartition_line_id': biggest_tax_line.tax_repartition_line_id.id,
+ 'tax_exigible': biggest_tax_line.tax_exigible,
+ 'exclude_from_invoice_tab': True,
+ })
+
+ elif self.invoice_cash_rounding_id.strategy == 'add_invoice_line':
+ if diff_balance > 0.0 and self.invoice_cash_rounding_id.loss_account_id:
+ account_id = self.invoice_cash_rounding_id.loss_account_id.id
+ else:
+ account_id = self.invoice_cash_rounding_id.profit_account_id.id
+ rounding_line_vals.update({
+ 'name': self.invoice_cash_rounding_id.name,
+ 'account_id': account_id,
+ })
+
+ # Create or update the cash rounding line.
+ if cash_rounding_line:
+ cash_rounding_line.update({
+ 'amount_currency': rounding_line_vals['amount_currency'],
+ 'debit': rounding_line_vals['debit'],
+ 'credit': rounding_line_vals['credit'],
+ 'account_id': rounding_line_vals['account_id'],
+ })
+ else:
+ create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create
+ cash_rounding_line = create_method(rounding_line_vals)
+
+ if in_draft_mode:
+ cash_rounding_line.update(cash_rounding_line._get_fields_onchange_balance(force_computation=True))
+
+ existing_cash_rounding_line = self.line_ids.filtered(lambda line: line.is_rounding_line)
+
+ # The cash rounding has been removed.
+ if not self.invoice_cash_rounding_id:
+ self.line_ids -= existing_cash_rounding_line
+ return
+
+ # The cash rounding strategy has changed.
+ if self.invoice_cash_rounding_id and existing_cash_rounding_line:
+ strategy = self.invoice_cash_rounding_id.strategy
+ old_strategy = 'biggest_tax' if existing_cash_rounding_line.tax_line_id else 'add_invoice_line'
+ if strategy != old_strategy:
+ self.line_ids -= existing_cash_rounding_line
+ existing_cash_rounding_line = self.env['account.move.line']
+
+ others_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type not in ('receivable', 'payable'))
+ others_lines -= existing_cash_rounding_line
+ total_amount_currency = sum(others_lines.mapped('amount_currency'))
+
+ diff_balance, diff_amount_currency = _compute_cash_rounding(self, total_amount_currency)
+
+ # The invoice is already rounded.
+ if self.currency_id.is_zero(diff_balance) and self.currency_id.is_zero(diff_amount_currency):
+ self.line_ids -= existing_cash_rounding_line
+ return
+
+ _apply_cash_rounding(self, diff_balance, diff_amount_currency, existing_cash_rounding_line)
+
+ def _recompute_payment_terms_lines(self):
+ ''' Compute the dynamic payment term lines of the journal entry.'''
+ self.ensure_one()
+ self = self.with_company(self.company_id)
+ in_draft_mode = self != self._origin
+ today = fields.Date.context_today(self)
+ self = self.with_company(self.journal_id.company_id)
+
+ def _get_payment_terms_computation_date(self):
+ ''' Get the date from invoice that will be used to compute the payment terms.
+ :param self: The current account.move record.
+ :return: A datetime.date object.
+ '''
+ if self.invoice_payment_term_id:
+ return self.invoice_date or today
+ else:
+ return self.invoice_date_due or self.invoice_date or today
+
+ def _get_payment_terms_account(self, payment_terms_lines):
+ ''' Get the account from invoice that will be set as receivable / payable account.
+ :param self: The current account.move record.
+ :param payment_terms_lines: The current payment terms lines.
+ :return: An account.account record.
+ '''
+ if payment_terms_lines:
+ # Retrieve account from previous payment terms lines in order to allow the user to set a custom one.
+ return payment_terms_lines[0].account_id
+ elif self.partner_id:
+ # Retrieve account from partner.
+ if self.is_sale_document(include_receipts=True):
+ return self.partner_id.property_account_receivable_id
+ else:
+ return self.partner_id.property_account_payable_id
+ else:
+ # Search new account.
+ domain = [
+ ('company_id', '=', self.company_id.id),
+ ('internal_type', '=', 'receivable' if self.move_type in ('out_invoice', 'out_refund', 'out_receipt') else 'payable'),
+ ]
+ return self.env['account.account'].search(domain, limit=1)
+
+ def _compute_payment_terms(self, date, total_balance, total_amount_currency):
+ ''' Compute the payment terms.
+ :param self: The current account.move record.
+ :param date: The date computed by '_get_payment_terms_computation_date'.
+ :param total_balance: The invoice's total in company's currency.
+ :param total_amount_currency: The invoice's total in invoice's currency.
+ :return: A list <to_pay_company_currency, to_pay_invoice_currency, due_date>.
+ '''
+ if self.invoice_payment_term_id:
+ to_compute = self.invoice_payment_term_id.compute(total_balance, date_ref=date, currency=self.company_id.currency_id)
+ if self.currency_id == self.company_id.currency_id:
+ # Single-currency.
+ return [(b[0], b[1], b[1]) for b in to_compute]
+ else:
+ # Multi-currencies.
+ to_compute_currency = self.invoice_payment_term_id.compute(total_amount_currency, date_ref=date, currency=self.currency_id)
+ return [(b[0], b[1], ac[1]) for b, ac in zip(to_compute, to_compute_currency)]
+ else:
+ return [(fields.Date.to_string(date), total_balance, total_amount_currency)]
+
+ def _compute_diff_payment_terms_lines(self, existing_terms_lines, account, to_compute):
+ ''' Process the result of the '_compute_payment_terms' method and creates/updates corresponding invoice lines.
+ :param self: The current account.move record.
+ :param existing_terms_lines: The current payment terms lines.
+ :param account: The account.account record returned by '_get_payment_terms_account'.
+ :param to_compute: The list returned by '_compute_payment_terms'.
+ '''
+ # As we try to update existing lines, sort them by due date.
+ existing_terms_lines = existing_terms_lines.sorted(lambda line: line.date_maturity or today)
+ existing_terms_lines_index = 0
+
+ # Recompute amls: update existing line or create new one for each payment term.
+ new_terms_lines = self.env['account.move.line']
+ for date_maturity, balance, amount_currency in to_compute:
+ currency = self.journal_id.company_id.currency_id
+ if currency and currency.is_zero(balance) and len(to_compute) > 1:
+ continue
+
+ if existing_terms_lines_index < len(existing_terms_lines):
+ # Update existing line.
+ candidate = existing_terms_lines[existing_terms_lines_index]
+ existing_terms_lines_index += 1
+ candidate.update({
+ 'date_maturity': date_maturity,
+ 'amount_currency': -amount_currency,
+ 'debit': balance < 0.0 and -balance or 0.0,
+ 'credit': balance > 0.0 and balance or 0.0,
+ })
+ else:
+ # Create new line.
+ create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create
+ candidate = create_method({
+ 'name': self.payment_reference or '',
+ 'debit': balance < 0.0 and -balance or 0.0,
+ 'credit': balance > 0.0 and balance or 0.0,
+ 'quantity': 1.0,
+ 'amount_currency': -amount_currency,
+ 'date_maturity': date_maturity,
+ 'move_id': self.id,
+ 'currency_id': self.currency_id.id,
+ 'account_id': account.id,
+ 'partner_id': self.commercial_partner_id.id,
+ 'exclude_from_invoice_tab': True,
+ })
+ new_terms_lines += candidate
+ if in_draft_mode:
+ candidate.update(candidate._get_fields_onchange_balance(force_computation=True))
+ return new_terms_lines
+
+ existing_terms_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable'))
+ others_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type not in ('receivable', 'payable'))
+ company_currency_id = (self.company_id or self.env.company).currency_id
+ total_balance = sum(others_lines.mapped(lambda l: company_currency_id.round(l.balance)))
+ total_amount_currency = sum(others_lines.mapped('amount_currency'))
+
+ if not others_lines:
+ self.line_ids -= existing_terms_lines
+ return
+
+ computation_date = _get_payment_terms_computation_date(self)
+ account = _get_payment_terms_account(self, existing_terms_lines)
+ to_compute = _compute_payment_terms(self, computation_date, total_balance, total_amount_currency)
+ new_terms_lines = _compute_diff_payment_terms_lines(self, existing_terms_lines, account, to_compute)
+
+ # Remove old terms lines that are no longer needed.
+ self.line_ids -= existing_terms_lines - new_terms_lines
+
+ if new_terms_lines:
+ self.payment_reference = new_terms_lines[-1].name or ''
+ self.invoice_date_due = new_terms_lines[-1].date_maturity
+
+ def _recompute_dynamic_lines(self, recompute_all_taxes=False, recompute_tax_base_amount=False):
+ ''' Recompute all lines that depend of others.
+
+ For example, tax lines depends of base lines (lines having tax_ids set). This is also the case of cash rounding
+ lines that depend of base lines or tax lines depending the cash rounding strategy. When a payment term is set,
+ this method will auto-balance the move with payment term lines.
+
+ :param recompute_all_taxes: Force the computation of taxes. If set to False, the computation will be done
+ or not depending of the field 'recompute_tax_line' in lines.
+ '''
+ for invoice in self:
+ # Dispatch lines and pre-compute some aggregated values like taxes.
+ for line in invoice.line_ids:
+ if line.recompute_tax_line:
+ recompute_all_taxes = True
+ line.recompute_tax_line = False
+
+ # Compute taxes.
+ if recompute_all_taxes:
+ invoice._recompute_tax_lines()
+ if recompute_tax_base_amount:
+ invoice._recompute_tax_lines(recompute_tax_base_amount=True)
+
+ if invoice.is_invoice(include_receipts=True):
+
+ # Compute cash rounding.
+ invoice._recompute_cash_rounding_lines()
+
+ # Compute payment terms.
+ invoice._recompute_payment_terms_lines()
+
+ # Only synchronize one2many in onchange.
+ if invoice != invoice._origin:
+ invoice.invoice_line_ids = invoice.line_ids.filtered(lambda line: not line.exclude_from_invoice_tab)
+
+ @api.depends('journal_id')
+ def _compute_company_id(self):
+ for move in self:
+ move.company_id = move.journal_id.company_id or move.company_id or self.env.company
+
+ def _get_lines_onchange_currency(self):
+ # Override needed for COGS
+ return self.line_ids
+
+ def onchange(self, values, field_name, field_onchange):
+ # OVERRIDE
+ # As the dynamic lines in this model are quite complex, we need to ensure some computations are done exactly
+ # at the beginning / at the end of the onchange mechanism. So, the onchange recursivity is disabled.
+ return super(AccountMove, self.with_context(recursive_onchanges=False)).onchange(values, field_name, field_onchange)
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('company_id', 'invoice_filter_type_domain')
+ def _compute_suitable_journal_ids(self):
+ for m in self:
+ journal_type = m.invoice_filter_type_domain or 'general'
+ company_id = m.company_id.id or self.env.company.id
+ domain = [('company_id', '=', company_id), ('type', '=', journal_type)]
+ m.suitable_journal_ids = self.env['account.journal'].search(domain)
+
+ @api.depends('posted_before', 'state', 'journal_id', 'date')
+ def _compute_name(self):
+ def journal_key(move):
+ return (move.journal_id, move.journal_id.refund_sequence and move.move_type)
+
+ def date_key(move):
+ return (move.date.year, move.date.month)
+
+ grouped = defaultdict( # key: journal_id, move_type
+ lambda: defaultdict( # key: first adjacent (date.year, date.month)
+ lambda: {
+ 'records': self.env['account.move'],
+ 'format': False,
+ 'format_values': False,
+ 'reset': False
+ }
+ )
+ )
+ self = self.sorted(lambda m: (m.date, m.ref or '', m.id))
+ highest_name = self[0]._get_last_sequence() if self else False
+
+ # Group the moves by journal and month
+ for move in self:
+ if not highest_name and move == self[0] and not move.posted_before and move.date:
+ # In the form view, we need to compute a default sequence so that the user can edit
+ # it. We only check the first move as an approximation (enough for new in form view)
+ pass
+ elif (move.name and move.name != '/') or move.state != 'posted':
+ try:
+ if not move.posted_before:
+ move._constrains_date_sequence()
+ # Has already a name or is not posted, we don't add to a batch
+ continue
+ except ValidationError:
+ # Has never been posted and the name doesn't match the date: recompute it
+ pass
+ group = grouped[journal_key(move)][date_key(move)]
+ if not group['records']:
+ # Compute all the values needed to sequence this whole group
+ move._set_next_sequence()
+ group['format'], group['format_values'] = move._get_sequence_format_param(move.name)
+ group['reset'] = move._deduce_sequence_number_reset(move.name)
+ group['records'] += move
+
+ # Fusion the groups depending on the sequence reset and the format used because `seq` is
+ # the same counter for multiple groups that might be spread in multiple months.
+ final_batches = []
+ for journal_group in grouped.values():
+ journal_group_changed = True
+ for date_group in journal_group.values():
+ if (
+ journal_group_changed
+ or final_batches[-1]['format'] != date_group['format']
+ or dict(final_batches[-1]['format_values'], seq=0) != dict(date_group['format_values'], seq=0)
+ ):
+ final_batches += [date_group]
+ journal_group_changed = False
+ elif date_group['reset'] == 'never':
+ final_batches[-1]['records'] += date_group['records']
+ elif (
+ date_group['reset'] == 'year'
+ and final_batches[-1]['records'][0].date.year == date_group['records'][0].date.year
+ ):
+ final_batches[-1]['records'] += date_group['records']
+ else:
+ final_batches += [date_group]
+
+ # Give the name based on previously computed values
+ for batch in final_batches:
+ for move in batch['records']:
+ move.name = batch['format'].format(**batch['format_values'])
+ batch['format_values']['seq'] += 1
+ batch['records']._compute_split_sequence()
+
+ self.filtered(lambda m: not m.name).name = '/'
+
+ @api.depends('journal_id', 'date')
+ def _compute_highest_name(self):
+ for record in self:
+ record.highest_name = record._get_last_sequence()
+
+ @api.onchange('name', 'highest_name')
+ def _onchange_name_warning(self):
+ if self.name and self.name != '/' and self.name <= (self.highest_name or ''):
+ self.show_name_warning = True
+ else:
+ self.show_name_warning = False
+
+ origin_name = self._origin.name
+ if not origin_name or origin_name == '/':
+ origin_name = self.highest_name
+ if self.name and self.name != '/' and origin_name and origin_name != '/':
+ format, format_values = self._get_sequence_format_param(self.name)
+ origin_format, origin_format_values = self._get_sequence_format_param(origin_name)
+
+ if (
+ format != origin_format
+ or dict(format_values, seq=0) != dict(origin_format_values, seq=0)
+ ):
+ changed = _(
+ "It was previously '%(previous)s' and it is now '%(current)s'.",
+ previous=origin_name,
+ current=self.name,
+ )
+ reset = self._deduce_sequence_number_reset(self.name)
+ if reset == 'month':
+ detected = _(
+ "The sequence will restart at 1 at the start of every month.\n"
+ "The year detected here is '%(year)s' and the month is '%(month)s'.\n"
+ "The incrementing number in this case is '%(formatted_seq)s'."
+ )
+ elif reset == 'year':
+ detected = _(
+ "The sequence will restart at 1 at the start of every year.\n"
+ "The year detected here is '%(year)s'.\n"
+ "The incrementing number in this case is '%(formatted_seq)s'."
+ )
+ else:
+ detected = _(
+ "The sequence will never restart.\n"
+ "The incrementing number in this case is '%(formatted_seq)s'."
+ )
+ format_values['formatted_seq'] = "{seq:0{seq_length}d}".format(**format_values)
+ detected = detected % format_values
+ return {'warning': {
+ 'title': _("The sequence format has changed."),
+ 'message': "%s\n\n%s" % (changed, detected)
+ }}
+
+ def _get_last_sequence_domain(self, relaxed=False):
+ self.ensure_one()
+ if not self.date or not self.journal_id:
+ return "WHERE FALSE", {}
+ where_string = "WHERE journal_id = %(journal_id)s AND name != '/'"
+ param = {'journal_id': self.journal_id.id}
+
+ if not relaxed:
+ domain = [('journal_id', '=', self.journal_id.id), ('id', '!=', self.id or self._origin.id), ('name', 'not in', ('/', False))]
+ if self.journal_id.refund_sequence:
+ refund_types = ('out_refund', 'in_refund')
+ domain += [('move_type', 'in' if self.move_type in refund_types else 'not in', refund_types)]
+ reference_move_name = self.search(domain + [('date', '<=', self.date)], order='date desc', limit=1).name
+ if not reference_move_name:
+ reference_move_name = self.search(domain, order='date asc', limit=1).name
+ sequence_number_reset = self._deduce_sequence_number_reset(reference_move_name)
+ if sequence_number_reset == 'year':
+ where_string += " AND date_trunc('year', date::timestamp without time zone) = date_trunc('year', %(date)s) "
+ param['date'] = self.date
+ param['anti_regex'] = re.sub(r"\?P<\w+>", "?:", self._sequence_monthly_regex.split('(?P<seq>')[0]) + '$'
+ elif sequence_number_reset == 'month':
+ where_string += " AND date_trunc('month', date::timestamp without time zone) = date_trunc('month', %(date)s) "
+ param['date'] = self.date
+ else:
+ param['anti_regex'] = re.sub(r"\?P<\w+>", "?:", self._sequence_yearly_regex.split('(?P<seq>')[0]) + '$'
+
+ if param.get('anti_regex') and not self.journal_id.sequence_override_regex:
+ where_string += " AND sequence_prefix !~ %(anti_regex)s "
+
+ if self.journal_id.refund_sequence:
+ if self.move_type in ('out_refund', 'in_refund'):
+ where_string += " AND move_type IN ('out_refund', 'in_refund') "
+ else:
+ where_string += " AND move_type NOT IN ('out_refund', 'in_refund') "
+
+ return where_string, param
+
+ def _get_starting_sequence(self):
+ self.ensure_one()
+ starting_sequence = "%s/%04d/%02d/0000" % (self.journal_id.code, self.date.year, self.date.month)
+ if self.journal_id.refund_sequence and self.move_type in ('out_refund', 'in_refund'):
+ starting_sequence = "R" + starting_sequence
+ return starting_sequence
+
+ @api.depends('move_type')
+ def _compute_type_name(self):
+ type_name_mapping = {k: v for k, v in
+ self._fields['move_type']._description_selection(self.env)}
+ replacements = {'out_invoice': _('Invoice'), 'out_refund': _('Credit Note')}
+
+ for record in self:
+ name = type_name_mapping[record.move_type]
+ record.type_name = replacements.get(record.move_type, name)
+
+ @api.depends('move_type')
+ def _compute_invoice_filter_type_domain(self):
+ for move in self:
+ if move.is_sale_document(include_receipts=True):
+ move.invoice_filter_type_domain = 'sale'
+ elif move.is_purchase_document(include_receipts=True):
+ move.invoice_filter_type_domain = 'purchase'
+ else:
+ move.invoice_filter_type_domain = False
+
+ @api.depends('partner_id')
+ def _compute_commercial_partner_id(self):
+ for move in self:
+ move.commercial_partner_id = move.partner_id.commercial_partner_id
+
+ @api.depends('commercial_partner_id')
+ def _compute_bank_partner_id(self):
+ for move in self:
+ if move.is_outbound():
+ move.bank_partner_id = move.commercial_partner_id
+ else:
+ move.bank_partner_id = move.company_id.partner_id
+
+ @api.model
+ def _get_invoice_in_payment_state(self):
+ ''' Hook to give the state when the invoice becomes fully paid. This is necessary because the users working
+ with only invoicing don't want to see the 'in_payment' state. Then, this method will be overridden in the
+ accountant module to enable the 'in_payment' state. '''
+ return 'paid'
+
+ @api.depends(
+ 'line_ids.matched_debit_ids.debit_move_id.move_id.payment_id.is_matched',
+ 'line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual',
+ 'line_ids.matched_debit_ids.debit_move_id.move_id.line_ids.amount_residual_currency',
+ 'line_ids.matched_credit_ids.credit_move_id.move_id.payment_id.is_matched',
+ 'line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual',
+ 'line_ids.matched_credit_ids.credit_move_id.move_id.line_ids.amount_residual_currency',
+ 'line_ids.debit',
+ 'line_ids.credit',
+ 'line_ids.currency_id',
+ 'line_ids.amount_currency',
+ 'line_ids.amount_residual',
+ 'line_ids.amount_residual_currency',
+ 'line_ids.payment_id.state',
+ 'line_ids.full_reconcile_id')
+ def _compute_amount(self):
+ for move in self:
+
+ if move.payment_state == 'invoicing_legacy':
+ # invoicing_legacy state is set via SQL when setting setting field
+ # invoicing_switch_threshold (defined in account_accountant).
+ # The only way of going out of this state is through this setting,
+ # so we don't recompute it here.
+ move.payment_state = move.payment_state
+ continue
+
+ total_untaxed = 0.0
+ total_untaxed_currency = 0.0
+ total_tax = 0.0
+ total_tax_currency = 0.0
+ total_to_pay = 0.0
+ total_residual = 0.0
+ total_residual_currency = 0.0
+ total = 0.0
+ total_currency = 0.0
+ currencies = move._get_lines_onchange_currency().currency_id
+
+ for line in move.line_ids:
+ if move.is_invoice(include_receipts=True):
+ # === Invoices ===
+
+ 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_to_pay += line.balance
+ 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
+
+ if move.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)
+ move.amount_residual = -sign * (total_residual_currency if len(currencies) == 1 else total_residual)
+ move.amount_untaxed_signed = -total_untaxed
+ move.amount_tax_signed = -total_tax
+ move.amount_total_signed = abs(total) if move.move_type == 'entry' else -total
+ move.amount_residual_signed = total_residual
+
+ currency = len(currencies) == 1 and currencies or move.company_id.currency_id
+
+ # Compute 'payment_state'.
+ new_pmt_state = 'not_paid' if move.move_type != 'entry' else False
+
+ if move.is_invoice(include_receipts=True) and move.state == 'posted':
+
+ if currency.is_zero(move.amount_residual):
+ reconciled_payments = move._get_reconciled_payments()
+ if not reconciled_payments or all(payment.is_matched for payment in reconciled_payments):
+ new_pmt_state = 'paid'
+ else:
+ new_pmt_state = move._get_invoice_in_payment_state()
+ elif currency.compare_amounts(total_to_pay, total_residual) != 0:
+ new_pmt_state = 'partial'
+
+ if new_pmt_state == 'paid' and move.move_type in ('in_invoice', 'out_invoice', 'entry'):
+ reverse_type = move.move_type == 'in_invoice' and 'in_refund' or move.move_type == 'out_invoice' and 'out_refund' or 'entry'
+ reverse_moves = self.env['account.move'].search([('reversed_entry_id', '=', move.id), ('state', '=', 'posted'), ('move_type', '=', reverse_type)])
+
+ # We only set 'reversed' state in cas of 1 to 1 full reconciliation with a reverse entry; otherwise, we use the regular 'paid' state
+ reverse_moves_full_recs = reverse_moves.mapped('line_ids.full_reconcile_id')
+ if reverse_moves_full_recs.mapped('reconciled_line_ids.move_id').filtered(lambda x: x not in (reverse_moves + reverse_moves_full_recs.mapped('exchange_move_id'))) == move:
+ new_pmt_state = 'reversed'
+
+ move.payment_state = new_pmt_state
+
+ def _inverse_amount_total(self):
+ for move in self:
+ if len(move.line_ids) != 2 or move.is_invoice(include_receipts=True):
+ continue
+
+ to_write = []
+
+ amount_currency = abs(move.amount_total)
+ balance = move.currency_id._convert(amount_currency, move.company_currency_id, move.company_id, move.date)
+
+ for line in move.line_ids:
+ if not line.currency_id.is_zero(balance - abs(line.balance)):
+ to_write.append((1, line.id, {
+ 'debit': line.balance > 0.0 and balance or 0.0,
+ 'credit': line.balance < 0.0 and balance or 0.0,
+ 'amount_currency': line.balance > 0.0 and amount_currency or -amount_currency,
+ }))
+
+ move.write({'line_ids': to_write})
+
+ def _get_domain_matching_suspense_moves(self):
+ self.ensure_one()
+ domain = self.env['account.move.line']._get_suspense_moves_domain()
+ domain += ['|', ('partner_id', '=?', self.partner_id.id), ('partner_id', '=', False)]
+ if self.is_inbound():
+ domain.append(('balance', '=', -self.amount_residual))
+ else:
+ domain.append(('balance', '=', self.amount_residual))
+ return domain
+
+ def _compute_has_matching_suspense_amount(self):
+ for r in self:
+ res = False
+ if r.state == 'posted' and r.is_invoice() and r.payment_state == 'not_paid':
+ domain = r._get_domain_matching_suspense_moves()
+ #there are more than one but less than 5 suspense moves matching the residual amount
+ if (0 < self.env['account.move.line'].search_count(domain) < 5):
+ domain2 = [
+ ('payment_state', '=', 'not_paid'),
+ ('state', '=', 'posted'),
+ ('amount_residual', '=', r.amount_residual),
+ ('move_type', '=', r.move_type)]
+ #there are less than 5 other open invoices of the same type with the same residual
+ if self.env['account.move'].search_count(domain2) < 5:
+ res = True
+ r.invoice_has_matching_suspense_amount = res
+
+ @api.depends('partner_id', 'invoice_source_email', 'partner_id.name')
+ def _compute_invoice_partner_display_info(self):
+ for move in self:
+ vendor_display_name = move.partner_id.display_name
+ if not vendor_display_name:
+ if move.invoice_source_email:
+ vendor_display_name = _('@From: %(email)s', email=move.invoice_source_email)
+ else:
+ vendor_display_name = _('#Created by: %s', move.sudo().create_uid.name or self.env.user.name)
+ move.invoice_partner_display_name = vendor_display_name
+
+ def _compute_payments_widget_to_reconcile_info(self):
+ for move in self:
+ 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):
+ continue
+
+ 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.append(('balance', '<', 0.0))
+ payments_widget_vals['title'] = _('Outstanding credits')
+ else:
+ domain.append(('balance', '>', 0.0))
+ payments_widget_vals['title'] = _('Outstanding debits')
+
+ for line in self.env['account.move.line'].search(domain):
+
+ if line.currency_id == move.currency_id:
+ # Same foreign currency.
+ amount = abs(line.amount_residual_currency)
+ else:
+ # Different foreign currencies.
+ amount = move.company_currency_id._convert(
+ abs(line.amount_residual),
+ move.currency_id,
+ move.company_id,
+ line.date,
+ )
+
+ 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
+
+ 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:
+ 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,
+ 'journal_name': counterpart_line.journal_id.name,
+ 'amount': amount,
+ 'currency': self.currency_id.symbol,
+ 'digits': [69, self.currency_id.decimal_places],
+ 'position': self.currency_id.position,
+ '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': reconciliation_ref,
+ })
+ return reconciled_vals
+
+ @api.depends('move_type', 'line_ids.amount_residual')
+ def _compute_payments_widget_reconciled_info(self):
+ for move in self:
+ 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)
+
+ @api.depends('line_ids.price_subtotal', 'line_ids.tax_base_amount', 'line_ids.tax_line_id', 'partner_id', 'currency_id')
+ def _compute_invoice_taxes_by_group(self):
+ for move in self:
+
+ # Not working on something else than invoices.
+ if not move.is_invoice(include_receipts=True):
+ move.amount_by_group = []
+ continue
+
+ lang_env = move.with_context(lang=move.partner_id.lang).env
+ balance_multiplicator = -1 if move.is_inbound() else 1
+
+ tax_lines = move.line_ids.filtered('tax_line_id')
+ base_lines = move.line_ids.filtered('tax_ids')
+
+ tax_group_mapping = defaultdict(lambda: {
+ 'base_lines': set(),
+ 'base_amount': 0.0,
+ 'tax_amount': 0.0,
+ })
+
+ # Compute base amounts.
+ for base_line in base_lines:
+ base_amount = balance_multiplicator * (base_line.amount_currency if base_line.currency_id else base_line.balance)
+
+ for tax in base_line.tax_ids.flatten_taxes_hierarchy():
+
+ if base_line.tax_line_id.tax_group_id == tax.tax_group_id:
+ continue
+
+ tax_group_vals = tax_group_mapping[tax.tax_group_id]
+ if base_line not in tax_group_vals['base_lines']:
+ tax_group_vals['base_amount'] += base_amount
+ tax_group_vals['base_lines'].add(base_line)
+
+ # Compute tax amounts.
+ for tax_line in tax_lines:
+ tax_amount = balance_multiplicator * (tax_line.amount_currency if tax_line.currency_id else tax_line.balance)
+ tax_group_vals = tax_group_mapping[tax_line.tax_line_id.tax_group_id]
+ tax_group_vals['tax_amount'] += tax_amount
+
+ tax_groups = sorted(tax_group_mapping.keys(), key=lambda x: x.sequence)
+ amount_by_group = []
+ for tax_group in tax_groups:
+ tax_group_vals = tax_group_mapping[tax_group]
+ amount_by_group.append((
+ tax_group.name,
+ tax_group_vals['tax_amount'],
+ tax_group_vals['base_amount'],
+ formatLang(lang_env, tax_group_vals['tax_amount'], currency_obj=move.currency_id),
+ formatLang(lang_env, tax_group_vals['base_amount'], currency_obj=move.currency_id),
+ len(tax_group_mapping),
+ tax_group.id
+ ))
+ move.amount_by_group = amount_by_group
+
+ @api.model
+ def _get_tax_key_for_group_add_base(self, line):
+ """
+ Useful for _compute_invoice_taxes_by_group
+ must be consistent with _get_tax_grouping_key_from_tax_line
+ @return list
+ """
+ # DEPRECATED: TO BE REMOVED IN MASTER
+ return [line.tax_line_id.id]
+
+ @api.depends('date', 'line_ids.debit', 'line_ids.credit', 'line_ids.tax_line_id', 'line_ids.tax_ids', 'line_ids.tax_tag_ids')
+ def _compute_tax_lock_date_message(self):
+ for move in self:
+ if move._affect_tax_report() and move.company_id.tax_lock_date and move.date and move.date <= move.company_id.tax_lock_date:
+ move.tax_lock_date_message = _(
+ "The accounting date is set prior to the tax lock date which is set on %s. "
+ "Hence, the accounting date will be changed to the next available date when posting.",
+ format_date(self.env, move.company_id.tax_lock_date))
+ else:
+ move.tax_lock_date_message = False
+
+ @api.depends('restrict_mode_hash_table', 'state')
+ def _compute_show_reset_to_draft_button(self):
+ for move in self:
+ move.show_reset_to_draft_button = not move.restrict_mode_hash_table and move.state in ('posted', 'cancel')
+
+ # -------------------------------------------------------------------------
+ # BUSINESS MODELS SYNCHRONIZATION
+ # -------------------------------------------------------------------------
+
+ def _synchronize_business_models(self, changed_fields):
+ ''' Ensure the consistency between:
+ account.payment & account.move
+ account.bank.statement.line & account.move
+
+ The idea is to call the method performing the synchronization of the business
+ models regarding their related journal entries. To avoid cycling, the
+ 'skip_account_move_synchronization' key is used through the context.
+
+ :param changed_fields: A set containing all modified fields on account.move.
+ '''
+ if self._context.get('skip_account_move_synchronization'):
+ return
+
+ self_sudo = self.sudo()
+ self_sudo.payment_id._synchronize_from_moves(changed_fields)
+ self_sudo.statement_line_id._synchronize_from_moves(changed_fields)
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('name', 'journal_id', 'state')
+ def _check_unique_sequence_number(self):
+ moves = self.filtered(lambda move: move.state == 'posted')
+ if not moves:
+ return
+
+ self.flush(['name', 'journal_id', 'move_type', 'state'])
+
+ # /!\ Computed stored fields are not yet inside the database.
+ self._cr.execute('''
+ SELECT move2.id, move2.name
+ FROM account_move move
+ INNER JOIN account_move move2 ON
+ move2.name = move.name
+ AND move2.journal_id = move.journal_id
+ AND move2.move_type = move.move_type
+ AND move2.id != move.id
+ WHERE move.id IN %s AND move2.state = 'posted'
+ ''', [tuple(moves.ids)])
+ res = self._cr.fetchall()
+ if res:
+ raise ValidationError(_('Posted journal entry must have an unique sequence number per company.\n'
+ 'Problematic numbers: %s\n') % ', '.join(r[1] for r in res))
+
+ @api.constrains('ref', 'move_type', 'partner_id', 'journal_id', 'invoice_date')
+ def _check_duplicate_supplier_reference(self):
+ moves = self.filtered(lambda move: move.is_purchase_document() and move.ref)
+ if not moves:
+ return
+
+ self.env["account.move"].flush([
+ "ref", "move_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('''
+ SELECT move2.id
+ 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 = journal.company_id
+ AND move2.commercial_partner_id = partner.commercial_partner_id
+ AND move2.move_type = move.move_type
+ AND (move.invoice_date is NULL OR move2.invoice_date = move.invoice_date)
+ AND move2.id != move.id
+ WHERE move.id IN %s
+ ''', [tuple(moves.ids)])
+ duplicated_moves = self.browse([r[0] for r in self._cr.fetchall()])
+ if duplicated_moves:
+ raise ValidationError(_('Duplicated vendor reference detected. You probably encoded twice the same vendor bill/credit note:\n%s') % "\n".join(
+ duplicated_moves.mapped(lambda m: "%(partner)s - %(ref)s - %(date)s" % {
+ 'ref': m.ref,
+ 'partner': m.partner_id.display_name,
+ 'date': format_date(self.env, m.invoice_date),
+ })
+ ))
+
+ def _check_balanced(self):
+ ''' Assert the move is fully balanced debit = credit.
+ An error is raised if it's not the case.
+ '''
+ moves = self.filtered(lambda move: move.line_ids)
+ if not moves:
+ return
+
+ # /!\ As this method is called in create / write, we can't make the assumption the computed stored fields
+ # are already done. Then, this query MUST NOT depend of computed stored fields (e.g. balance).
+ # It happens as the ORM makes the create with the 'no_recompute' statement.
+ self.env['account.move.line'].flush(self.env['account.move.line']._fields)
+ self.env['account.move'].flush(['journal_id'])
+ self._cr.execute('''
+ SELECT line.move_id, ROUND(SUM(line.debit - line.credit), currency.decimal_places)
+ FROM account_move_line line
+ JOIN account_move move ON move.id = line.move_id
+ JOIN account_journal journal ON journal.id = move.journal_id
+ JOIN res_company company ON company.id = journal.company_id
+ JOIN res_currency currency ON currency.id = company.currency_id
+ WHERE line.move_id IN %s
+ GROUP BY line.move_id, currency.decimal_places
+ HAVING ROUND(SUM(line.debit - line.credit), currency.decimal_places) != 0.0;
+ ''', [tuple(self.ids)])
+
+ query_res = self._cr.fetchall()
+ if query_res:
+ ids = [res[0] for res in query_res]
+ sums = [res[1] for res in query_res]
+ raise UserError(_("Cannot create unbalanced journal entry. Ids: %s\nDifferences debit - credit: %s") % (ids, sums))
+
+ def _check_fiscalyear_lock_date(self):
+ for move in self:
+ lock_date = move.company_id._get_user_fiscal_lock_date()
+ if move.date <= lock_date:
+ if self.user_has_groups('account.group_account_manager'):
+ message = _("You cannot add/modify entries prior to and inclusive of the lock date %s.", format_date(self.env, lock_date))
+ else:
+ message = _("You cannot add/modify entries prior to and inclusive of the lock date %s. Check the company settings or ask someone with the 'Adviser' role", format_date(self.env, lock_date))
+ raise UserError(message)
+ return True
+
+ @api.constrains('move_type', 'journal_id')
+ def _check_journal_type(self):
+ for record in self:
+ journal_type = record.journal_id.type
+
+ if record.is_sale_document() and journal_type != 'sale' or record.is_purchase_document() and journal_type != 'purchase':
+ raise ValidationError(_("The chosen journal has a type that is not compatible with your invoice type. Sales operations should go to 'sale' journals, and purchase operations to 'purchase' ones."))
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ def _move_autocomplete_invoice_lines_values(self):
+ ''' This method recomputes dynamic lines on the current journal entry that include taxes, cash rounding
+ and payment terms lines.
+ '''
+ self.ensure_one()
+
+ for line in self.line_ids:
+ # Do something only on invoice lines.
+ if line.exclude_from_invoice_tab:
+ continue
+
+ # Shortcut to load the demo data.
+ # Doing line.account_id triggers a default_get(['account_id']) that could returns a result.
+ # A section / note must not have an account_id set.
+ if not line._cache.get('account_id') and not line.display_type and not line._origin:
+ line.account_id = line._get_computed_account() or self.journal_id.default_account_id
+ if line.product_id and not line._cache.get('name'):
+ line.name = line._get_computed_name()
+
+ # Compute the account before the partner_id
+ # In case account_followup is installed
+ # Setting the partner will get the account_id in cache
+ # If the account_id is not in cache, it will trigger the default value
+ # Which is wrong in some case
+ # It's better to set the account_id before the partner_id
+ # Ensure related fields are well copied.
+ if line.partner_id != self.partner_id.commercial_partner_id:
+ line.partner_id = self.partner_id.commercial_partner_id
+ line.date = self.date
+ line.recompute_tax_line = True
+ line.currency_id = self.currency_id
+
+
+ self.line_ids._onchange_price_subtotal()
+ self._recompute_dynamic_lines(recompute_all_taxes=True)
+
+ values = self._convert_to_write(self._cache)
+ values.pop('invoice_line_ids', None)
+ return values
+
+ @api.model
+ def _move_autocomplete_invoice_lines_create(self, vals_list):
+ ''' During the create of an account.move with only 'invoice_line_ids' set and not 'line_ids', this method is called
+ to auto compute accounting lines of the invoice. In that case, accounts will be retrieved and taxes, cash rounding
+ and payment terms will be computed. At the end, the values will contains all accounting lines in 'line_ids'
+ and the moves should be balanced.
+
+ :param vals_list: The list of values passed to the 'create' method.
+ :return: Modified list of values.
+ '''
+ new_vals_list = []
+ for vals in vals_list:
+ vals = dict(vals)
+
+ if vals.get('invoice_date') and not vals.get('date'):
+ vals['date'] = vals['invoice_date']
+
+ default_move_type = vals.get('move_type') or self._context.get('default_move_type')
+ ctx_vals = {}
+ if default_move_type:
+ ctx_vals['default_move_type'] = default_move_type
+ if vals.get('journal_id'):
+ ctx_vals['default_journal_id'] = vals['journal_id']
+ # reorder the companies in the context so that the company of the journal
+ # (which will be the company of the move) is the main one, ensuring all
+ # property fields are read with the correct company
+ journal_company = self.env['account.journal'].browse(vals['journal_id']).company_id
+ allowed_companies = self._context.get('allowed_company_ids', journal_company.ids)
+ reordered_companies = sorted(allowed_companies, key=lambda cid: cid != journal_company.id)
+ ctx_vals['allowed_company_ids'] = reordered_companies
+ self_ctx = self.with_context(**ctx_vals)
+ vals = self_ctx._add_missing_default_values(vals)
+
+ is_invoice = vals.get('move_type') in self.get_invoice_types(include_receipts=True)
+
+ if 'line_ids' in vals:
+ vals.pop('invoice_line_ids', None)
+ new_vals_list.append(vals)
+ continue
+
+ if is_invoice and 'invoice_line_ids' in vals:
+ vals['line_ids'] = vals['invoice_line_ids']
+
+ vals.pop('invoice_line_ids', None)
+
+ move = self_ctx.new(vals)
+ new_vals_list.append(move._move_autocomplete_invoice_lines_values())
+
+ return new_vals_list
+
+ def _move_autocomplete_invoice_lines_write(self, vals):
+ ''' During the write of an account.move with only 'invoice_line_ids' set and not 'line_ids', this method is called
+ to auto compute accounting lines of the invoice. In that case, accounts will be retrieved and taxes, cash rounding
+ and payment terms will be computed. At the end, the values will contains all accounting lines in 'line_ids'
+ and the moves should be balanced.
+
+ :param vals_list: A python dict representing the values to write.
+ :return: True if the auto-completion did something, False otherwise.
+ '''
+ enable_autocomplete = 'invoice_line_ids' in vals and 'line_ids' not in vals and True or False
+
+ if not enable_autocomplete:
+ return False
+
+ vals['line_ids'] = vals.pop('invoice_line_ids')
+ for invoice in self:
+ invoice_new = invoice.with_context(default_move_type=invoice.move_type, default_journal_id=invoice.journal_id.id).new(origin=invoice)
+ invoice_new.update(vals)
+ values = invoice_new._move_autocomplete_invoice_lines_values()
+ values.pop('invoice_line_ids', None)
+ invoice.write(values)
+ return True
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ default = dict(default or {})
+ if (fields.Date.to_date(default.get('date')) or self.date) <= self.company_id._get_user_fiscal_lock_date():
+ default['date'] = self.company_id._get_user_fiscal_lock_date() + timedelta(days=1)
+ if self.move_type == 'entry':
+ default['partner_id'] = False
+ return super(AccountMove, self).copy(default)
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # OVERRIDE
+ if any('state' in vals and vals.get('state') == 'posted' for vals in vals_list):
+ raise UserError(_('You cannot create a move already in the posted state. Please create a draft move and post it after.'))
+
+ vals_list = self._move_autocomplete_invoice_lines_create(vals_list)
+ rslt = super(AccountMove, self).create(vals_list)
+ for i, vals in enumerate(vals_list):
+ if 'line_ids' in vals:
+ rslt[i].update_lines_tax_exigibility()
+ return rslt
+
+ def write(self, vals):
+ for move in self:
+ if (move.restrict_mode_hash_table and move.state == "posted" and set(vals).intersection(INTEGRITY_HASH_MOVE_FIELDS)):
+ raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_MOVE_FIELDS))
+ if (move.restrict_mode_hash_table and move.inalterable_hash and 'inalterable_hash' in vals) or (move.secure_sequence_number and 'secure_sequence_number' in vals):
+ raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.'))
+ if (move.posted_before and 'journal_id' in vals and move.journal_id.id != vals['journal_id']):
+ raise UserError(_('You cannot edit the journal of an account move if it has been posted once.'))
+ if (move.name and move.name != '/' and 'journal_id' in vals and move.journal_id.id != vals['journal_id']):
+ raise UserError(_('You cannot edit the journal of an account move if it already has a sequence number assigned.'))
+
+ # You can't change the date of a move being inside a locked period.
+ if 'date' in vals and move.date != vals['date']:
+ move._check_fiscalyear_lock_date()
+ move.line_ids._check_tax_lock_date()
+
+ # You can't post subtract a move to a locked period.
+ if 'state' in vals and move.state == 'posted' and vals['state'] != 'posted':
+ move._check_fiscalyear_lock_date()
+ move.line_ids._check_tax_lock_date()
+
+ if move.journal_id.sequence_override_regex and vals.get('name') and vals['name'] != '/' and not re.match(move.journal_id.sequence_override_regex, vals['name']):
+ if not self.env.user.has_group('account.group_account_manager'):
+ raise UserError(_('The Journal Entry sequence is not conform to the current format. Only the Advisor can change it.'))
+ move.journal_id.sequence_override_regex = False
+
+ if self._move_autocomplete_invoice_lines_write(vals):
+ res = True
+ else:
+ vals.pop('invoice_line_ids', None)
+ res = super(AccountMove, self.with_context(check_move_validity=False, skip_account_move_synchronization=True)).write(vals)
+
+ # You can't change the date of a not-locked move to a locked period.
+ # You can't post a new journal entry inside a locked period.
+ if 'date' in vals or 'state' in vals:
+ self._check_fiscalyear_lock_date()
+ self.mapped('line_ids')._check_tax_lock_date()
+
+ if ('state' in vals and vals.get('state') == 'posted'):
+ for move in self.filtered(lambda m: m.restrict_mode_hash_table and not(m.secure_sequence_number or m.inalterable_hash)).sorted(lambda m: (m.date, m.ref or '', m.id)):
+ new_number = move.journal_id.secure_sequence_id.next_by_id()
+ vals_hashing = {'secure_sequence_number': new_number,
+ 'inalterable_hash': move._get_new_hash(new_number)}
+ res |= super(AccountMove, move).write(vals_hashing)
+
+ # Ensure the move is still well balanced.
+ if 'line_ids' in vals:
+ if self._context.get('check_move_validity', True):
+ self._check_balanced()
+ self.update_lines_tax_exigibility()
+
+ self._synchronize_business_models(set(vals.keys()))
+
+ return res
+
+ def unlink(self):
+ for move in self:
+ if move.posted_before and not self._context.get('force_delete'):
+ raise UserError(_("You cannot delete an entry which has been posted once."))
+ self.line_ids.unlink()
+ return super(AccountMove, self).unlink()
+
+ @api.depends('name', 'state')
+ def name_get(self):
+ result = []
+ for move in self:
+ if self._context.get('name_groupby'):
+ name = '**%s**, %s' % (format_date(self.env, move.date), move._get_move_display_name())
+ if move.ref:
+ name += ' (%s)' % move.ref
+ if move.partner_id.name:
+ name += ' - %s' % move.partner_id.name
+ else:
+ name = move._get_move_display_name(show_ref=True)
+ result.append((move.id, name))
+ return result
+
+ def _creation_subtype(self):
+ # OVERRIDE
+ if self.move_type in ('out_invoice', 'out_refund', 'out_receipt'):
+ return self.env.ref('account.mt_invoice_created')
+ else:
+ return super(AccountMove, self)._creation_subtype()
+
+ def _track_subtype(self, init_values):
+ # OVERRIDE to add custom subtype depending of the state.
+ self.ensure_one()
+
+ if not self.is_invoice(include_receipts=True):
+ return super(AccountMove, self)._track_subtype(init_values)
+
+ if 'payment_state' in init_values and self.payment_state == 'paid':
+ return self.env.ref('account.mt_invoice_paid')
+ elif 'state' in init_values and self.state == 'posted' and self.is_sale_document(include_receipts=True):
+ return self.env.ref('account.mt_invoice_validated')
+ return super(AccountMove, self)._track_subtype(init_values)
+
+ def _creation_message(self):
+ # OVERRIDE
+ if not self.is_invoice(include_receipts=True):
+ return super()._creation_message()
+ return {
+ 'out_invoice': _('Invoice Created'),
+ 'out_refund': _('Credit Note Created'),
+ 'in_invoice': _('Vendor Bill Created'),
+ 'in_refund': _('Refund Created'),
+ 'out_receipt': _('Sales Receipt Created'),
+ 'in_receipt': _('Purchase Receipt Created'),
+ }[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'],
+ '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
+
+ # Determine is the move is now fully paid.
+ values['is_fully_paid'] = self.company_id.currency_id.is_zero(values['total_residual']) \
+ or values['currency'].is_zero(values['total_residual_currency'])
+
+ return values
+
+ # -------------------------------------------------------------------------
+ # BUSINESS METHODS
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def get_invoice_types(self, include_receipts=False):
+ return ['out_invoice', 'out_refund', 'in_refund', 'in_invoice'] + (include_receipts and ['out_receipt', 'in_receipt'] or [])
+
+ def is_invoice(self, include_receipts=False):
+ return self.move_type in self.get_invoice_types(include_receipts)
+
+ @api.model
+ def get_sale_types(self, include_receipts=False):
+ return ['out_invoice', 'out_refund'] + (include_receipts and ['out_receipt'] or [])
+
+ def is_sale_document(self, include_receipts=False):
+ return self.move_type in self.get_sale_types(include_receipts)
+
+ @api.model
+ def get_purchase_types(self, include_receipts=False):
+ return ['in_invoice', 'in_refund'] + (include_receipts and ['in_receipt'] or [])
+
+ def is_purchase_document(self, include_receipts=False):
+ return self.move_type in self.get_purchase_types(include_receipts)
+
+ @api.model
+ def get_inbound_types(self, include_receipts=True):
+ return ['out_invoice', 'in_refund'] + (include_receipts and ['out_receipt'] or [])
+
+ def is_inbound(self, include_receipts=True):
+ return self.move_type in self.get_inbound_types(include_receipts)
+
+ @api.model
+ def get_outbound_types(self, include_receipts=True):
+ return ['in_invoice', 'out_refund'] + (include_receipts and ['in_receipt'] or [])
+
+ def is_outbound(self, include_receipts=True):
+ return self.move_type in self.get_outbound_types(include_receipts)
+
+ def _affect_tax_report(self):
+ return any(line._affect_tax_report() for line in self.line_ids)
+
+ def _get_invoice_reference_euro_invoice(self):
+ """ This computes the reference based on the RF Creditor Reference.
+ The data of the reference is the database id number of the invoice.
+ For instance, if an invoice is issued with id 43, the check number
+ is 07 so the reference will be 'RF07 43'.
+ """
+ self.ensure_one()
+ base = self.id
+ check_digits = calc_check_digits('{}RF'.format(base))
+ reference = 'RF{} {}'.format(check_digits, " ".join(["".join(x) for x in zip_longest(*[iter(str(base))]*4, fillvalue="")]))
+ return reference
+
+ def _get_invoice_reference_euro_partner(self):
+ """ This computes the reference based on the RF Creditor Reference.
+ The data of the reference is the user defined reference of the
+ partner or the database id number of the parter.
+ For instance, if an invoice is issued for the partner with internal
+ reference 'food buyer 654', the digits will be extracted and used as
+ the data. This will lead to a check number equal to 00 and the
+ reference will be 'RF00 654'.
+ If no reference is set for the partner, its id in the database will
+ be used.
+ """
+ self.ensure_one()
+ partner_ref = self.partner_id.ref
+ partner_ref_nr = re.sub('\D', '', partner_ref or '')[-21:] or str(self.partner_id.id)[-21:]
+ partner_ref_nr = partner_ref_nr[-21:]
+ check_digits = calc_check_digits('{}RF'.format(partner_ref_nr))
+ reference = 'RF{} {}'.format(check_digits, " ".join(["".join(x) for x in zip_longest(*[iter(partner_ref_nr)]*4, fillvalue="")]))
+ return reference
+
+ def _get_invoice_reference_odoo_invoice(self):
+ """ This computes the reference based on the Odoo format.
+ We simply return the number of the invoice, defined on the journal
+ sequence.
+ """
+ self.ensure_one()
+ return self.name
+
+ def _get_invoice_reference_odoo_partner(self):
+ """ This computes the reference based on the Odoo format.
+ The data used is the reference set on the partner or its database
+ id otherwise. For instance if the reference of the customer is
+ 'dumb customer 97', the reference will be 'CUST/dumb customer 97'.
+ """
+ ref = self.partner_id.ref or str(self.partner_id.id)
+ prefix = _('CUST')
+ return '%s/%s' % (prefix, ref)
+
+ def _get_invoice_computed_reference(self):
+ self.ensure_one()
+ if self.journal_id.invoice_reference_type == 'none':
+ return ''
+ else:
+ ref_function = getattr(self, '_get_invoice_reference_{}_{}'.format(self.journal_id.invoice_reference_model, self.journal_id.invoice_reference_type))
+ if ref_function:
+ return ref_function()
+ 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.
+ :return: A string representing the invoice.
+ '''
+ self.ensure_one()
+ draft_name = ''
+ if self.state == 'draft':
+ draft_name += {
+ 'out_invoice': _('Draft Invoice'),
+ 'out_refund': _('Draft Credit Note'),
+ 'in_invoice': _('Draft Bill'),
+ 'in_refund': _('Draft Vendor Credit Note'),
+ 'out_receipt': _('Draft Sales Receipt'),
+ 'in_receipt': _('Draft Purchase Receipt'),
+ 'entry': _('Draft Entry'),
+ }[self.move_type]
+ if not self.name or self.name == '/':
+ draft_name += ' (* %s)' % str(self.id)
+ else:
+ draft_name += ' ' + self.name
+ return (draft_name or self.name) + (show_ref and self.ref and ' (%s%s)' % (self.ref[:50], '...' if len(self.ref) > 50 else '') or '')
+
+ def _get_invoice_delivery_partner_id(self):
+ ''' Hook allowing to retrieve the right delivery address depending of installed modules.
+ :return: A res.partner record's id representing the delivery address.
+ '''
+ self.ensure_one()
+ return self.partner_id.address_get(['delivery'])['delivery']
+
+ 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'))
+ reconciled_amls = reconciled_lines.mapped('matched_debit_ids.debit_move_id') + \
+ reconciled_lines.mapped('matched_credit_ids.credit_move_id')
+ return reconciled_amls.move_id.payment_id
+
+ def _get_reconciled_statement_lines(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'))
+ reconciled_amls = reconciled_lines.mapped('matched_debit_ids.debit_move_id') + \
+ reconciled_lines.mapped('matched_credit_ids.credit_move_id')
+ return reconciled_amls.move_id.statement_line_id
+
+ def _get_reconciled_invoices(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'))
+ reconciled_amls = reconciled_lines.mapped('matched_debit_ids.debit_move_id') + \
+ reconciled_lines.mapped('matched_credit_ids.credit_move_id')
+ return reconciled_amls.move_id.filtered(lambda move: move.is_invoice(include_receipts=True))
+
+ def _get_reconciled_invoices_partials(self):
+ ''' Helper to retrieve the details about reconciled invoices.
+ :return A list of tuple (partial, amount, invoice_line).
+ '''
+ self.ensure_one()
+ pay_term_lines = self.line_ids\
+ .filtered(lambda line: line.account_internal_type in ('receivable', 'payable'))
+ invoice_partials = []
+
+ 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):
+ ''' 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.
+
+ :param default_values: A copy_date of the original journal entry.
+ :param cancel: A flag indicating the reverse is made to cancel the original journal entry.
+ :return: The updated default_values.
+ '''
+ self.ensure_one()
+
+ def compute_tax_repartition_lines_mapping(move_vals):
+ ''' Computes and returns a mapping between the current repartition lines to the new expected one.
+ :param move_vals: The newly created invoice as a python dictionary to be passed to the 'create' method.
+ :return: A map invoice_repartition_line => refund_repartition_line.
+ '''
+ # invoice_repartition_line => refund_repartition_line
+ mapping = {}
+
+ # Do nothing if the move is not a credit note.
+ if move_vals['move_type'] not in ('out_refund', 'in_refund'):
+ return mapping
+
+ for line_command in move_vals.get('line_ids', []):
+ line_vals = line_command[2] # (0, 0, {...})
+
+ if line_vals.get('tax_line_id'):
+ # Tax line.
+ tax_ids = [line_vals['tax_line_id']]
+ elif line_vals.get('tax_ids') and line_vals['tax_ids'][0][2]:
+ # Base line.
+ tax_ids = line_vals['tax_ids'][0][2]
+ else:
+ continue
+
+ for tax in self.env['account.tax'].browse(tax_ids).flatten_taxes_hierarchy():
+ for inv_rep_line, ref_rep_line in zip(tax.invoice_repartition_line_ids, tax.refund_repartition_line_ids):
+ mapping[inv_rep_line] = ref_rep_line
+ return mapping
+
+ move_vals = self.with_context(include_business_fields=True).copy_data(default=default_values)[0]
+
+ tax_repartition_lines_mapping = compute_tax_repartition_lines_mapping(move_vals)
+
+ for line_command in move_vals.get('line_ids', []):
+ line_vals = line_command[2] # (0, 0, {...})
+
+ # ==== Inverse debit / credit / amount_currency ====
+ amount_currency = -line_vals.get('amount_currency', 0.0)
+ balance = line_vals['credit'] - line_vals['debit']
+
+ line_vals.update({
+ 'amount_currency': amount_currency,
+ 'debit': balance > 0.0 and balance or 0.0,
+ 'credit': balance < 0.0 and -balance or 0.0,
+ })
+
+ if move_vals['move_type'] not in ('out_refund', 'in_refund'):
+ continue
+
+ # ==== Map tax repartition lines ====
+ if line_vals.get('tax_repartition_line_id'):
+ # Tax line.
+ invoice_repartition_line = self.env['account.tax.repartition.line'].browse(line_vals['tax_repartition_line_id'])
+ if invoice_repartition_line not in tax_repartition_lines_mapping:
+ raise UserError(_("It seems that the taxes have been modified since the creation of the journal entry. You should create the credit note manually instead."))
+ refund_repartition_line = tax_repartition_lines_mapping[invoice_repartition_line]
+
+ # Find the right account.
+ account_id = self.env['account.move.line']._get_default_tax_account(refund_repartition_line).id
+ if not account_id:
+ if not invoice_repartition_line.account_id:
+ # Keep the current account as the current one comes from the base line.
+ account_id = line_vals['account_id']
+ else:
+ tax = invoice_repartition_line.invoice_tax_id
+ base_line = self.line_ids.filtered(lambda line: tax in line.tax_ids.flatten_taxes_hierarchy())[0]
+ account_id = base_line.account_id.id
+
+ tags = refund_repartition_line.tag_ids
+ if line_vals.get('tax_ids'):
+ subsequent_taxes = self.env['account.tax'].browse(line_vals['tax_ids'][0][2])
+ tags += subsequent_taxes.refund_repartition_line_ids.filtered(lambda x: x.repartition_type == 'base').tag_ids
+
+ line_vals.update({
+ 'tax_repartition_line_id': refund_repartition_line.id,
+ 'account_id': account_id,
+ 'tax_tag_ids': [(6, 0, tags.ids)],
+ })
+ elif line_vals.get('tax_ids') and line_vals['tax_ids'][0][2]:
+ # Base line.
+ taxes = self.env['account.tax'].browse(line_vals['tax_ids'][0][2]).flatten_taxes_hierarchy()
+ invoice_repartition_lines = taxes\
+ .mapped('invoice_repartition_line_ids')\
+ .filtered(lambda line: line.repartition_type == 'base')
+ refund_repartition_lines = invoice_repartition_lines\
+ .mapped(lambda line: tax_repartition_lines_mapping[line])
+
+ line_vals['tax_tag_ids'] = [(6, 0, refund_repartition_lines.mapped('tag_ids').ids)]
+ return move_vals
+
+ def _reverse_moves(self, default_values_list=None, cancel=False):
+ ''' Reverse a recordset of account.move.
+ If cancel parameter is true, the reconcilable or liquidity lines
+ of each original move will be reconciled with its reverse's.
+
+ :param default_values_list: A list of default values to consider per move.
+ ('type' & 'reversed_entry_id' are computed in the method).
+ :return: An account.move recordset, reverse of the current self.
+ '''
+ if not default_values_list:
+ default_values_list = [{} for move in self]
+
+ if cancel:
+ lines = self.mapped('line_ids')
+ # Avoid maximum recursion depth.
+ if lines:
+ lines.remove_move_reconcile()
+
+ reverse_type_map = {
+ 'entry': 'entry',
+ 'out_invoice': 'out_refund',
+ 'out_refund': 'entry',
+ 'in_invoice': 'in_refund',
+ 'in_refund': 'entry',
+ 'out_receipt': 'entry',
+ 'in_receipt': 'entry',
+ }
+
+ move_vals_list = []
+ for move, default_values in zip(self, default_values_list):
+ default_values.update({
+ 'move_type': reverse_type_map[move.move_type],
+ 'reversed_entry_id': move.id,
+ })
+ move_vals_list.append(move.with_context(move_reverse_cancel=cancel)._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:
+ reverse_moves.with_context(move_reverse_cancel=cancel)._post(soft=False)
+ for move, reverse_move in zip(self, reverse_moves):
+ lines = move.line_ids.filtered(
+ lambda x: (x.account_id.reconcile or x.account_id.internal_type == 'liquidity')
+ and not x.reconciled
+ )
+ for line in lines:
+ counterpart_lines = reverse_move.line_ids.filtered(
+ lambda x: x.account_id == line.account_id
+ and x.currency_id == line.currency_id
+ and not x.reconciled
+ )
+ (line + counterpart_lines).with_context(move_reverse_cancel=cancel).reconcile()
+
+ return reverse_moves
+
+ def open_reconcile_view(self):
+ return self.line_ids.open_reconcile_view()
+
+ @api.model
+ def message_new(self, msg_dict, custom_values=None):
+ # OVERRIDE
+ # Add custom behavior when receiving a new invoice through the mail's gateway.
+ if (custom_values or {}).get('move_type', 'entry') not in ('out_invoice', 'in_invoice'):
+ return super().message_new(msg_dict, custom_values=custom_values)
+
+ def is_internal_partner(partner):
+ # Helper to know if the partner is an internal one.
+ return partner.user_ids and all(user.has_group('base.group_user') for user in partner.user_ids)
+
+ # Search for partners in copy.
+ cc_mail_addresses = email_split(msg_dict.get('cc', ''))
+ followers = [partner for partner in self._mail_find_partner_from_emails(cc_mail_addresses) if partner]
+
+ # Search for partner that sent the mail.
+ from_mail_addresses = email_split(msg_dict.get('from', ''))
+ senders = partners = [partner for partner in self._mail_find_partner_from_emails(from_mail_addresses) if partner]
+
+ # Search for partners using the user.
+ if not senders:
+ senders = partners = list(self._mail_search_on_user(from_mail_addresses))
+
+ if partners:
+ # Check we are not in the case when an internal user forwarded the mail manually.
+ if is_internal_partner(partners[0]):
+ # Search for partners in the mail's body.
+ body_mail_addresses = set(email_re.findall(msg_dict.get('body')))
+ partners = [partner for partner in self._mail_find_partner_from_emails(body_mail_addresses) if not is_internal_partner(partner)]
+
+ # Little hack: Inject the mail's subject in the body.
+ if msg_dict.get('subject') and msg_dict.get('body'):
+ msg_dict['body'] = '<div><div><h3>%s</h3></div>%s</div>' % (msg_dict['subject'], msg_dict['body'])
+
+ # Create the invoice.
+ values = {
+ 'name': '/', # we have to give the name otherwise it will be set to the mail's subject
+ 'invoice_source_email': from_mail_addresses[0],
+ 'partner_id': partners and partners[0].id or False,
+ }
+ move_ctx = self.with_context(default_move_type=custom_values['move_type'], default_journal_id=custom_values['journal_id'])
+ move = super(AccountMove, move_ctx).message_new(msg_dict, custom_values=values)
+ move._compute_name() # because the name is given, we need to recompute in case it is the first invoice of the journal
+
+ # Assign followers.
+ all_followers_ids = set(partner.id for partner in followers + senders + partners if is_internal_partner(partner))
+ move.message_subscribe(list(all_followers_ids))
+ return move
+
+ def post(self):
+ warnings.warn(
+ "RedirectWarning method 'post()' is a deprecated alias to 'action_post()' or _post()",
+ DeprecationWarning,
+ stacklevel=2
+ )
+ return self.action_post()
+
+ def _post(self, soft=True):
+ """Post/Validate the documents.
+
+ Posting the documents will give it a number, and check that the document is
+ complete (some fields might not be required if not posted but are required
+ otherwise).
+ If the journal is locked with a hash table, it will be impossible to change
+ some fields afterwards.
+
+ :param soft (bool): if True, future documents are not immediately posted,
+ but are set to be auto posted automatically at the set accounting date.
+ Nothing will be performed on those documents before the accounting date.
+ :return Model<account.move>: the documents that have been posted
+ """
+ if soft:
+ future_moves = self.filtered(lambda move: move.date > fields.Date.context_today(self))
+ future_moves.auto_post = True
+ for move in future_moves:
+ msg = _('This move will be posted at the accounting date: %(date)s', date=format_date(self.env, move.date))
+ move.message_post(body=msg)
+ to_post = self - future_moves
+ else:
+ to_post = self
+
+ # `user_has_group` won't be bypassed by `sudo()` since it doesn't change the user anymore.
+ if not self.env.su and not self.env.user.has_group('account.group_account_invoice'):
+ raise AccessError(_("You don't have the access rights to post an invoice."))
+ for move in to_post:
+ if move.state == 'posted':
+ raise UserError(_('The entry %s (id %s) is already posted.') % (move.name, move.id))
+ if not move.line_ids.filtered(lambda line: not line.display_type):
+ raise UserError(_('You need to add a line before posting.'))
+ if move.auto_post and move.date > fields.Date.context_today(self):
+ date_msg = move.date.strftime(get_lang(self.env).date_format)
+ raise UserError(_("This move is configured to be auto-posted on %s", date_msg))
+
+ if not move.partner_id:
+ if move.is_sale_document():
+ raise UserError(_("The field 'Customer' is required, please complete it to validate the Customer Invoice."))
+ elif move.is_purchase_document():
+ raise UserError(_("The field 'Vendor' is required, please complete it to validate the Vendor Bill."))
+
+ if move.is_invoice(include_receipts=True) and float_compare(move.amount_total, 0.0, precision_rounding=move.currency_id.rounding) < 0:
+ raise UserError(_("You cannot validate an invoice with a negative total amount. You should create a credit note instead. Use the action menu to transform it into a credit note or refund."))
+
+ # Handle case when the invoice_date is not set. In that case, the invoice_date is set at today and then,
+ # lines are recomputed accordingly.
+ # /!\ 'check_move_validity' must be there since the dynamic lines will be recomputed outside the 'onchange'
+ # environment.
+ if not move.invoice_date:
+ if move.is_sale_document(include_receipts=True):
+ move.invoice_date = fields.Date.context_today(self)
+ move.with_context(check_move_validity=False)._onchange_invoice_date()
+ elif move.is_purchase_document(include_receipts=True):
+ raise UserError(_("The Bill/Refund date is required to validate this document."))
+
+ # When the accounting date is prior to the tax lock date, move it automatically to the next available date.
+ # /!\ 'check_move_validity' must be there since the dynamic lines will be recomputed outside the 'onchange'
+ # environment.
+ if (move.company_id.tax_lock_date and move.date <= move.company_id.tax_lock_date) and (move.line_ids.tax_ids or move.line_ids.tax_tag_ids):
+ move.date = move._get_accounting_date(move.invoice_date or move.date, True)
+ move.with_context(check_move_validity=False)._onchange_currency()
+
+ # Create the analytic lines in batch is faster as it leads to less cache invalidation.
+ to_post.mapped('line_ids').create_analytic_lines()
+ to_post.write({
+ 'state': 'posted',
+ 'posted_before': True,
+ })
+
+ for move in to_post:
+ move.message_subscribe([p.id for p in [move.partner_id] if p not in move.sudo().message_partner_ids])
+
+ # Compute 'ref' for 'out_invoice'.
+ if move._auto_compute_invoice_reference():
+ to_write = {
+ 'payment_reference': move._get_invoice_computed_reference(),
+ 'line_ids': []
+ }
+ for line in move.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')):
+ to_write['line_ids'].append((1, line.id, {'name': to_write['payment_reference']}))
+ move.write(to_write)
+
+ for move in to_post:
+ if move.is_sale_document() \
+ and move.journal_id.sale_activity_type_id \
+ and (move.journal_id.sale_activity_user_id or move.invoice_user_id).id not in (self.env.ref('base.user_root').id, False):
+ move.activity_schedule(
+ date_deadline=min((date for date in move.line_ids.mapped('date_maturity') if date), default=move.date),
+ activity_type_id=move.journal_id.sale_activity_type_id.id,
+ summary=move.journal_id.sale_activity_note,
+ user_id=move.journal_id.sale_activity_user_id.id or move.invoice_user_id.id,
+ )
+
+ customer_count, supplier_count = defaultdict(int), defaultdict(int)
+ for move in to_post:
+ if move.is_sale_document():
+ customer_count[move.partner_id] += 1
+ elif move.is_purchase_document():
+ supplier_count[move.partner_id] += 1
+ for partner, count in customer_count.items():
+ (partner | partner.commercial_partner_id)._increase_rank('customer_rank', count)
+ for partner, count in supplier_count.items():
+ (partner | partner.commercial_partner_id)._increase_rank('supplier_rank', count)
+
+ # Trigger action for paid invoices in amount is zero
+ to_post.filtered(
+ lambda m: m.is_invoice(include_receipts=True) and m.currency_id.is_zero(m.amount_total)
+ ).action_invoice_paid()
+
+ # Force balance check since nothing prevents another module to create an incorrect entry.
+ # This is performed at the very end to avoid flushing fields before the whole processing.
+ to_post._check_balanced()
+ return to_post
+
+ def _auto_compute_invoice_reference(self):
+ ''' Hook to be overridden to set custom conditions for auto-computed invoice references.
+ :return True if the move should get a auto-computed reference else False
+ :rtype bool
+ '''
+ self.ensure_one()
+ return self.move_type == 'out_invoice' and not self.payment_reference
+
+ def action_reverse(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_view_account_move_reversal")
+
+ if self.is_invoice():
+ action['name'] = _('Credit Note')
+
+ return action
+
+ def action_post(self):
+ self._post(soft=False)
+ return False
+
+ 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()
+
+ @api.model
+ def setting_upload_bill_wizard(self):
+ """ Called by the 'First Bill' button of the setup bar."""
+ self.env.company.sudo().set_onboarding_step_done('account_setup_bill_state')
+
+ new_wizard = self.env['account.tour.upload.bill'].create({})
+ view_id = self.env.ref('account.account_tour_upload_bill').id
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Import your first bill'),
+ 'view_mode': 'form',
+ 'res_model': 'account.tour.upload.bill',
+ 'target': 'new',
+ 'res_id': new_wizard.id,
+ 'views': [[view_id, 'form']],
+ }
+
+ def button_draft(self):
+ AccountMoveLine = self.env['account.move.line']
+ excluded_move_ids = []
+
+ if self._context.get('suspense_moves_mode'):
+ 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 reset to draft an exchange difference journal entry.'))
+ if move.tax_cash_basis_rec_id:
+ raise UserError(_('You cannot reset to draft a tax cash basis journal entry.'))
+ if move.restrict_mode_hash_table and move.state == 'posted' and move.id not in excluded_move_ids:
+ raise UserError(_('You cannot modify a posted entry of this journal because it is in strict mode.'))
+ # We remove all the analytics entries for this journal
+ move.mapped('line_ids.analytic_line_ids').unlink()
+
+ self.mapped('line_ids').remove_move_reconcile()
+ self.write({'state': 'draft', 'is_move_sent': False})
+
+ def button_cancel(self):
+ self.write({'auto_post': False, 'state': 'cancel'})
+
+ def action_invoice_sent(self):
+ """ Open a window to compose an email, with the edi invoice template
+ message loaded by default
+ """
+ self.ensure_one()
+ template = self.env.ref('account.email_template_edi_invoice', raise_if_not_found=False)
+ lang = False
+ if template:
+ lang = template._render_lang(self.ids)[self.id]
+ if not lang:
+ lang = get_lang(self.env).code
+ compose_form = self.env.ref('account.account_invoice_send_wizard_form', raise_if_not_found=False)
+ ctx = dict(
+ default_model='account.move',
+ default_res_id=self.id,
+ # For the sake of consistency we need a default_res_model if
+ # default_res_id is set. Not renaming default_model as it can
+ # create many side-effects.
+ default_res_model='account.move',
+ default_use_template=bool(template),
+ default_template_id=template and template.id or False,
+ default_composition_mode='comment',
+ mark_invoice_as_sent=True,
+ custom_layout="mail.mail_notification_paynow",
+ model_description=self.with_context(lang=lang).type_name,
+ force_email=True
+ )
+ return {
+ 'name': _('Send Invoice'),
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.invoice.send',
+ 'views': [(compose_form.id, 'form')],
+ 'view_id': compose_form.id,
+ 'target': 'new',
+ 'context': ctx,
+ }
+
+ def _get_new_hash(self, secure_seq_number):
+ """ Returns the hash to write on journal entries when they get posted"""
+ self.ensure_one()
+ #get the only one exact previous move in the securisation sequence
+ prev_move = self.search([('state', '=', 'posted'),
+ ('company_id', '=', self.company_id.id),
+ ('journal_id', '=', self.journal_id.id),
+ ('secure_sequence_number', '!=', 0),
+ ('secure_sequence_number', '=', int(secure_seq_number) - 1)])
+ if prev_move and len(prev_move) != 1:
+ raise UserError(
+ _('An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry.'))
+
+ #build and return the hash
+ return self._compute_hash(prev_move.inalterable_hash if prev_move else u'')
+
+ def _compute_hash(self, previous_hash):
+ """ Computes the hash of the browse_record given as self, based on the hash
+ of the previous record in the company's securisation sequence given as parameter"""
+ self.ensure_one()
+ hash_string = sha256((previous_hash + self.string_to_hash).encode('utf-8'))
+ return hash_string.hexdigest()
+
+ def _compute_string_to_hash(self):
+ def _getattrstring(obj, field_str):
+ field_value = obj[field_str]
+ if obj._fields[field_str].type == 'many2one':
+ field_value = field_value.id
+ return str(field_value)
+
+ for move in self:
+ values = {}
+ for field in INTEGRITY_HASH_MOVE_FIELDS:
+ values[field] = _getattrstring(move, field)
+
+ for line in move.line_ids:
+ for field in INTEGRITY_HASH_LINE_FIELDS:
+ k = 'line_%d_%s' % (line.id, field)
+ values[k] = _getattrstring(line, field)
+ #make the json serialization canonical
+ # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00)
+ move.string_to_hash = dumps(values, sort_keys=True,
+ ensure_ascii=True, indent=None,
+ separators=(',',':'))
+
+ def action_invoice_print(self):
+ """ Print the invoice and mark it as sent, so that we can see more
+ easily the next step of the workflow
+ """
+ if any(not move.is_invoice(include_receipts=True) for move in self):
+ raise UserError(_("Only invoices could be printed."))
+
+ self.filtered(lambda inv: not inv.is_move_sent).write({'is_move_sent': True})
+ if self.user_has_groups('account.group_account_invoice'):
+ return self.env.ref('account.account_invoices').report_action(self)
+ else:
+ return self.env.ref('account.account_invoices_without_payment').report_action(self)
+
+ def action_invoice_paid(self):
+ ''' Hook to be overrided called when the invoice moves to the paid state. '''
+ pass
+
+ def action_register_payment(self):
+ ''' Open the account.payment.register wizard to pay the selected journal entries.
+ :return: An action opening the account.payment.register wizard.
+ '''
+ return {
+ 'name': _('Register Payment'),
+ 'res_model': 'account.payment.register',
+ 'view_mode': 'form',
+ 'context': {
+ 'active_model': 'account.move',
+ 'active_ids': self.ids,
+ },
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ }
+
+ def action_switch_invoice_into_refund_credit_note(self):
+ if any(move.move_type not in ('in_invoice', 'out_invoice') for move in self):
+ raise ValidationError(_("This action isn't available for this document."))
+
+ for move in self:
+ reversed_move = move._reverse_move_vals({}, False)
+ new_invoice_line_ids = []
+ for cmd, virtualid, line_vals in reversed_move['line_ids']:
+ if not line_vals['exclude_from_invoice_tab']:
+ new_invoice_line_ids.append((0, 0,line_vals))
+ if move.amount_total < 0:
+ # Inverse all invoice_line_ids
+ for cmd, virtualid, line_vals in new_invoice_line_ids:
+ line_vals.update({
+ 'quantity' : -line_vals['quantity'],
+ 'amount_currency' : -line_vals['amount_currency'],
+ 'debit' : line_vals['credit'],
+ 'credit' : line_vals['debit']
+ })
+ move.write({
+ 'move_type': move.move_type.replace('invoice', 'refund'),
+ 'invoice_line_ids' : [(5, 0, 0)],
+ 'partner_bank_id': False,
+ })
+ move.write({'invoice_line_ids' : new_invoice_line_ids})
+
+ def _get_report_base_filename(self):
+ if any(not move.is_invoice() for move in self):
+ raise UserError(_("Only invoices could be printed."))
+ return self._get_move_display_name()
+
+ def _get_name_invoice_report(self):
+ """ This method need to be inherit by the localizations if they want to print a custom invoice report instead of
+ the default one. For example please review the l10n_ar module """
+ self.ensure_one()
+ return 'account.report_invoice_document'
+
+ def preview_invoice(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_url',
+ 'target': 'self',
+ 'url': self.get_portal_url(),
+ }
+
+ def _compute_access_url(self):
+ super(AccountMove, self)._compute_access_url()
+ for move in self.filtered(lambda move: move.is_invoice()):
+ move.access_url = '/my/invoices/%s' % (move.id)
+
+ @api.depends('line_ids')
+ def _compute_has_reconciled_entries(self):
+ for move in self:
+ move.has_reconciled_entries = len(move.line_ids._reconciled_lines()) > 1
+
+ def action_view_reverse_entry(self):
+ self.ensure_one()
+
+ # Create action.
+ action = {
+ 'name': _('Reverse Moves'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ }
+ reverse_entries = self.env['account.move'].search([('reversed_entry_id', '=', self.id)])
+ if len(reverse_entries) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': reverse_entries.id,
+ })
+ else:
+ action.update({
+ 'view_mode': 'tree',
+ 'domain': [('id', 'in', reverse_entries.ids)],
+ })
+ return action
+
+ @api.model
+ def _autopost_draft_entries(self):
+ ''' This method is called from a cron job.
+ It is used to post entries such as those created by the module
+ account_asset.
+ '''
+ records = self.search([
+ ('state', '=', 'draft'),
+ ('date', '<=', fields.Date.context_today(self)),
+ ('auto_post', '=', True),
+ ])
+ for ids in self._cr.split_for_in_conditions(records.ids, size=1000):
+ self.browse(ids)._post()
+ if not self.env.registry.in_test_mode():
+ self._cr.commit()
+
+ # offer the possibility to duplicate thanks to a button instead of a hidden menu, which is more visible
+ def action_duplicate(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
+ action['context'] = dict(self.env.context)
+ action['context']['form_view_initial_mode'] = 'edit'
+ action['context']['view_no_maturity'] = False
+ action['views'] = [(self.env.ref('account.view_move_form').id, 'form')]
+ action['res_id'] = self.copy().id
+ return action
+
+ @api.model
+ def _move_dict_to_preview_vals(self, move_vals, currency_id=None):
+ preview_vals = {
+ 'group_name': "%s, %s" % (format_date(self.env, move_vals['date']) or _('[Not set]'), move_vals['ref']),
+ 'items_vals': move_vals['line_ids'],
+ }
+ for line in preview_vals['items_vals']:
+ if 'partner_id' in line[2]:
+ # sudo is needed to compute display_name in a multi companies environment
+ line[2]['partner_id'] = self.env['res.partner'].browse(line[2]['partner_id']).sudo().display_name
+ line[2]['account_id'] = self.env['account.account'].browse(line[2]['account_id']).display_name or _('Destination Account')
+ line[2]['debit'] = currency_id and formatLang(self.env, line[2]['debit'], currency_obj=currency_id) or line[2]['debit']
+ line[2]['credit'] = currency_id and formatLang(self.env, line[2]['credit'], currency_obj=currency_id) or line[2]['debit']
+ return preview_vals
+
+ def generate_qr_code(self):
+ """ Generates and returns a QR-code generation URL for this invoice,
+ raising an error message if something is misconfigured.
+
+ The chosen QR generation method is the one set in qr_method field if there is one,
+ or the first eligible one found. If this search had to be performed and
+ and eligible method was found, qr_method field is set to this method before
+ returning the URL. If no eligible QR method could be found, we return None.
+ """
+ self.ensure_one()
+
+ if not self.is_invoice():
+ raise UserError(_("QR-codes can only be generated for invoice entries."))
+
+ qr_code_method = self.qr_code_method
+ if qr_code_method:
+ # If the user set a qr code generator manually, we check that we can use it
+ if not self.partner_bank_id._eligible_for_qr_code(self.qr_code_method, self.partner_id, self.currency_id):
+ raise UserError(_("The chosen QR-code type is not eligible for this invoice."))
+ else:
+ # Else we find one that's eligible and assign it to the invoice
+ for candidate_method, candidate_name in self.env['res.partner.bank'].get_available_qr_methods_in_sequence():
+ if self.partner_bank_id._eligible_for_qr_code(candidate_method, self.partner_id, self.currency_id):
+ qr_code_method = candidate_method
+ break
+
+ if not qr_code_method:
+ # No eligible method could be found; we can't generate the QR-code
+ return None
+
+ unstruct_ref = self.ref if self.ref else self.name
+ rslt = self.partner_bank_id.build_qr_code_url(self.amount_residual, unstruct_ref, self.payment_reference, self.currency_id, self.partner_id, qr_code_method, silent_errors=False)
+
+ # We only set qr_code_method after generating the url; otherwise, it
+ # could be set even in case of a failure in the QR code generation
+ # (which would change the field, but not refresh UI, making the displayed data inconsistent with db)
+ self.qr_code_method = qr_code_method
+
+ return rslt
+
+ def _message_post_after_hook(self, new_message, message_values):
+ # OVERRIDE
+ # When posting a message, check the attachment to see if it's an invoice and update with the imported data.
+ res = super()._message_post_after_hook(new_message, message_values)
+
+ attachments = new_message.attachment_ids
+ if len(self) != 1 or not attachments or self.env.context.get('no_new_invoice') or not self.is_invoice(include_receipts=True):
+ return res
+
+ odoobot = self.env.ref('base.partner_root')
+ if attachments and self.state != 'draft':
+ self.message_post(body=_('The invoice is not a draft, it was not updated from the attachment.'),
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ author_id=odoobot.id)
+ return res
+ if attachments and self.line_ids:
+ self.message_post(body=_('The invoice already contains lines, it was not updated from the attachment.'),
+ message_type='comment',
+ subtype_xmlid='mail.mt_note',
+ author_id=odoobot.id)
+ return res
+
+ decoders = self.env['account.move']._get_update_invoice_from_attachment_decoders(self)
+ for decoder in sorted(decoders, key=lambda d: d[0]):
+ # start with message_main_attachment_id, that way if OCR is installed, only that one will be parsed.
+ # this is based on the fact that the ocr will be the last decoder.
+ for attachment in attachments.sorted(lambda x: x != self.message_main_attachment_id):
+ invoice = decoder[1](attachment, self)
+ if invoice:
+ return res
+
+ return res
+
+ def _get_create_invoice_from_attachment_decoders(self):
+ """ Returns a list of method that are able to create an invoice from an attachment and a priority.
+
+ :returns: A list of tuples (priority, method) where method takes an attachment as parameter.
+ """
+ return []
+
+ def _get_update_invoice_from_attachment_decoders(self, invoice):
+ """ Returns a list of method that are able to create an invoice from an attachment and a priority.
+
+ :param invoice: The invoice on which to update the data.
+ :returns: A list of tuples (priority, method) where method takes an attachment as parameter.
+ """
+ return []
+
+class AccountMoveLine(models.Model):
+ _name = "account.move.line"
+ _description = "Journal Item"
+ _order = "date desc, move_name desc, id"
+ _check_company_auto = True
+
+ # ==== Business fields ====
+ move_id = fields.Many2one('account.move', string='Journal Entry',
+ index=True, required=True, readonly=True, auto_join=True, ondelete="cascade",
+ check_company=True,
+ help="The move of this entry line.")
+ move_name = fields.Char(string='Number', related='move_id.name', store=True, index=True)
+ date = fields.Date(related='move_id.date', store=True, readonly=True, index=True, copy=False, group_operator='min')
+ ref = fields.Char(related='move_id.ref', store=True, copy=False, index=True, readonly=False)
+ parent_state = fields.Selection(related='move_id.state', store=True, readonly=True)
+ journal_id = fields.Many2one(related='move_id.journal_id', store=True, index=True, copy=False)
+ company_id = fields.Many2one(related='move_id.company_id', store=True, readonly=True, default=lambda self: self.env.company)
+ company_currency_id = fields.Many2one(related='company_id.currency_id', string='Company Currency',
+ readonly=True, store=True,
+ help='Utility field to express amount currency')
+ tax_fiscal_country_id = fields.Many2one(comodel_name='res.country', related='move_id.company_id.account_tax_fiscal_country_id')
+ account_id = fields.Many2one('account.account', string='Account',
+ index=True, ondelete="cascade",
+ domain="[('deprecated', '=', False), ('company_id', '=', 'company_id'),('is_off_balance', '=', False)]",
+ check_company=True,
+ tracking=True)
+ account_internal_type = fields.Selection(related='account_id.user_type_id.type', string="Internal Type", readonly=True)
+ account_internal_group = fields.Selection(related='account_id.user_type_id.internal_group', string="Internal Group", readonly=True)
+ account_root_id = fields.Many2one(related='account_id.root_id', string="Account Root", store=True, readonly=True)
+ sequence = fields.Integer(default=10)
+ name = fields.Char(string='Label', tracking=True)
+ quantity = fields.Float(string='Quantity',
+ default=1.0, digits='Product Unit of Measure',
+ help="The optional quantity expressed by this line, eg: number of product sold. "
+ "The quantity is not a legal requirement but is very useful for some reports.")
+ price_unit = fields.Float(string='Unit Price', digits='Product Price')
+ discount = fields.Float(string='Discount (%)', digits='Discount', default=0.0)
+ debit = fields.Monetary(string='Debit', default=0.0, currency_field='company_currency_id')
+ credit = fields.Monetary(string='Credit', default=0.0, currency_field='company_currency_id')
+ balance = fields.Monetary(string='Balance', store=True,
+ currency_field='company_currency_id',
+ compute='_compute_balance',
+ help="Technical field holding the debit - credit in order to open meaningful graph views from reports")
+ cumulated_balance = fields.Monetary(string='Cumulated Balance', store=False,
+ currency_field='company_currency_id',
+ compute='_compute_cumulated_balance',
+ help="Cumulated balance depending on the domain and the order chosen in the view.")
+ amount_currency = fields.Monetary(string='Amount in Currency', store=True, copy=True,
+ help="The amount expressed in an optional other currency if it is a multi-currency entry.")
+ price_subtotal = fields.Monetary(string='Subtotal', store=True, readonly=True,
+ currency_field='currency_id')
+ price_total = fields.Monetary(string='Total', store=True, readonly=True,
+ currency_field='currency_id')
+ 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,
+ help="This field is used for payable and receivable journal entries. You can put the limit date for the payment of this line.")
+ currency_id = fields.Many2one('res.currency', string='Currency', required=True)
+ partner_id = fields.Many2one('res.partner', string='Partner', ondelete='restrict')
+ product_uom_id = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]")
+ product_id = fields.Many2one('product.product', string='Product', ondelete='restrict')
+ product_uom_category_id = fields.Many2one('uom.category', related='product_id.uom_id.category_id')
+
+ # ==== Origin fields ====
+ reconcile_model_id = fields.Many2one('account.reconcile.model', string="Reconciliation Model", copy=False, readonly=True, check_company=True)
+ payment_id = fields.Many2one('account.payment', index=True, store=True,
+ string="Originator Payment",
+ related='move_id.payment_id',
+ help="The payment that created this entry")
+ statement_line_id = fields.Many2one('account.bank.statement.line', index=True, store=True,
+ string="Originator Statement Line",
+ related='move_id.statement_line_id',
+ help="The statement line that created this entry")
+ statement_id = fields.Many2one(related='statement_line_id.statement_id', store=True, index=True, copy=False,
+ help="The bank statement used for bank reconciliation")
+
+ # ==== Tax fields ====
+ tax_ids = fields.Many2many(
+ comodel_name='account.tax',
+ string="Taxes",
+ context={'active_test': False},
+ check_company=True,
+ help="Taxes that apply on the base amount")
+ tax_line_id = fields.Many2one('account.tax', string='Originator Tax', ondelete='restrict', store=True,
+ compute='_compute_tax_line_id', help="Indicates that this journal item is a tax line")
+ tax_group_id = fields.Many2one(related='tax_line_id.tax_group_id', string='Originator tax group',
+ readonly=True, store=True,
+ help='technical field for widget tax-group-custom-field')
+ tax_base_amount = fields.Monetary(string="Base Amount", store=True, readonly=True,
+ currency_field='company_currency_id')
+ tax_exigible = fields.Boolean(string='Appears in VAT report', default=True, readonly=True,
+ help="Technical field used to mark a tax line as exigible in the vat report or not (only exigible journal items"
+ " are displayed). By default all new journal items are directly exigible, but with the feature cash_basis"
+ " on taxes, some will become exigible only when the payment is recorded.")
+ tax_repartition_line_id = fields.Many2one(comodel_name='account.tax.repartition.line',
+ string="Originator Tax Distribution Line", ondelete='restrict', readonly=True,
+ check_company=True,
+ help="Tax distribution line that caused the creation of this move line, if any")
+ tax_tag_ids = fields.Many2many(string="Tags", comodel_name='account.account.tag', ondelete='restrict',
+ help="Tags assigned to this line by the tax creating it, if any. It determines its impact on financial reports.", tracking=True)
+ tax_audit = fields.Char(string="Tax Audit String", compute="_compute_tax_audit", store=True,
+ help="Computed field, listing the tax grids impacted by this line, and the amount it applies to each of them.")
+
+ # ==== Reconciliation fields ====
+ amount_residual = fields.Monetary(string='Residual Amount', store=True,
+ currency_field='company_currency_id',
+ 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='_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',
+ help='Debit journal items that are matched with this journal item.', readonly=True)
+ matched_credit_ids = fields.One2many('account.partial.reconcile', 'debit_move_id', string='Matched Credits',
+ help='Credit journal items that are matched with this journal item.', readonly=True)
+ matching_number = fields.Char(string="Matching #", compute='_compute_matching_number', store=True, help="Matching number for this line, 'P' if it is only partially reconcile, or the name of the full reconcile if it exists.")
+
+ # ==== Analytic fields ====
+ analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines')
+ analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account',
+ index=True, compute="_compute_analytic_account", store=True, readonly=False, check_company=True, copy=True)
+ analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags',
+ compute="_compute_analytic_account", store=True, readonly=False, check_company=True, copy=True)
+
+ # ==== Onchange / display purpose fields ====
+ recompute_tax_line = fields.Boolean(store=False, readonly=True,
+ help="Technical field used to know on which lines the taxes must be recomputed.")
+ display_type = fields.Selection([
+ ('line_section', 'Section'),
+ ('line_note', 'Note'),
+ ], default=False, help="Technical field for UX purpose.")
+ is_rounding_line = fields.Boolean(help="Technical field used to retrieve the cash rounding line.")
+ exclude_from_invoice_tab = fields.Boolean(help="Technical field used to exclude some lines from the invoice_line_ids tab in the form view.")
+
+ _sql_constraints = [
+ (
+ 'check_credit_debit',
+ 'CHECK(credit + debit>=0 AND credit * debit=0)',
+ 'Wrong credit or debit value in accounting entry !'
+ ),
+ (
+ 'check_accountable_required_fields',
+ "CHECK(COALESCE(display_type IN ('line_section', 'line_note'), 'f') OR account_id IS NOT NULL)",
+ "Missing required account on accountable invoice line."
+ ),
+ (
+ 'check_non_accountable_fields_null',
+ "CHECK(display_type NOT IN ('line_section', 'line_note') OR (amount_currency = 0 AND debit = 0 AND credit = 0 AND account_id IS NULL))",
+ "Forbidden unit price, account and quantity on non-accountable invoice line"
+ ),
+ (
+ 'check_amount_currency_balance_sign',
+ '''CHECK(
+ (
+ (currency_id != company_currency_id)
+ AND
+ (
+ (debit - credit <= 0 AND amount_currency <= 0)
+ OR
+ (debit - credit >= 0 AND amount_currency >= 0)
+ )
+ )
+ OR
+ (
+ currency_id = company_currency_id
+ AND
+ ROUND(debit - credit - amount_currency, 2) = 0
+ )
+ )''',
+ "The amount expressed in the secondary currency must be positive when account is debited and negative when "
+ "account is credited. If the currency is the same as the one from the company, this amount must strictly "
+ "be equal to the balance."
+ ),
+ ]
+
+ # -------------------------------------------------------------------------
+ # HELPERS
+ # -------------------------------------------------------------------------
+
+ @api.model
+ def _get_default_line_name(self, document, amount, currency, date, partner=None):
+ ''' Helper to construct a default label to set on journal items.
+
+ E.g. Vendor Reimbursement $ 1,555.00 - Azure Interior - 05/14/2020.
+
+ :param document: A string representing the type of the document.
+ :param amount: The document's amount.
+ :param currency: The document's currency.
+ :param date: The document's date.
+ :param partner: The optional partner.
+ :return: A string.
+ '''
+ values = ['%s %s' % (document, formatLang(self.env, amount, currency_obj=currency))]
+ if partner:
+ values.append(partner.display_name)
+ values.append(format_date(self.env, fields.Date.to_string(date)))
+ return ' - '.join(values)
+
+ @api.model
+ def _get_default_tax_account(self, repartition_line):
+ tax = repartition_line.invoice_tax_id or repartition_line.refund_tax_id
+ if tax.tax_exigibility == 'on_payment':
+ account = tax.cash_basis_transition_account_id
+ else:
+ account = repartition_line.account_id
+ return account
+
+ def _get_computed_name(self):
+ self.ensure_one()
+
+ if not self.product_id:
+ return ''
+
+ if self.partner_id.lang:
+ product = self.product_id.with_context(lang=self.partner_id.lang)
+ else:
+ product = self.product_id
+
+ values = []
+ if product.partner_ref:
+ values.append(product.partner_ref)
+ if self.journal_id.type == 'sale':
+ if product.description_sale:
+ values.append(product.description_sale)
+ elif self.journal_id.type == 'purchase':
+ if product.description_purchase:
+ values.append(product.description_purchase)
+ return '\n'.join(values)
+
+ def _get_computed_price_unit(self):
+ ''' Helper to get the default price unit based on the product by taking care of the taxes
+ set on the product and the fiscal position.
+ :return: The price unit.
+ '''
+ self.ensure_one()
+
+ if not self.product_id:
+ return 0.0
+
+ company = self.move_id.company_id
+ currency = self.move_id.currency_id
+ company_currency = company.currency_id
+ product_uom = self.product_id.uom_id
+ fiscal_position = self.move_id.fiscal_position_id
+ is_refund_document = self.move_id.move_type in ('out_refund', 'in_refund')
+ move_date = self.move_id.date or fields.Date.context_today(self)
+
+ if self.move_id.is_sale_document(include_receipts=True):
+ product_price_unit = self.product_id.lst_price
+ product_taxes = self.product_id.taxes_id
+ elif self.move_id.is_purchase_document(include_receipts=True):
+ product_price_unit = self.product_id.standard_price
+ product_taxes = self.product_id.supplier_taxes_id
+ else:
+ return 0.0
+ product_taxes = product_taxes.filtered(lambda tax: tax.company_id == company)
+
+ # Apply unit of measure.
+ if self.product_uom_id and self.product_uom_id != product_uom:
+ product_price_unit = product_uom._compute_price(product_price_unit, self.product_uom_id)
+
+ # Apply fiscal position.
+ if product_taxes and fiscal_position:
+ product_taxes_after_fp = fiscal_position.map_tax(product_taxes, partner=self.partner_id)
+
+ if set(product_taxes.ids) != set(product_taxes_after_fp.ids):
+ flattened_taxes_before_fp = product_taxes._origin.flatten_taxes_hierarchy()
+ if any(tax.price_include for tax in flattened_taxes_before_fp):
+ taxes_res = flattened_taxes_before_fp.compute_all(
+ product_price_unit,
+ quantity=1.0,
+ currency=company_currency,
+ product=self.product_id,
+ partner=self.partner_id,
+ is_refund=is_refund_document,
+ )
+ product_price_unit = company_currency.round(taxes_res['total_excluded'])
+
+ flattened_taxes_after_fp = product_taxes_after_fp._origin.flatten_taxes_hierarchy()
+ if any(tax.price_include for tax in flattened_taxes_after_fp):
+ taxes_res = flattened_taxes_after_fp.compute_all(
+ product_price_unit,
+ quantity=1.0,
+ currency=company_currency,
+ product=self.product_id,
+ partner=self.partner_id,
+ is_refund=is_refund_document,
+ handle_price_include=False,
+ )
+ for tax_res in taxes_res['taxes']:
+ tax = self.env['account.tax'].browse(tax_res['id'])
+ if tax.price_include:
+ product_price_unit += tax_res['amount']
+
+ # Apply currency rate.
+ if currency and currency != company_currency:
+ product_price_unit = company_currency._convert(product_price_unit, currency, company, move_date)
+
+ return product_price_unit
+
+ def _get_computed_account(self):
+ self.ensure_one()
+ self = self.with_company(self.move_id.journal_id.company_id)
+
+ if not self.product_id:
+ return
+
+ fiscal_position = self.move_id.fiscal_position_id
+ accounts = self.product_id.product_tmpl_id.get_product_accounts(fiscal_pos=fiscal_position)
+ if self.move_id.is_sale_document(include_receipts=True):
+ # Out invoice.
+ return accounts['income'] or self.account_id
+ elif self.move_id.is_purchase_document(include_receipts=True):
+ # In invoice.
+ return accounts['expense'] or self.account_id
+
+ def _get_computed_taxes(self):
+ self.ensure_one()
+
+ if self.move_id.is_sale_document(include_receipts=True):
+ # Out invoice.
+ if self.product_id.taxes_id:
+ tax_ids = self.product_id.taxes_id.filtered(lambda tax: tax.company_id == self.move_id.company_id)
+ elif self.account_id.tax_ids:
+ tax_ids = self.account_id.tax_ids
+ else:
+ tax_ids = self.env['account.tax']
+ if not tax_ids and not self.exclude_from_invoice_tab:
+ tax_ids = self.move_id.company_id.account_sale_tax_id
+ elif self.move_id.is_purchase_document(include_receipts=True):
+ # In invoice.
+ if self.product_id.supplier_taxes_id:
+ tax_ids = self.product_id.supplier_taxes_id.filtered(lambda tax: tax.company_id == self.move_id.company_id)
+ elif self.account_id.tax_ids:
+ tax_ids = self.account_id.tax_ids
+ else:
+ tax_ids = self.env['account.tax']
+ if not tax_ids and not self.exclude_from_invoice_tab:
+ tax_ids = self.move_id.company_id.account_purchase_tax_id
+ else:
+ # Miscellaneous operation.
+ tax_ids = self.account_id.tax_ids
+
+ if self.company_id and tax_ids:
+ tax_ids = tax_ids.filtered(lambda tax: tax.company_id == self.company_id)
+
+ return tax_ids
+
+ def _get_computed_uom(self):
+ self.ensure_one()
+ if self.product_id:
+ return self.product_id.uom_id
+ return False
+
+ def _set_price_and_tax_after_fpos(self):
+ self.ensure_one()
+ # Manage the fiscal position after that and adapt the price_unit.
+ # E.g. mapping a price-included-tax to a price-excluded-tax must
+ # remove the tax amount from the price_unit.
+ # However, mapping a price-included tax to another price-included tax must preserve the balance but
+ # adapt the price_unit to the new tax.
+ # E.g. mapping a 10% price-included tax to a 20% price-included tax for a price_unit of 110 should preserve
+ # 100 as balance but set 120 as price_unit.
+ if self.tax_ids and self.move_id.fiscal_position_id and self.move_id.fiscal_position_id.tax_ids:
+ price_subtotal = self._get_price_total_and_subtotal()['price_subtotal']
+ self.tax_ids = self.move_id.fiscal_position_id.map_tax(
+ self.tax_ids._origin,
+ partner=self.move_id.partner_id)
+ accounting_vals = self._get_fields_onchange_subtotal(
+ price_subtotal=price_subtotal,
+ currency=self.move_id.company_currency_id)
+ amount_currency = accounting_vals['amount_currency']
+ business_vals = self._get_fields_onchange_balance(amount_currency=amount_currency)
+ if 'price_unit' in business_vals:
+ self.price_unit = business_vals['price_unit']
+
+ @api.depends('product_id', 'account_id', 'partner_id', 'date')
+ def _compute_analytic_account(self):
+ for record in self:
+ if not record.exclude_from_invoice_tab or not record.move_id.is_invoice(include_receipts=True):
+ rec = self.env['account.analytic.default'].account_get(
+ product_id=record.product_id.id,
+ partner_id=record.partner_id.commercial_partner_id.id or record.move_id.partner_id.commercial_partner_id.id,
+ account_id=record.account_id.id,
+ user_id=record.env.uid,
+ date=record.date,
+ company_id=record.move_id.company_id.id
+ )
+ if rec:
+ record.analytic_account_id = rec.analytic_id
+ record.analytic_tag_ids = rec.analytic_tag_ids
+
+ def _get_price_total_and_subtotal(self, price_unit=None, quantity=None, discount=None, currency=None, product=None, partner=None, taxes=None, move_type=None):
+ self.ensure_one()
+ return self._get_price_total_and_subtotal_model(
+ price_unit=price_unit or self.price_unit,
+ quantity=quantity or self.quantity,
+ discount=discount or self.discount,
+ currency=currency or self.currency_id,
+ product=product or self.product_id,
+ partner=partner or self.partner_id,
+ taxes=taxes or self.tax_ids,
+ move_type=move_type or self.move_id.move_type,
+ )
+
+ @api.model
+ def _get_price_total_and_subtotal_model(self, price_unit, quantity, discount, currency, product, partner, taxes, move_type):
+ ''' This method is used to compute 'price_total' & 'price_subtotal'.
+
+ :param price_unit: The current price unit.
+ :param quantity: The current quantity.
+ :param discount: The current discount.
+ :param currency: The line's currency.
+ :param product: The line's product.
+ :param partner: The line's partner.
+ :param taxes: The applied taxes.
+ :param move_type: The type of the move.
+ :return: A dictionary containing 'price_subtotal' & 'price_total'.
+ '''
+ res = {}
+
+ # Compute 'price_subtotal'.
+ line_discount_price_unit = price_unit * (1 - (discount / 100.0))
+ subtotal = quantity * line_discount_price_unit
+
+ # Compute 'price_total'.
+ if taxes:
+ force_sign = -1 if move_type in ('out_invoice', 'in_refund', 'out_receipt') else 1
+ taxes_res = taxes._origin.with_context(force_sign=force_sign).compute_all(line_discount_price_unit,
+ quantity=quantity, currency=currency, product=product, partner=partner, is_refund=move_type in ('out_refund', 'in_refund'))
+ res['price_subtotal'] = taxes_res['total_excluded']
+ res['price_total'] = taxes_res['total_included']
+ else:
+ res['price_total'] = res['price_subtotal'] = subtotal
+ #In case of multi currency, round before it's use for computing debit credit
+ if currency:
+ res = {k: currency.round(v) for k, v in res.items()}
+ return res
+
+ def _get_fields_onchange_subtotal(self, price_subtotal=None, move_type=None, currency=None, company=None, date=None):
+ self.ensure_one()
+ return self._get_fields_onchange_subtotal_model(
+ price_subtotal=price_subtotal or self.price_subtotal,
+ move_type=move_type or self.move_id.move_type,
+ currency=currency or self.currency_id,
+ company=company or self.move_id.company_id,
+ date=date or self.move_id.date,
+ )
+
+ @api.model
+ def _get_fields_onchange_subtotal_model(self, price_subtotal, move_type, currency, company, date):
+ ''' This method is used to recompute the values of 'amount_currency', 'debit', 'credit' due to a change made
+ in some business fields (affecting the 'price_subtotal' field).
+
+ :param price_subtotal: The untaxed amount.
+ :param move_type: The type of the move.
+ :param currency: The line's currency.
+ :param company: The move's company.
+ :param date: The move's date.
+ :return: A dictionary containing 'debit', 'credit', 'amount_currency'.
+ '''
+ if move_type in self.move_id.get_outbound_types():
+ sign = 1
+ elif move_type in self.move_id.get_inbound_types():
+ sign = -1
+ else:
+ sign = 1
+
+ amount_currency = price_subtotal * sign
+ balance = currency._convert(amount_currency, company.currency_id, company, date or fields.Date.context_today(self))
+ return {
+ 'amount_currency': amount_currency,
+ 'currency_id': currency.id,
+ 'debit': balance > 0.0 and balance or 0.0,
+ 'credit': balance < 0.0 and -balance or 0.0,
+ }
+
+ def _get_fields_onchange_balance(self, quantity=None, discount=None, amount_currency=None, move_type=None, currency=None, taxes=None, price_subtotal=None, force_computation=False):
+ self.ensure_one()
+ return self._get_fields_onchange_balance_model(
+ quantity=quantity or self.quantity,
+ discount=discount or self.discount,
+ amount_currency=amount_currency or self.amount_currency,
+ move_type=move_type or self.move_id.move_type,
+ currency=currency or self.currency_id or self.move_id.currency_id,
+ taxes=taxes or self.tax_ids,
+ price_subtotal=price_subtotal or self.price_subtotal,
+ force_computation=force_computation,
+ )
+
+ @api.model
+ def _get_fields_onchange_balance_model(self, quantity, discount, amount_currency, move_type, currency, taxes, price_subtotal, force_computation=False):
+ ''' This method is used to recompute the values of 'quantity', 'discount', 'price_unit' due to a change made
+ in some accounting fields such as 'balance'.
+
+ This method is a bit complex as we need to handle some special cases.
+ For example, setting a positive balance with a 100% discount.
+
+ :param quantity: The current quantity.
+ :param discount: The current discount.
+ :param amount_currency: The new balance in line's currency.
+ :param move_type: The type of the move.
+ :param currency: The currency.
+ :param taxes: The applied taxes.
+ :param price_subtotal: The price_subtotal.
+ :return: A dictionary containing 'quantity', 'discount', 'price_unit'.
+ '''
+ if move_type in self.move_id.get_outbound_types():
+ sign = 1
+ elif move_type in self.move_id.get_inbound_types():
+ sign = -1
+ else:
+ sign = 1
+ amount_currency *= sign
+
+ # Avoid rounding issue when dealing with price included taxes. For example, when the price_unit is 2300.0 and
+ # a 5.5% price included tax is applied on it, a balance of 2300.0 / 1.055 = 2180.094 ~ 2180.09 is computed.
+ # However, when triggering the inverse, 2180.09 + (2180.09 * 0.055) = 2180.09 + 119.90 = 2299.99 is computed.
+ # To avoid that, set the price_subtotal at the balance if the difference between them looks like a rounding
+ # issue.
+ if not force_computation and currency.is_zero(amount_currency - price_subtotal):
+ return {}
+
+ taxes = taxes.flatten_taxes_hierarchy()
+ if taxes and any(tax.price_include for tax in taxes):
+ # Inverse taxes. E.g:
+ #
+ # Price Unit | Taxes | Originator Tax |Price Subtotal | Price Total
+ # -----------------------------------------------------------------------------------
+ # 110 | 10% incl, 5% | | 100 | 115
+ # 10 | | 10% incl | 10 | 10
+ # 5 | | 5% | 5 | 5
+ #
+ # When setting the balance to -200, the expected result is:
+ #
+ # Price Unit | Taxes | Originator Tax |Price Subtotal | Price Total
+ # -----------------------------------------------------------------------------------
+ # 220 | 10% incl, 5% | | 200 | 230
+ # 20 | | 10% incl | 20 | 20
+ # 10 | | 5% | 10 | 10
+ force_sign = -1 if move_type in ('out_invoice', 'in_refund', 'out_receipt') else 1
+ taxes_res = taxes._origin.with_context(force_sign=force_sign).compute_all(amount_currency, currency=currency, handle_price_include=False)
+ for tax_res in taxes_res['taxes']:
+ tax = self.env['account.tax'].browse(tax_res['id'])
+ if tax.price_include:
+ amount_currency += tax_res['amount']
+
+ discount_factor = 1 - (discount / 100.0)
+ if amount_currency and discount_factor:
+ # discount != 100%
+ vals = {
+ 'quantity': quantity or 1.0,
+ 'price_unit': amount_currency / discount_factor / (quantity or 1.0),
+ }
+ elif amount_currency and not discount_factor:
+ # discount == 100%
+ vals = {
+ 'quantity': quantity or 1.0,
+ 'discount': 0.0,
+ 'price_unit': amount_currency / (quantity or 1.0),
+ }
+ elif not discount_factor:
+ # balance of line is 0, but discount == 100% so we display the normal unit_price
+ vals = {}
+ else:
+ # balance is 0, so unit price is 0 as well
+ vals = {'price_unit': 0.0}
+ return vals
+
+ # -------------------------------------------------------------------------
+ # ONCHANGE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.onchange('amount_currency', 'currency_id', 'debit', 'credit', 'tax_ids', 'account_id', 'price_unit')
+ def _onchange_mark_recompute_taxes(self):
+ ''' Recompute the dynamic onchange based on taxes.
+ If the edited line is a tax line, don't recompute anything as the user must be able to
+ set a custom value.
+ '''
+ for line in self:
+ if not line.tax_repartition_line_id:
+ line.recompute_tax_line = True
+
+ @api.onchange('analytic_account_id', 'analytic_tag_ids')
+ def _onchange_mark_recompute_taxes_analytic(self):
+ ''' Trigger tax recomputation only when some taxes with analytics
+ '''
+ for line in self:
+ if not line.tax_repartition_line_id and any(tax.analytic for tax in line.tax_ids):
+ line.recompute_tax_line = True
+
+ @api.onchange('product_id')
+ def _onchange_product_id(self):
+ for line in self:
+ if not line.product_id or line.display_type in ('line_section', 'line_note'):
+ continue
+
+ line.name = line._get_computed_name()
+ line.account_id = line._get_computed_account()
+ taxes = line._get_computed_taxes()
+ if taxes and line.move_id.fiscal_position_id:
+ taxes = line.move_id.fiscal_position_id.map_tax(taxes, partner=line.partner_id)
+ line.tax_ids = taxes
+ line.product_uom_id = line._get_computed_uom()
+ line.price_unit = line._get_computed_price_unit()
+
+ @api.onchange('product_uom_id')
+ def _onchange_uom_id(self):
+ ''' Recompute the 'price_unit' depending of the unit of measure. '''
+ if self.display_type in ('line_section', 'line_note'):
+ return
+ taxes = self._get_computed_taxes()
+ if taxes and self.move_id.fiscal_position_id:
+ taxes = self.move_id.fiscal_position_id.map_tax(taxes, partner=self.partner_id)
+ self.tax_ids = taxes
+ self.price_unit = self._get_computed_price_unit()
+
+ @api.onchange('account_id')
+ def _onchange_account_id(self):
+ ''' Recompute 'tax_ids' based on 'account_id'.
+ /!\ Don't remove existing taxes if there is no explicit taxes set on the account.
+ '''
+ if not self.display_type and (self.account_id.tax_ids or not self.tax_ids):
+ taxes = self._get_computed_taxes()
+
+ if taxes and self.move_id.fiscal_position_id:
+ taxes = self.move_id.fiscal_position_id.map_tax(taxes, partner=self.partner_id)
+
+ self.tax_ids = taxes
+
+ def _onchange_balance(self):
+ for line in self:
+ if line.currency_id == line.move_id.company_id.currency_id:
+ line.amount_currency = line.balance
+ else:
+ continue
+ if not line.move_id.is_invoice(include_receipts=True):
+ continue
+ line.update(line._get_fields_onchange_balance())
+
+ @api.onchange('debit')
+ def _onchange_debit(self):
+ if self.debit:
+ self.credit = 0.0
+ self._onchange_balance()
+
+ @api.onchange('credit')
+ def _onchange_credit(self):
+ if self.credit:
+ self.debit = 0.0
+ self._onchange_balance()
+
+ @api.onchange('amount_currency')
+ def _onchange_amount_currency(self):
+ for line in self:
+ company = line.move_id.company_id
+ balance = line.currency_id._convert(line.amount_currency, company.currency_id, company, line.move_id.date)
+ line.debit = balance if balance > 0.0 else 0.0
+ line.credit = -balance if balance < 0.0 else 0.0
+
+ if not line.move_id.is_invoice(include_receipts=True):
+ continue
+
+ line.update(line._get_fields_onchange_balance())
+ line.update(line._get_price_total_and_subtotal())
+
+ @api.onchange('quantity', 'discount', 'price_unit', 'tax_ids')
+ def _onchange_price_subtotal(self):
+ for line in self:
+ if not line.move_id.is_invoice(include_receipts=True):
+ continue
+
+ line.update(line._get_price_total_and_subtotal())
+ line.update(line._get_fields_onchange_subtotal())
+
+ @api.onchange('currency_id')
+ def _onchange_currency(self):
+ for line in self:
+ company = line.move_id.company_id
+
+ if line.move_id.is_invoice(include_receipts=True):
+ line._onchange_price_subtotal()
+ elif not line.move_id.reversed_entry_id:
+ balance = line.currency_id._convert(line.amount_currency, company.currency_id, company, line.move_id.date or fields.Date.context_today(line))
+ line.debit = balance if balance > 0.0 else 0.0
+ line.credit = -balance if balance < 0.0 else 0.0
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('full_reconcile_id.name', 'matched_debit_ids', 'matched_credit_ids')
+ def _compute_matching_number(self):
+ for record in self:
+ if record.full_reconcile_id:
+ record.matching_number = record.full_reconcile_id.name
+ elif record.matched_debit_ids or record.matched_credit_ids:
+ record.matching_number = 'P'
+ else:
+ record.matching_number = None
+
+ @api.depends('debit', 'credit')
+ def _compute_balance(self):
+ for line in self:
+ line.balance = line.debit - line.credit
+
+ @api.model
+ def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
+ def to_tuple(t):
+ return tuple(map(to_tuple, t)) if isinstance(t, (list, tuple)) else t
+ # Make an explicit order because we will need to reverse it
+ order = (order or self._order) + ', id'
+ # Add the domain and order by in order to compute the cumulated balance in _compute_cumulated_balance
+ return super(AccountMoveLine, self.with_context(domain_cumulated_balance=to_tuple(domain or []), order_cumulated_balance=order)).search_read(domain, fields, offset, limit, order)
+
+ @api.depends_context('order_cumulated_balance', 'domain_cumulated_balance')
+ def _compute_cumulated_balance(self):
+ if not self.env.context.get('order_cumulated_balance'):
+ # We do not come from search_read, so we are not in a list view, so it doesn't make any sense to compute the cumulated balance
+ self.cumulated_balance = 0
+ return
+
+ # get the where clause
+ query = self._where_calc(list(self.env.context.get('domain_cumulated_balance') or []))
+ order_string = ", ".join(self._generate_order_by_inner(self._table, self.env.context.get('order_cumulated_balance'), query, reverse_direction=True))
+ from_clause, where_clause, where_clause_params = query.get_sql()
+ sql = """
+ SELECT account_move_line.id, SUM(account_move_line.balance) OVER (
+ ORDER BY %(order_by)s
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
+ )
+ FROM %(from)s
+ WHERE %(where)s
+ """ % {'from': from_clause, 'where': where_clause or 'TRUE', 'order_by': order_string}
+ self.env.cr.execute(sql, where_clause_params)
+ result = {r[0]: r[1] for r in self.env.cr.fetchall()}
+ for record in self:
+ record.cumulated_balance = result[record.id]
+
+ @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 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:
+ line.amount_residual_currency = 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):
+ """ tax_line_id is computed as the tax linked to the repartition line creating
+ the move.
+ """
+ for record in self:
+ rep_line = record.tax_repartition_line_id
+ # A constraint on account.tax.repartition.line ensures both those fields are mutually exclusive
+ record.tax_line_id = rep_line.invoice_tax_id or rep_line.refund_tax_id
+
+ @api.depends('tax_tag_ids', 'debit', 'credit', 'journal_id')
+ def _compute_tax_audit(self):
+ separator = ' '
+
+ for record in self:
+ currency = record.company_id.currency_id
+ audit_str = ''
+ for tag in record.tax_tag_ids:
+
+ if record.move_id.tax_cash_basis_rec_id:
+ # Cash basis entries are always treated as misc operations, applying the tag sign directly to the balance
+ type_multiplicator = 1
+ else:
+ type_multiplicator = (record.journal_id.type == 'sale' and -1 or 1) * (self._get_refund_tax_audit_condition(record) and -1 or 1)
+
+ tag_amount = type_multiplicator * (tag.tax_negate and -1 or 1) * record.balance
+
+ if tag.tax_report_line_ids:
+ #Then, the tag comes from a report line, and hence has a + or - sign (also in its name)
+ for report_line in tag.tax_report_line_ids:
+ audit_str += separator if audit_str else ''
+ audit_str += report_line.tag_name + ': ' + formatLang(self.env, tag_amount, currency_obj=currency)
+ else:
+ # Then, it's a financial tag (sign is always +, and never shown in tag name)
+ audit_str += separator if audit_str else ''
+ audit_str += tag.name + ': ' + formatLang(self.env, tag_amount, currency_obj=currency)
+
+ record.tax_audit = audit_str
+
+ def _get_refund_tax_audit_condition(self, aml):
+ """ Returns the condition to be used for the provided move line to tell
+ whether or not it comes from a refund operation.
+ This is overridden by pos in order to treat returns properly.
+ """
+ return aml.move_id.move_type in ('in_refund', 'out_refund')
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('account_id', 'journal_id')
+ def _check_constrains_account_id_journal_id(self):
+ for line in self.filtered(lambda x: x.display_type not in ('line_section', 'line_note')):
+ account = line.account_id
+ journal = line.move_id.journal_id
+
+ if account.deprecated:
+ raise UserError(_('The account %s (%s) is deprecated.') % (account.name, account.code))
+
+ account_currency = account.currency_id
+ if account_currency and account_currency != line.company_currency_id and account_currency != line.currency_id:
+ raise UserError(_('The account selected on your journal entry forces to provide a secondary currency. You should remove the secondary currency on the account.'))
+
+ if account.allowed_journal_ids and journal not in account.allowed_journal_ids:
+ raise UserError(_('You cannot use this account (%s) in this journal, check the field \'Allowed Journals\' on the related account.', account.display_name))
+
+ failed_check = False
+ if (journal.type_control_ids - journal.default_account_id.user_type_id) or journal.account_control_ids:
+ failed_check = True
+ if journal.type_control_ids:
+ failed_check = account.user_type_id not in (journal.type_control_ids - journal.default_account_id.user_type_id)
+ if failed_check and journal.account_control_ids:
+ failed_check = account not in journal.account_control_ids
+
+ if failed_check:
+ raise UserError(_('You cannot use this account (%s) in this journal, check the section \'Control-Access\' under tab \'Advanced Settings\' on the related journal.', account.display_name))
+
+ @api.constrains('account_id', 'tax_ids', 'tax_line_id', 'reconciled')
+ def _check_off_balance(self):
+ for line in self:
+ if line.account_id.internal_group == 'off_balance':
+ if any(a.internal_group != line.account_id.internal_group for a in line.move_id.line_ids.account_id):
+ raise UserError(_('If you want to use "Off-Balance Sheet" accounts, all the accounts of the journal entry must be of this type'))
+ if line.tax_ids or line.tax_line_id:
+ raise UserError(_('You cannot use taxes on lines with an Off-Balance account'))
+ if line.reconciled:
+ raise UserError(_('Lines from "Off-Balance Sheet" accounts cannot be reconciled'))
+
+ def _affect_tax_report(self):
+ self.ensure_one()
+ return self.tax_ids or self.tax_line_id or self.tax_tag_ids.filtered(lambda x: x.applicability == "taxes")
+
+ def _check_tax_lock_date(self):
+ for line in self.filtered(lambda l: l.move_id.state == 'posted'):
+ move = line.move_id
+ if move.company_id.tax_lock_date and move.date <= move.company_id.tax_lock_date and line._affect_tax_report():
+ 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.")
+ % format_date(self.env, move.company_id.tax_lock_date))
+
+ def _check_reconciliation(self):
+ for line in self:
+ if line.matched_debit_ids or line.matched_credit_ids:
+ raise UserError(_("You cannot do this modification on a reconciled journal entry. "
+ "You can just change some non legal fields or you must unreconcile first.\n"
+ "Journal Entry (id): %s (%s)") % (line.move_id.name, line.move_id.id))
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ def init(self):
+ """ change index on partner_id to a multi-column index on (partner_id, ref), the new index will behave in the
+ same way when we search on partner_id, with the addition of being optimal when having a query that will
+ search on partner_id and ref at the same time (which is the case when we open the bank reconciliation widget)
+ """
+ cr = self._cr
+ cr.execute('DROP INDEX IF EXISTS account_move_line_partner_id_index')
+ cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('account_move_line_partner_id_ref_idx',))
+ if not cr.fetchone():
+ cr.execute('CREATE INDEX account_move_line_partner_id_ref_idx ON account_move_line (partner_id, ref)')
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # OVERRIDE
+ ACCOUNTING_FIELDS = ('debit', 'credit', 'amount_currency')
+ BUSINESS_FIELDS = ('price_unit', 'quantity', 'discount', 'tax_ids')
+
+ for vals in vals_list:
+ move = self.env['account.move'].browse(vals['move_id'])
+ vals.setdefault('company_currency_id', move.company_id.currency_id.id) # important to bypass the ORM limitation where monetary fields are not rounded; more info in the commit message
+
+ # Ensure balance == amount_currency in case of missing currency or same currency as the one from the
+ # company.
+ currency_id = vals.get('currency_id') or move.company_id.currency_id.id
+ if currency_id == move.company_id.currency_id.id:
+ balance = vals.get('debit', 0.0) - vals.get('credit', 0.0)
+ vals.update({
+ 'currency_id': currency_id,
+ 'amount_currency': balance,
+ })
+ else:
+ vals['amount_currency'] = vals.get('amount_currency', 0.0)
+
+ if move.is_invoice(include_receipts=True):
+ currency = move.currency_id
+ partner = self.env['res.partner'].browse(vals.get('partner_id'))
+ taxes = self.new({'tax_ids': vals.get('tax_ids', [])}).tax_ids
+ tax_ids = set(taxes.ids)
+ taxes = self.env['account.tax'].browse(tax_ids)
+
+ # Ensure consistency between accounting & business fields.
+ # As we can't express such synchronization as computed fields without cycling, we need to do it both
+ # in onchange and in create/write. So, if something changed in accounting [resp. business] fields,
+ # business [resp. accounting] fields are recomputed.
+ if any(vals.get(field) for field in ACCOUNTING_FIELDS):
+ price_subtotal = self._get_price_total_and_subtotal_model(
+ vals.get('price_unit', 0.0),
+ vals.get('quantity', 0.0),
+ vals.get('discount', 0.0),
+ currency,
+ self.env['product.product'].browse(vals.get('product_id')),
+ partner,
+ taxes,
+ move.move_type,
+ ).get('price_subtotal', 0.0)
+ vals.update(self._get_fields_onchange_balance_model(
+ vals.get('quantity', 0.0),
+ vals.get('discount', 0.0),
+ vals['amount_currency'],
+ move.move_type,
+ currency,
+ taxes,
+ price_subtotal
+ ))
+ vals.update(self._get_price_total_and_subtotal_model(
+ vals.get('price_unit', 0.0),
+ vals.get('quantity', 0.0),
+ vals.get('discount', 0.0),
+ currency,
+ self.env['product.product'].browse(vals.get('product_id')),
+ partner,
+ taxes,
+ move.move_type,
+ ))
+ elif any(vals.get(field) for field in BUSINESS_FIELDS):
+ vals.update(self._get_price_total_and_subtotal_model(
+ vals.get('price_unit', 0.0),
+ vals.get('quantity', 0.0),
+ vals.get('discount', 0.0),
+ currency,
+ self.env['product.product'].browse(vals.get('product_id')),
+ partner,
+ taxes,
+ move.move_type,
+ ))
+ vals.update(self._get_fields_onchange_subtotal_model(
+ vals['price_subtotal'],
+ move.move_type,
+ currency,
+ move.company_id,
+ move.date,
+ ))
+
+ lines = super(AccountMoveLine, self).create(vals_list)
+
+ moves = lines.mapped('move_id')
+ if self._context.get('check_move_validity', True):
+ moves._check_balanced()
+ moves._check_fiscalyear_lock_date()
+ lines._check_tax_lock_date()
+ moves._synchronize_business_models({'line_ids'})
+
+ return lines
+
+ def write(self, vals):
+ # OVERRIDE
+ ACCOUNTING_FIELDS = ('debit', 'credit', 'amount_currency')
+ BUSINESS_FIELDS = ('price_unit', 'quantity', 'discount', 'tax_ids')
+ PROTECTED_FIELDS_TAX_LOCK_DATE = ['debit', 'credit', 'tax_line_id', 'tax_ids', 'tax_tag_ids']
+ PROTECTED_FIELDS_LOCK_DATE = PROTECTED_FIELDS_TAX_LOCK_DATE + ['account_id', 'journal_id', 'amount_currency', 'currency_id', 'partner_id']
+ PROTECTED_FIELDS_RECONCILIATION = ('account_id', 'date', 'debit', 'credit', 'amount_currency', 'currency_id')
+
+ account_to_write = self.env['account.account'].browse(vals['account_id']) if 'account_id' in vals else None
+
+ # Check writing a deprecated account.
+ if account_to_write and account_to_write.deprecated:
+ raise UserError(_('You cannot use a deprecated account.'))
+
+ for line in self:
+ if line.parent_state == 'posted':
+ if line.move_id.restrict_mode_hash_table and set(vals).intersection(INTEGRITY_HASH_LINE_FIELDS):
+ raise UserError(_("You cannot edit the following fields due to restrict mode being activated on the journal: %s.") % ', '.join(INTEGRITY_HASH_LINE_FIELDS))
+ if any(key in vals for key in ('tax_ids', 'tax_line_ids')):
+ raise UserError(_('You cannot modify the taxes related to a posted journal item, you should reset the journal entry to draft to do so.'))
+
+ # Check the lock date.
+ if any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in PROTECTED_FIELDS_LOCK_DATE):
+ line.move_id._check_fiscalyear_lock_date()
+
+ # Check the tax lock date.
+ if any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in PROTECTED_FIELDS_TAX_LOCK_DATE):
+ line._check_tax_lock_date()
+
+ # Check the reconciliation.
+ if any(self.env['account.move']._field_will_change(line, vals, field_name) for field_name in PROTECTED_FIELDS_RECONCILIATION):
+ line._check_reconciliation()
+
+ # Check switching receivable / payable accounts.
+ if account_to_write:
+ account_type = line.account_id.user_type_id.type
+ if line.move_id.is_sale_document(include_receipts=True):
+ if (account_type == 'receivable' and account_to_write.user_type_id.type != account_type) \
+ or (account_type != 'receivable' and account_to_write.user_type_id.type == 'receivable'):
+ raise UserError(_("You can only set an account having the receivable type on payment terms lines for customer invoice."))
+ if line.move_id.is_purchase_document(include_receipts=True):
+ if (account_type == 'payable' and account_to_write.user_type_id.type != account_type) \
+ or (account_type != 'payable' and account_to_write.user_type_id.type == 'payable'):
+ raise UserError(_("You can only set an account having the payable type on payment terms lines for vendor bill."))
+
+ # Tracking stuff can be skipped for perfs using tracking_disable context key
+ if not self.env.context.get('tracking_disable', False):
+ # Get all tracked fields (without related fields because these fields must be manage on their own model)
+ tracking_fields = []
+ for value in vals:
+ field = self._fields[value]
+ if hasattr(field, 'related') and field.related:
+ continue # We don't want to track related field.
+ if hasattr(field, 'tracking') and field.tracking:
+ tracking_fields.append(value)
+ ref_fields = self.env['account.move.line'].fields_get(tracking_fields)
+
+ # Get initial values for each line
+ move_initial_values = {}
+ for line in self.filtered(lambda l: l.move_id.posted_before): # Only lines with posted once move.
+ for field in tracking_fields:
+ # Group initial values by move_id
+ if line.move_id.id not in move_initial_values:
+ move_initial_values[line.move_id.id] = {}
+ move_initial_values[line.move_id.id].update({field: line[field]})
+
+ result = True
+ for line in self:
+ cleaned_vals = line.move_id._cleanup_write_orm_values(line, vals)
+ if not cleaned_vals:
+ continue
+
+ # Auto-fill amount_currency if working in single-currency.
+ if 'currency_id' not in cleaned_vals \
+ and line.currency_id == line.company_currency_id \
+ and any(field_name in cleaned_vals for field_name in ('debit', 'credit')):
+ cleaned_vals.update({
+ 'amount_currency': vals.get('debit', 0.0) - vals.get('credit', 0.0),
+ })
+
+ result |= super(AccountMoveLine, line).write(cleaned_vals)
+
+ if not line.move_id.is_invoice(include_receipts=True):
+ continue
+
+ # Ensure consistency between accounting & business fields.
+ # As we can't express such synchronization as computed fields without cycling, we need to do it both
+ # in onchange and in create/write. So, if something changed in accounting [resp. business] fields,
+ # business [resp. accounting] fields are recomputed.
+ if any(field in cleaned_vals for field in ACCOUNTING_FIELDS):
+ price_subtotal = line._get_price_total_and_subtotal().get('price_subtotal', 0.0)
+ to_write = line._get_fields_onchange_balance(price_subtotal=price_subtotal)
+ to_write.update(line._get_price_total_and_subtotal(
+ price_unit=to_write.get('price_unit', line.price_unit),
+ quantity=to_write.get('quantity', line.quantity),
+ discount=to_write.get('discount', line.discount),
+ ))
+ result |= super(AccountMoveLine, line).write(to_write)
+ elif any(field in cleaned_vals for field in BUSINESS_FIELDS):
+ to_write = line._get_price_total_and_subtotal()
+ to_write.update(line._get_fields_onchange_subtotal(
+ price_subtotal=to_write['price_subtotal'],
+ ))
+ result |= super(AccountMoveLine, line).write(to_write)
+
+ # Check total_debit == total_credit in the related moves.
+ if self._context.get('check_move_validity', True):
+ self.mapped('move_id')._check_balanced()
+
+ self.mapped('move_id')._synchronize_business_models({'line_ids'})
+
+ if not self.env.context.get('tracking_disable', False):
+ # Create the dict for the message post
+ tracking_values = {} # Tracking values to write in the message post
+ for move_id, modified_lines in move_initial_values.items():
+ tmp_move = {move_id: []}
+ for line in self.filtered(lambda l: l.move_id.id == move_id):
+ changes, tracking_value_ids = line._mail_track(ref_fields, modified_lines) # Return a tuple like (changed field, ORM command)
+ tmp = {'line_id': line.id}
+ if tracking_value_ids:
+ selected_field = tracking_value_ids[0][2] # Get the last element of the tuple in the list of ORM command. (changed, [(0, 0, THIS)])
+ tmp.update({
+ **{'field_name': selected_field.get('field_desc')},
+ **self._get_formated_values(selected_field)
+ })
+ elif changes:
+ field_name = line._fields[changes.pop()].string # Get the field name
+ tmp.update({
+ 'error': True,
+ 'field_error': field_name
+ })
+ else:
+ continue
+ tmp_move[move_id].append(tmp)
+ if len(tmp_move[move_id]) > 0:
+ tracking_values.update(tmp_move)
+
+ # Write in the chatter.
+ for move in self.mapped('move_id'):
+ fields = tracking_values.get(move.id, [])
+ if len(fields) > 0:
+ msg = self._get_tracking_field_string(tracking_values.get(move.id))
+ move.message_post(body=msg) # Write for each concerned move the message in the chatter
+
+ return result
+
+ def _valid_field_parameter(self, field, name):
+ # I can't even
+ return name == 'tracking' or super()._valid_field_parameter(field, name)
+
+ def unlink(self):
+ moves = self.mapped('move_id')
+
+ # Prevent deleting lines on posted entries
+ if not self.env.context.get('force_delete', False) and any(m.state == 'posted' for m in moves):
+ raise UserError(_('You cannot delete an item linked to a posted entry.'))
+
+ # Check the lines are not reconciled (partially or not).
+ self._check_reconciliation()
+
+ # Check the lock date.
+ moves._check_fiscalyear_lock_date()
+
+ # Check the tax lock date.
+ self._check_tax_lock_date()
+
+ res = super(AccountMoveLine, self).unlink()
+
+ # Check total_debit == total_credit in the related moves.
+ if self._context.get('check_move_validity', True):
+ moves._check_balanced()
+
+ return res
+
+ @api.model
+ def default_get(self, default_fields):
+ # OVERRIDE
+ values = super(AccountMoveLine, self).default_get(default_fields)
+
+ if 'account_id' in default_fields and not values.get('account_id') \
+ and (self._context.get('journal_id') or self._context.get('default_journal_id')) \
+ and self._context.get('default_move_type') in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'):
+ # Fill missing 'account_id'.
+ journal = self.env['account.journal'].browse(self._context.get('default_journal_id') or self._context['journal_id'])
+ values['account_id'] = journal.default_account_id.id
+ elif self._context.get('line_ids') and any(field_name in default_fields for field_name in ('debit', 'credit', 'account_id', 'partner_id')):
+ move = self.env['account.move'].new({'line_ids': self._context['line_ids']})
+
+ # 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:
+ values.update({'credit': balance})
+
+ # Suggest default value for 'partner_id'.
+ if 'partner_id' in default_fields and not values.get('partner_id'):
+ if len(move.line_ids[-2:]) == 2 and move.line_ids[-1].partner_id == move.line_ids[-2].partner_id != False:
+ values['partner_id'] = move.line_ids[-2:].mapped('partner_id').id
+
+ # Suggest default value for 'account_id'.
+ if 'account_id' in default_fields and not values.get('account_id'):
+ if len(move.line_ids[-2:]) == 2 and move.line_ids[-1].account_id == move.line_ids[-2].account_id != False:
+ values['account_id'] = move.line_ids[-2:].mapped('account_id').id
+ if values.get('display_type') or self.display_type:
+ values.pop('account_id', None)
+ return values
+
+ @api.depends('ref', 'move_id')
+ def name_get(self):
+ result = []
+ for line in self:
+ name = line.move_id.name or ''
+ if line.ref:
+ name += " (%s)" % line.ref
+ name += (line.name or line.product_id.display_name) and (' ' + (line.name or line.product_id.display_name)) or ''
+ result.append((line.id, name))
+ return result
+
+ # -------------------------------------------------------------------------
+ # TRACKING METHODS
+ # -------------------------------------------------------------------------
+
+ def _get_formated_values(self, tracked_field):
+ if tracked_field.get('field_type') in ('date', 'datetime'):
+ return {
+ 'old_value': format_date(self.env, fields.Datetime.from_string(tracked_field.get('old_value_datetime'))),
+ 'new_value': format_date(self.env, fields.Datetime.from_string(tracked_field.get('new_value_datetime'))),
+ }
+ elif tracked_field.get('field_type') in ('one2many', 'many2many', 'many2one'):
+ return {
+ 'old_value': tracked_field.get('old_value_char', ''),
+ 'new_value': tracked_field.get('new_value_char', '')
+ }
+ else:
+ return {
+ 'old_value': [val for key, val in tracked_field.items() if 'old_value' in key][0], # Get the first element because we create a list like ['Elem']
+ 'new_value': [val for key, val in tracked_field.items() if 'new_value' in key][0], # Get the first element because we create a list like ['Elem']
+ }
+
+ def _get_tracking_field_string(self, fields):
+ ARROW_RIGHT = '<span aria-label="Changed" class="fa fa-long-arrow-alt-right" role="img" title="Changed"></span>'
+ msg = '<ul>'
+ for field in fields:
+ redirect_link = '<a href=# data-oe-model=account.move.line data-oe-id=%d>#%d</a>' % (field['line_id'], field['line_id']) # Account move line link
+ if field.get('error', False):
+ msg += '<li>%s: %s</li>' % (
+ field['field_error'],
+ _('A modification has been operated on the line %s.', redirect_link)
+ )
+ else:
+ msg += '<li>%s: %s %s %s (%s)</li>' % (field['field_name'], field['old_value'], ARROW_RIGHT, field['new_value'], redirect_link)
+ msg += '</ul>'
+ return msg
+
+ # -------------------------------------------------------------------------
+ # RECONCILIATION
+ # -------------------------------------------------------------------------
+
+ 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(lambda line: line.balance > 0.0 or line.amount_currency > 0.0))
+ credit_lines = iter(self.filtered(lambda line: line.balance < 0.0 or line.amount_currency < 0.0))
+ 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)
+ has_debit_residual_left = not debit_line.company_currency_id.is_zero(debit_amount_residual) and debit_amount_residual > 0.0
+ has_credit_residual_left = not credit_line.company_currency_id.is_zero(credit_amount_residual) and credit_amount_residual < 0.0
+ has_debit_residual_curr_left = not debit_line_currency.is_zero(debit_amount_residual_currency) and debit_amount_residual_currency > 0.0
+ has_credit_residual_curr_left = not credit_line_currency.is_zero(credit_amount_residual_currency) and credit_amount_residual_currency < 0.0
+
+ if debit_line_currency == credit_line_currency:
+ # Reconcile on the same currency.
+
+ # The debit line is now fully reconciled because:
+ # - either amount_residual & amount_residual_currency are at 0.
+ # - either the credit_line is not an exchange difference one.
+ if not has_debit_residual_curr_left and (has_credit_residual_curr_left or not has_debit_residual_left):
+ debit_line = None
+ continue
+
+ # The credit line is now fully reconciled because:
+ # - either amount_residual & amount_residual_currency are at 0.
+ # - either the debit is not an exchange difference one.
+ if not has_credit_residual_curr_left and (has_debit_residual_curr_left or not has_credit_residual_left):
+ 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 since amount_residual is 0.
+ if not has_debit_residual_left:
+ debit_line = None
+ continue
+
+ # The credit line is now fully reconciled since amount_residual is 0.
+ if not has_credit_residual_left:
+ 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,
+ )
+ 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.company_id.expense_currency_exchange_account_id
+ else:
+ exchange_line_account = journal.company_id.income_currency_exchange_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.company_id.expense_currency_exchange_account_id
+ else:
+ exchange_line_account = journal.company_id.income_currency_exchange_account_id
+ else:
+ continue
+
+ 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:
+ 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
+
+ # ==========================================================================
+ # Add the balance of all tax lines of the current move in order in order
+ # to compute the residual amount for each of them.
+ # ==========================================================================
+
+ for line in move_values['to_process_lines']:
+
+ vals = {
+ 'currency_id': line.currency_id.id,
+ 'partner_id': line.partner_id.id,
+ 'tax_ids': [(6, 0, line.tax_ids.ids)],
+ 'tax_tag_ids': [(6, 0, line._convert_tags_for_cash_basis(line.tax_tag_ids).ids)],
+ 'debit': line.debit,
+ 'credit': line.credit,
+ }
+
+ if line.tax_repartition_line_id:
+ # Tax line.
+ grouping_key = self.env['account.partial.reconcile']._get_cash_basis_tax_line_grouping_key_from_record(line)
+ if grouping_key in account_vals_to_fix:
+ debit = account_vals_to_fix[grouping_key]['debit'] + vals['debit']
+ credit = account_vals_to_fix[grouping_key]['credit'] + vals['credit']
+ balance = debit - credit
+
+ account_vals_to_fix[grouping_key].update({
+ 'debit': balance if balance > 0 else 0,
+ 'credit': -balance if balance < 0 else 0,
+ 'tax_base_amount': account_vals_to_fix[grouping_key]['tax_base_amount'] + line.tax_base_amount,
+ })
+ else:
+ account_vals_to_fix[grouping_key] = {
+ **vals,
+ 'account_id': line.account_id.id,
+ 'tax_base_amount': line.tax_base_amount,
+ 'tax_repartition_line_id': line.tax_repartition_line_id.id,
+ }
+ elif line.tax_ids:
+ # Base line.
+ account_to_fix = line.company_id.account_cash_basis_base_account_id
+ if not account_to_fix:
+ continue
+
+ grouping_key = self.env['account.partial.reconcile']._get_cash_basis_base_line_grouping_key_from_record(line, account=account_to_fix)
+
+ if grouping_key not in account_vals_to_fix:
+ account_vals_to_fix[grouping_key] = {
+ **vals,
+ 'account_id': account_to_fix.id,
+ }
+ else:
+ # Multiple base lines could share the same key, if the same
+ # cash basis tax is used alone on several lines of the invoices
+ account_vals_to_fix[grouping_key]['debit'] += vals['debit']
+ account_vals_to_fix[grouping_key]['credit'] += vals['credit']
+
+ # ==========================================================================
+ # 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:
+ grouping_key = None
+ if line.tax_repartition_line_id:
+ # Tax line.
+ 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,
+ )
+ elif line.tax_ids:
+ # Base line.
+ grouping_key = self.env['account.partial.reconcile']._get_cash_basis_base_line_grouping_key_from_record(
+ line,
+ account=line.company_id.account_cash_basis_base_account_id,
+ )
+
+ if grouping_key not in account_vals_to_fix:
+ continue
+
+ account_vals_to_fix[grouping_key]['debit'] -= line.debit
+ 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.
+ # - fix rounding issues on the tax account/base tax account.
+ # ==========================================================================
+
+ for values in account_vals_to_fix.values():
+ balance = values['debit'] - values['credit']
+
+ if move.company_currency_id.is_zero(balance):
+ continue
+
+ if values.get('tax_repartition_line_id'):
+ # Tax line.
+ tax_repartition_line = self.env['account.tax.repartition.line'].browse(values['tax_repartition_line_id'])
+ account = tax_repartition_line.account_id or self.env['account.account'].browse(values['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': balance if balance > 0.0 else 0.0,
+ 'credit': -balance if balance < 0.0 else 0.0,
+ 'account_id': account.id,
+ 'sequence': sequence,
+ }),
+ (0, 0, {
+ **values,
+ 'name': _('Currency exchange rate difference (cash basis)'),
+ 'debit': -balance if balance < 0.0 else 0.0,
+ 'credit': balance if balance > 0.0 else 0.0,
+ 'account_id': values['account_id'],
+ 'tax_ids': [],
+ 'tax_tag_ids': [],
+ 'tax_repartition_line_id': False,
+ 'sequence': sequence + 1,
+ }),
+ ]
+ else:
+ # Base line.
+ 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': balance if balance > 0.0 else 0.0,
+ 'credit': -balance if balance < 0.0 else 0.0,
+ 'sequence': sequence,
+ }),
+ (0, 0, {
+ **values,
+ 'name': _('Currency exchange rate difference (cash basis)'),
+ 'debit': -balance if balance < 0.0 else 0.0,
+ 'credit': balance if balance > 0.0 else 0.0,
+ 'tax_ids': [],
+ 'tax_tag_ids': [],
+ 'sequence': sequence + 1,
+ }),
+ ]
+
+ if not self:
+ return self.env['account.move']
+
+ company = self[0].company_id
+ journal = company.currency_exchange_journal_id
+
+ 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']:
+ # Check the configuration of the exchange difference journal.
+ if not journal:
+ raise UserError(_("You should configure the 'Exchange Gain or Loss Journal' in your company settings, to manage automatically the booking of accounting entries related to differences between exchange rates."))
+ if not journal.company_id.expense_currency_exchange_account_id:
+ raise UserError(_("You should configure the 'Loss Exchange Rate Account' in your company settings, to manage automatically the booking of accounting entries related to differences between exchange rates."))
+ if not journal.company_id.income_currency_exchange_account_id.id:
+ raise UserError(_("You should configure the 'Gain Exchange Rate Account' in your company settings, to manage automatically the booking of accounting entries related to differences between exchange rates."))
+
+ 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:
+ exchange_field = 'amount_residual'
+
+ if exchange_diff_line[exchange_field] > 0.0:
+ debit_line = exchange_diff_line
+ credit_line = source_line
+ else:
+ 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,
+ })
+
+ 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:
+ if line.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 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 and not self._context.get('move_reverse_cancel'):
+ 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)
+
+ if is_full_needed:
+
+ # ==== 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(soft=False)
+
+ # ==== 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 move: move.payment_state in ('paid', 'in_payment'))\
+ .action_invoice_paid()
+
+ return results
+
+ def remove_move_reconcile(self):
+ """ Undo a reconciliation """
+ (self.matched_debit_ids + self.matched_credit_ids).unlink()
+
+ def _copy_data_extend_business_fields(self, values):
+ ''' Hook allowing copying business fields under certain conditions.
+ E.g. The link to the sale order lines must be preserved in case of a refund.
+ '''
+ self.ensure_one()
+
+ def copy_data(self, default=None):
+ res = super(AccountMoveLine, self).copy_data(default=default)
+
+ 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'] = ''
+ # Don't copy restricted fields of notes
+ if line.display_type in ('line_section', 'line_note'):
+ values['amount_currency'] = 0
+ values['debit'] = 0
+ values['credit'] = 0
+ values['account_id'] = False
+ if self._context.get('include_business_fields'):
+ line._copy_data_extend_business_fields(values)
+ return res
+
+ # -------------------------------------------------------------------------
+ # MISC
+ # -------------------------------------------------------------------------
+
+ def _get_analytic_tag_ids(self):
+ self.ensure_one()
+ return self.analytic_tag_ids.filtered(lambda r: not r.active_analytic_distribution).ids
+
+ def create_analytic_lines(self):
+ """ Create analytic items upon validation of an account.move.line having an analytic account or an analytic distribution.
+ """
+ lines_to_create_analytic_entries = self.env['account.move.line']
+ analytic_line_vals = []
+ for obj_line in self:
+ for tag in obj_line.analytic_tag_ids.filtered('active_analytic_distribution'):
+ for distribution in tag.analytic_distribution_ids:
+ analytic_line_vals.append(obj_line._prepare_analytic_distribution_line(distribution))
+ if obj_line.analytic_account_id:
+ lines_to_create_analytic_entries |= obj_line
+
+ # create analytic entries in batch
+ if lines_to_create_analytic_entries:
+ analytic_line_vals += lines_to_create_analytic_entries._prepare_analytic_line()
+
+ self.env['account.analytic.line'].create(analytic_line_vals)
+
+ def _prepare_analytic_line(self):
+ """ Prepare the values used to create() an account.analytic.line upon validation of an account.move.line having
+ an analytic account. This method is intended to be extended in other modules.
+ :return list of values to create analytic.line
+ :rtype list
+ """
+ result = []
+ for move_line in self:
+ amount = (move_line.credit or 0.0) - (move_line.debit or 0.0)
+ default_name = move_line.name or (move_line.ref or '/' + ' -- ' + (move_line.partner_id and move_line.partner_id.name or '/'))
+ result.append({
+ 'name': default_name,
+ 'date': move_line.date,
+ 'account_id': move_line.analytic_account_id.id,
+ 'group_id': move_line.analytic_account_id.group_id.id,
+ 'tag_ids': [(6, 0, move_line._get_analytic_tag_ids())],
+ 'unit_amount': move_line.quantity,
+ 'product_id': move_line.product_id and move_line.product_id.id or False,
+ 'product_uom_id': move_line.product_uom_id and move_line.product_uom_id.id or False,
+ 'amount': amount,
+ 'general_account_id': move_line.account_id.id,
+ 'ref': move_line.ref,
+ 'move_id': move_line.id,
+ 'user_id': move_line.move_id.invoice_user_id.id or self._uid,
+ 'partner_id': move_line.partner_id.id,
+ 'company_id': move_line.analytic_account_id.company_id.id or self.env.company.id,
+ })
+ return result
+
+ def _prepare_analytic_distribution_line(self, distribution):
+ """ Prepare the values used to create() an account.analytic.line upon validation of an account.move.line having
+ analytic tags with analytic distribution.
+ """
+ self.ensure_one()
+ amount = -self.balance * distribution.percentage / 100.0
+ default_name = self.name or (self.ref or '/' + ' -- ' + (self.partner_id and self.partner_id.name or '/'))
+ return {
+ 'name': default_name,
+ 'date': self.date,
+ 'account_id': distribution.account_id.id,
+ 'group_id': distribution.account_id.group_id.id,
+ 'partner_id': self.partner_id.id,
+ 'tag_ids': [(6, 0, [distribution.tag_id.id] + self._get_analytic_tag_ids())],
+ 'unit_amount': self.quantity,
+ 'product_id': self.product_id and self.product_id.id or False,
+ 'product_uom_id': self.product_uom_id and self.product_uom_id.id or False,
+ 'amount': amount,
+ 'general_account_id': self.account_id.id,
+ 'ref': self.ref,
+ 'move_id': self.id,
+ 'user_id': self.move_id.invoice_user_id.id or self._uid,
+ 'company_id': distribution.account_id.company_id.id or self.env.company.id,
+ }
+
+ @api.model
+ def _query_get(self, domain=None):
+ self.check_access_rights('read')
+
+ context = dict(self._context or {})
+ domain = domain or []
+ if not isinstance(domain, (list, tuple)):
+ domain = ast.literal_eval(domain)
+
+ date_field = 'date'
+ if context.get('aged_balance'):
+ date_field = 'date_maturity'
+ if context.get('date_to'):
+ domain += [(date_field, '<=', context['date_to'])]
+ if context.get('date_from'):
+ if not context.get('strict_range'):
+ domain += ['|', (date_field, '>=', context['date_from']), ('account_id.user_type_id.include_initial_balance', '=', True)]
+ elif context.get('initial_bal'):
+ domain += [(date_field, '<', context['date_from'])]
+ else:
+ domain += [(date_field, '>=', context['date_from'])]
+
+ if context.get('journal_ids'):
+ domain += [('journal_id', 'in', context['journal_ids'])]
+
+ state = context.get('state')
+ if state and state.lower() != 'all':
+ domain += [('move_id.state', '=', state)]
+
+ if context.get('company_id'):
+ domain += [('company_id', '=', context['company_id'])]
+ elif context.get('allowed_company_ids'):
+ domain += [('company_id', 'in', self.env.companies.ids)]
+ else:
+ domain += [('company_id', '=', self.env.company.id)]
+
+ if context.get('reconcile_date'):
+ domain += ['|', ('reconciled', '=', False), '|', ('matched_debit_ids.max_date', '>', context['reconcile_date']), ('matched_credit_ids.max_date', '>', context['reconcile_date'])]
+
+ if context.get('account_tag_ids'):
+ domain += [('account_id.tag_ids', 'in', context['account_tag_ids'].ids)]
+
+ if context.get('account_ids'):
+ domain += [('account_id', 'in', context['account_ids'].ids)]
+
+ if context.get('analytic_tag_ids'):
+ domain += [('analytic_tag_ids', 'in', context['analytic_tag_ids'].ids)]
+
+ if context.get('analytic_account_ids'):
+ domain += [('analytic_account_id', 'in', context['analytic_account_ids'].ids)]
+
+ if context.get('partner_ids'):
+ domain += [('partner_id', 'in', context['partner_ids'].ids)]
+
+ if context.get('partner_categories'):
+ domain += [('partner_id.category_id', 'in', context['partner_categories'].ids)]
+
+ where_clause = ""
+ where_clause_params = []
+ tables = ''
+ if domain:
+ domain.append(('display_type', 'not in', ('line_section', 'line_note')))
+ domain.append(('move_id.state', '!=', 'cancel'))
+
+ query = self._where_calc(domain)
+
+ # Wrap the query with 'company_id IN (...)' to avoid bypassing company access rights.
+ self._apply_ir_rules(query)
+
+ tables, where_clause, where_clause_params = query.get_sql()
+ return tables, where_clause, where_clause_params
+
+ def _reconciled_lines(self):
+ ids = []
+ for aml in self.filtered('account_id.reconcile'):
+ ids.extend([r.debit_move_id.id for r in aml.matched_debit_ids] if aml.credit > 0 else [r.credit_move_id.id for r in aml.matched_credit_ids])
+ ids.append(aml.id)
+ return ids
+
+ def open_reconcile_view(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('account.action_account_moves_all_a')
+ ids = self._reconciled_lines()
+ action['domain'] = [('id', 'in', ids)]
+ return action
+
+ def action_automatic_entry(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('account.account_automatic_entry_wizard_action')
+ # Force the values of the move line in the context to avoid issues
+ ctx = dict(self.env.context)
+ ctx.pop('active_id', None)
+ ctx['active_ids'] = self.ids
+ ctx['active_model'] = 'account.move.line'
+ action['context'] = ctx
+ return action
+
+ @api.model
+ def _get_suspense_moves_domain(self):
+ return [
+ ('move_id.to_check', '=', True),
+ ('full_reconcile_id', '=', False),
+ ('statement_line_id', '!=', False),
+ ]
+
+ def _get_attachment_domains(self):
+ self.ensure_one()
+ domains = [[('res_model', '=', 'account.move'), ('res_id', '=', self.move_id.id)]]
+ if self.statement_id:
+ domains.append([('res_model', '=', 'account.bank.statement'), ('res_id', '=', self.statement_id.id)])
+ if self.payment_id:
+ domains.append([('res_model', '=', 'account.payment'), ('res_id', '=', self.payment_id.id)])
+ return domains
+
+ def _convert_tags_for_cash_basis(self, tags):
+ """ Cash basis entries are managed by the tax report just like misc operations.
+ So it means that the tax report will not apply any additional multiplicator
+ to the balance of the cash basis lines.
+
+ For invoices move lines whose multiplicator would have been -1 (if their
+ taxes had not CABA), it will hence cause sign inversion if we directly copy
+ the tags from those lines. Instead, we need to invert all the signs from these
+ tags (if they come from tax report lines; tags created in data for financial
+ reports will stay onchanged).
+ """
+ self.ensure_one()
+ tax_multiplicator = (self.journal_id.type == 'sale' and -1 or 1) * (self.move_id.move_type in ('in_refund', 'out_refund') and -1 or 1)
+ if tax_multiplicator == -1:
+ # Take the opposite tags instead
+ return self._revert_signed_tags(tags)
+
+ return tags
+
+ @api.model
+ def _revert_signed_tags(self, tags):
+ rslt = self.env['account.account.tag']
+ for tag in tags:
+ if tag.tax_report_line_ids:
+ # tag created by an account.tax.report.line
+ new_tag = tag.tax_report_line_ids[0].tag_ids.filtered(lambda x: x.tax_negate != tag.tax_negate)
+ rslt += new_tag
+ else:
+ # tag created in data for use by an account.financial.html.report.line
+ rslt += tag
+
+ return rslt
diff --git a/addons/account/models/account_partial_reconcile.py b/addons/account/models/account_partial_reconcile.py
new file mode 100644
index 00000000..a9e1d03b
--- /dev/null
+++ b/addons/account/models/account_partial_reconcile.py
@@ -0,0 +1,617 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError, ValidationError
+
+from datetime import date
+
+
+class AccountPartialReconcile(models.Model):
+ _name = "account.partial.reconcile"
+ _description = "Partial Reconcile"
+ _rec_name = "id"
+
+ # ==== Reconciliation fields ====
+ debit_move_id = fields.Many2one(
+ comodel_name='account.move.line',
+ index=True, required=True)
+ credit_move_id = fields.Many2one(
+ comodel_name='account.move.line',
+ index=True, required=True)
+ full_reconcile_id = fields.Many2one(
+ comodel_name='account.full.reconcile',
+ string="Full Reconcile", copy=False)
+
+ # ==== Currency fields ====
+ company_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ string="Company Currency",
+ related='company_id.currency_id',
+ help="Utility field to express amount currency")
+ debit_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ store=True,
+ compute='_compute_debit_currency_id',
+ string="Currency of the debit journal item.")
+ credit_currency_id = fields.Many2one(
+ comodel_name='res.currency',
+ store=True,
+ compute='_compute_credit_currency_id',
+ string="Currency of the credit journal item.")
+
+ # ==== Amount fields ====
+ amount = fields.Monetary(
+ currency_field='company_currency_id',
+ help="Always positive amount concerned by this matching expressed in the company currency.")
+ debit_amount_currency = fields.Monetary(
+ currency_field='debit_currency_id',
+ help="Always positive amount concerned by this matching expressed in the debit line foreign currency.")
+ credit_amount_currency = fields.Monetary(
+ currency_field='credit_currency_id',
+ help="Always positive amount concerned by this matching expressed in the credit line foreign currency.")
+
+ # ==== Other fields ====
+ company_id = fields.Many2one(
+ comodel_name='res.company',
+ string="Company", store=True, readonly=False,
+ related='debit_move_id.company_id')
+ max_date = fields.Date(
+ string="Max Date of Matched Lines", store=True,
+ compute='_compute_max_date',
+ help="Technical field used to determine at which date this reconciliation needs to be shown on the "
+ "aged receivable/payable reports.")
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('debit_currency_id', 'credit_currency_id')
+ def _check_required_computed_currencies(self):
+ bad_partials = self.filtered(lambda partial: not partial.debit_currency_id or not partial.credit_currency_id)
+ if bad_partials:
+ raise ValidationError(_("Missing foreign currencies on partials having ids: %s", bad_partials.ids))
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('debit_move_id.date', 'credit_move_id.date')
+ def _compute_max_date(self):
+ for partial in self:
+ partial.max_date = max(
+ partial.debit_move_id.date,
+ partial.credit_move_id.date
+ )
+
+ @api.depends('debit_move_id')
+ def _compute_debit_currency_id(self):
+ for partial in self:
+ partial.debit_currency_id = partial.debit_move_id.currency_id \
+ or partial.debit_move_id.company_currency_id
+
+ @api.depends('credit_move_id')
+ def _compute_credit_currency_id(self):
+ for partial in self:
+ partial.credit_currency_id = partial.credit_move_id.currency_id \
+ or partial.credit_move_id.company_currency_id
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ def unlink(self):
+ # OVERRIDE to unlink full reconcile linked to the current partials
+ # and reverse the tax cash basis journal entries.
+
+ # Avoid cyclic unlink calls when removing the partials that could remove some full reconcile
+ # and then, loop again and again.
+ if not self:
+ return True
+
+ # Retrieve the matching number to unlink.
+ full_to_unlink = self.full_reconcile_id
+
+ # Retrieve the CABA entries to reverse.
+ moves_to_reverse = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self.ids)])
+
+ # Unlink partials before doing anything else to avoid 'Record has already been deleted' due to the recursion.
+ res = super().unlink()
+
+ # Reverse CABA entries.
+ today = fields.Date.context_today(self)
+ default_values_list = [{
+ 'date': move.date if move.date > (move.company_id.period_lock_date or date.min) else today,
+ 'ref': _('Reversal of: %s') % move.name,
+ } for move in moves_to_reverse]
+ moves_to_reverse._reverse_moves(default_values_list, cancel=True)
+
+ # Remove the matching numbers.
+ 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
+ source_line = partial.debit_move_id
+ counterpart_line = partial.credit_move_id
+ 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
+ source_line = partial.credit_move_id
+ counterpart_line = partial.debit_move_id
+
+ 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 source_line.currency_id != counterpart_line.currency_id:
+ # When the invoice and the payment are not sharing the same foreign currency, the rate is computed
+ # on-the-fly using the payment date.
+ payment_rate = self.env['res.currency']._get_conversion_rate(
+ counterpart_line.company_currency_id,
+ source_line.currency_id,
+ counterpart_line.company_id,
+ counterpart_line.date,
+ )
+ elif 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._convert_tags_for_cash_basis(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._convert_tags_for_cash_basis(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._convert_tags_for_cash_basis(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._convert_tags_for_cash_basis(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.
+ '''
+ # DEPRECATED: TO BE REMOVED IN MASTER
+ residual_amount_per_group = {}
+ move = move_values['move']
+
+ # ==========================================================================
+ # Part 1:
+ # Add the balance of all journal items that are not tax exigible in order to
+ # ensure the exact balance will be report on the Tax Report.
+ # This part is needed when the move will be fully paid after the current
+ # reconciliation.
+ # ==========================================================================
+
+ for line in move_values['to_process_lines']:
+ if line.tax_repartition_line_id:
+ # Tax line.
+ grouping_key = self._get_cash_basis_tax_line_grouping_key_from_record(
+ line,
+ account=line.tax_repartition_line_id.account_id,
+ )
+ residual_amount_per_group.setdefault(grouping_key, 0.0)
+ residual_amount_per_group[grouping_key] += line['amount_currency']
+
+ elif line.tax_ids:
+ # Base line.
+ grouping_key = self._get_cash_basis_base_line_grouping_key_from_record(
+ line,
+ account=line.company_id.account_cash_basis_base_account_id,
+ )
+ residual_amount_per_group.setdefault(grouping_key, 0.0)
+ residual_amount_per_group[grouping_key] += line['amount_currency']
+
+ # ==========================================================================
+ # Part 2:
+ # Subtract all previously created cash basis journal items during previous
+ # reconciliation.
+ # ==========================================================================
+
+ previous_tax_cash_basis_moves = self.env['account.move'].search([
+ '|',
+ ('tax_cash_basis_rec_id', 'in', self.ids),
+ ('tax_cash_basis_move_id', '=', move.id),
+ ])
+ for line in previous_tax_cash_basis_moves.line_ids:
+ if line.tax_repartition_line_id:
+ # Tax line.
+ grouping_key = self._get_cash_basis_tax_line_grouping_key_from_record(line)
+ elif line.tax_ids:
+ # Base line.
+ grouping_key = self._get_cash_basis_base_line_grouping_key_from_record(line)
+ else:
+ continue
+
+ if grouping_key not in residual_amount_per_group:
+ # The grouping_key is unknown regarding the current lines.
+ # Maybe this move has been created before migration and then,
+ # we are not able to ensure the full coverage of the balance.
+ return
+
+ residual_amount_per_group[grouping_key] -= line['amount_currency']
+
+ # ==========================================================================
+ # Part 3:
+ # Subtract all pending cash basis journal items that will be created during
+ # this reconciliation.
+ # ==========================================================================
+
+ for grouping_key, balance in pending_cash_basis_lines:
+ residual_amount_per_group[grouping_key] -= balance
+
+ # ==========================================================================
+ # Part 4:
+ # Fix the current cash basis journal items in progress by replacing the
+ # balance by the residual one.
+ # ==========================================================================
+
+ for grouping_key, aggregated_vals in partial_lines_to_create.items():
+ line_vals = aggregated_vals['vals']
+
+ amount_currency = residual_amount_per_group[grouping_key]
+ balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
+ line_vals.update({
+ 'debit': balance if balance > 0.0 else 0.0,
+ 'credit': -balance if balance < 0.0 else 0.0,
+ 'amount_currency': amount_currency,
+ })
+
+ def _create_tax_cash_basis_moves(self):
+ ''' Create the tax cash basis journal entries.
+ :return: The newly created journal entries.
+ '''
+ tax_cash_basis_values_per_move = self._collect_tax_cash_basis_values()
+
+ moves_to_create = []
+ to_reconcile_after = []
+ for move_values in tax_cash_basis_values_per_move.values():
+ move = move_values['move']
+ pending_cash_basis_lines = []
+
+ for partial_values in move_values['partials']:
+ partial = partial_values['partial']
+
+ # Init the journal entry.
+ move_vals = {
+ 'move_type': 'entry',
+ 'date': partial.max_date,
+ 'ref': move.name,
+ 'journal_id': partial.company_id.tax_cash_basis_journal_id.id,
+ 'line_ids': [],
+ 'tax_cash_basis_rec_id': partial.id,
+ 'tax_cash_basis_move_id': move.id,
+ }
+
+ # Tracking of lines grouped all together.
+ # Used to reduce the number of generated lines and to avoid rounding issues.
+ partial_lines_to_create = {}
+
+ for line in move_values['to_process_lines']:
+
+ # ==========================================================================
+ # Compute the balance of the current line on the cash basis entry.
+ # This balance is a percentage representing the part of the journal entry
+ # that is actually paid by the current partial.
+ # ==========================================================================
+
+ # Percentage expressed in the foreign currency.
+ amount_currency = line.currency_id.round(line.amount_currency * partial_values['percentage'])
+ balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
+
+ # ==========================================================================
+ # Prepare the mirror cash basis journal item of the current line.
+ # Group them all together as much as possible to reduce the number of
+ # generated journal items.
+ # Also track the computed balance in order to avoid rounding issues when
+ # the journal entry will be fully paid. At that case, we expect the exact
+ # amount of each line has been covered by the cash basis journal entries
+ # and well reported in the Tax Report.
+ # ==========================================================================
+
+ if line.tax_repartition_line_id:
+ # Tax line.
+
+ cb_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_line_vals)
+ elif line.tax_ids:
+ # Base line.
+
+ cb_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_line_vals)
+
+ if grouping_key in partial_lines_to_create:
+ aggregated_vals = partial_lines_to_create[grouping_key]['vals']
+
+ debit = aggregated_vals['debit'] + cb_line_vals['debit']
+ credit = aggregated_vals['credit'] + cb_line_vals['credit']
+ balance = debit - credit
+
+ aggregated_vals.update({
+ 'debit': balance if balance > 0 else 0,
+ 'credit': -balance if balance < 0 else 0,
+ 'amount_currency': aggregated_vals['amount_currency'] + cb_line_vals['amount_currency'],
+ })
+
+ if line.tax_repartition_line_id:
+ aggregated_vals.update({
+ 'tax_base_amount': aggregated_vals['tax_base_amount'] + cb_line_vals['tax_base_amount'],
+ })
+ partial_lines_to_create[grouping_key]['tax_line'] += line
+ else:
+ partial_lines_to_create[grouping_key] = {
+ 'vals': cb_line_vals,
+ }
+ if line.tax_repartition_line_id:
+ partial_lines_to_create[grouping_key].update({
+ 'tax_line': line,
+ })
+
+ # ==========================================================================
+ # Create the counterpart journal items.
+ # ==========================================================================
+
+ # To be able to retrieve the correct matching between the tax lines to reconcile
+ # later, the lines will be created using a specific sequence.
+ sequence = 0
+
+ for grouping_key, aggregated_vals in partial_lines_to_create.items():
+ line_vals = aggregated_vals['vals']
+ line_vals['sequence'] = sequence
+
+ pending_cash_basis_lines.append((grouping_key, line_vals['amount_currency']))
+
+ if 'tax_repartition_line_id' in line_vals:
+ # Tax line.
+
+ tax_line = aggregated_vals['tax_line']
+ counterpart_line_vals = self._prepare_cash_basis_counterpart_tax_line_vals(tax_line, line_vals)
+ counterpart_line_vals['sequence'] = sequence + 1
+
+ if tax_line.account_id.reconcile:
+ move_index = len(moves_to_create)
+ to_reconcile_after.append((tax_line, move_index, counterpart_line_vals['sequence']))
+
+ else:
+ # Base line.
+
+ counterpart_line_vals = self._prepare_cash_basis_counterpart_base_line_vals(line_vals)
+ counterpart_line_vals['sequence'] = sequence + 1
+
+ sequence += 2
+
+ move_vals['line_ids'] += [(0, 0, counterpart_line_vals), (0, 0, line_vals)]
+
+ moves_to_create.append(move_vals)
+
+ moves = self.env['account.move'].create(moves_to_create)
+ moves._post(soft=False)
+
+ # Reconcile the tax lines being on a reconcile tax basis transfer account.
+ for lines, move_index, sequence in to_reconcile_after:
+
+ # In expenses, all move lines are created manually without any grouping on tax lines.
+ # In that case, 'lines' could be already reconciled.
+ lines = lines.filtered(lambda x: not x.reconciled)
+ if not lines:
+ continue
+
+ 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
+
+ (lines + counterpart_line).reconcile()
+
+ return moves
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
new file mode 100644
index 00000000..c56c7d97
--- /dev/null
+++ b/addons/account/models/account_payment.py
@@ -0,0 +1,863 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError, ValidationError
+
+
+class AccountPaymentMethod(models.Model):
+ _name = "account.payment.method"
+ _description = "Payment Methods"
+ _order = 'sequence'
+
+ name = fields.Char(required=True, translate=True)
+ code = fields.Char(required=True) # For internal identification
+ payment_type = fields.Selection([('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True)
+ sequence = fields.Integer(help='Used to order Methods in the form view', default=10)
+
+
+class AccountPayment(models.Model):
+ _name = "account.payment"
+ _inherits = {'account.move': 'move_id'}
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _description = "Payments"
+ _order = "date desc, name desc"
+ _check_company_auto = True
+
+ def _get_default_journal(self):
+ ''' Retrieve the default journal for the account.payment.
+ /!\ This method will not override the method in 'account.move' because the ORM
+ doesn't allow overriding methods using _inherits. Then, this method will be called
+ manually in 'create' and 'new'.
+ :return: An account.journal record.
+ '''
+ return self.env['account.move']._search_default_journal(('bank', 'cash'))
+
+ # == Business fields ==
+ move_id = fields.Many2one(
+ comodel_name='account.move',
+ string='Journal Entry', required=True, readonly=True, ondelete='cascade',
+ check_company=True)
+
+ is_reconciled = fields.Boolean(string="Is Reconciled", store=True,
+ compute='_compute_reconciliation_status',
+ help="Technical field indicating if the payment is already reconciled.")
+ is_matched = fields.Boolean(string="Is Matched With a Bank Statement", store=True,
+ compute='_compute_reconciliation_status',
+ help="Technical field indicating if the payment has been matched with a statement line.")
+ partner_bank_id = fields.Many2one('res.partner.bank', string="Recipient Bank Account",
+ readonly=False, store=True,
+ compute='_compute_partner_bank_id',
+ domain="[('partner_id', '=', partner_id)]",
+ check_company=True)
+ is_internal_transfer = fields.Boolean(string="Is Internal Transfer",
+ readonly=False, store=True,
+ compute="_compute_is_internal_transfer")
+ qr_code = fields.Char(string="QR Code",
+ compute="_compute_qr_code",
+ help="QR-code report URL to use to generate the QR-code to scan with a banking app to perform this payment.")
+
+ # == Payment methods fields ==
+ payment_method_id = fields.Many2one('account.payment.method', string='Payment Method',
+ readonly=False, store=True,
+ compute='_compute_payment_method_id',
+ domain="[('id', 'in', available_payment_method_ids)]",
+ help="Manual: Get paid by cash, check or any other method outside of Odoo.\n"\
+ "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n"\
+ "Check: Pay bill by check and print it from Odoo.\n"\
+ "Batch Deposit: Encase several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Odoo, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit, module account_batch_payment must be installed.\n"\
+ "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ")
+ available_payment_method_ids = fields.Many2many('account.payment.method',
+ compute='_compute_payment_method_fields')
+ hide_payment_method = fields.Boolean(
+ compute='_compute_payment_method_fields',
+ help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'")
+
+ # == Synchronized fields with the account.move.lines ==
+ amount = fields.Monetary(currency_field='currency_id')
+ payment_type = fields.Selection([
+ ('outbound', 'Send Money'),
+ ('inbound', 'Receive Money'),
+ ], string='Payment Type', default='inbound', required=True)
+ partner_type = fields.Selection([
+ ('customer', 'Customer'),
+ ('supplier', 'Vendor'),
+ ], default='customer', tracking=True, required=True)
+ payment_reference = fields.Char(string="Payment Reference", copy=False,
+ help="Reference of the document used to issue this payment. Eg. check number, file name, etc.")
+ currency_id = fields.Many2one('res.currency', string='Currency', store=True, readonly=False,
+ compute='_compute_currency_id',
+ help="The payment's currency.")
+ partner_id = fields.Many2one(
+ comodel_name='res.partner',
+ string="Customer/Vendor",
+ store=True, readonly=False, ondelete='restrict',
+ compute='_compute_partner_id',
+ domain="['|', ('parent_id','=', False), ('is_company','=', True)]",
+ check_company=True)
+ destination_account_id = fields.Many2one(
+ comodel_name='account.account',
+ string='Destination Account',
+ store=True, readonly=False,
+ compute='_compute_destination_account_id',
+ domain="[('user_type_id.type', 'in', ('receivable', 'payable')), ('company_id', '=', company_id)]",
+ check_company=True)
+
+ # == Stat buttons ==
+ reconciled_invoice_ids = fields.Many2many('account.move', string="Reconciled Invoices",
+ compute='_compute_stat_buttons_from_reconciliation',
+ help="Invoices whose journal items have been reconciled with these payments.")
+ reconciled_invoices_count = fields.Integer(string="# Reconciled Invoices",
+ compute="_compute_stat_buttons_from_reconciliation")
+ reconciled_bill_ids = fields.Many2many('account.move', string="Reconciled Bills",
+ compute='_compute_stat_buttons_from_reconciliation',
+ help="Invoices whose journal items have been reconciled with these payments.")
+ reconciled_bills_count = fields.Integer(string="# Reconciled Bills",
+ compute="_compute_stat_buttons_from_reconciliation")
+ reconciled_statement_ids = fields.Many2many('account.bank.statement', string="Reconciled Statements",
+ compute='_compute_stat_buttons_from_reconciliation',
+ help="Statements matched to this payment")
+ reconciled_statements_count = fields.Integer(string="# Reconciled Statements",
+ compute="_compute_stat_buttons_from_reconciliation")
+
+ # == Display purpose fields ==
+ payment_method_code = fields.Char(
+ related='payment_method_id.code',
+ help="Technical field used to adapt the interface to the payment type selected.")
+ show_partner_bank_account = fields.Boolean(
+ compute='_compute_show_require_partner_bank',
+ help="Technical field used to know whether the field `partner_bank_id` needs to be displayed or not in the payments form views")
+ require_partner_bank_account = fields.Boolean(
+ compute='_compute_show_require_partner_bank',
+ help="Technical field used to know whether the field `partner_bank_id` needs to be required or not in the payments form views")
+ country_code = fields.Char(related='company_id.country_id.code')
+
+ _sql_constraints = [
+ (
+ 'check_amount_not_negative',
+ 'CHECK(amount >= 0.0)',
+ "The payment amount cannot be negative.",
+ ),
+ ]
+
+ # -------------------------------------------------------------------------
+ # HELPERS
+ # -------------------------------------------------------------------------
+
+ def _seek_for_lines(self):
+ ''' Helper used to dispatch the journal items between:
+ - The lines using the temporary liquidity account.
+ - The lines using the counterpart account.
+ - The lines being the write-off lines.
+ :return: (liquidity_lines, counterpart_lines, writeoff_lines)
+ '''
+ self.ensure_one()
+
+ liquidity_lines = self.env['account.move.line']
+ counterpart_lines = self.env['account.move.line']
+ writeoff_lines = self.env['account.move.line']
+
+ for line in self.move_id.line_ids:
+ if line.account_id in (
+ self.journal_id.default_account_id,
+ self.journal_id.payment_debit_account_id,
+ self.journal_id.payment_credit_account_id,
+ ):
+ liquidity_lines += line
+ elif line.account_id.internal_type in ('receivable', 'payable') or line.partner_id == line.company_id.partner_id:
+ counterpart_lines += line
+ else:
+ writeoff_lines += line
+
+ return liquidity_lines, counterpart_lines, writeoff_lines
+
+ def _prepare_move_line_default_vals(self, write_off_line_vals=None):
+ ''' Prepare the dictionary to create the default account.move.lines for the current payment.
+ :param write_off_line_vals: Optional dictionary to create a write-off account.move.line easily containing:
+ * amount: The amount to be added to the counterpart amount.
+ * name: The label to set on the line.
+ * account_id: The account on which create the write-off.
+ :return: A list of python dictionary to be passed to the account.move.line's 'create' method.
+ '''
+ self.ensure_one()
+ write_off_line_vals = write_off_line_vals or {}
+
+ if not self.journal_id.payment_debit_account_id or not self.journal_id.payment_credit_account_id:
+ raise UserError(_(
+ "You can't create a new payment without an outstanding payments/receipts account set on the %s journal.",
+ self.journal_id.display_name))
+
+ # Compute amounts.
+ write_off_amount_currency = write_off_line_vals.get('amount', 0.0)
+
+ if self.payment_type == 'inbound':
+ # Receive money.
+ liquidity_amount_currency = self.amount
+ elif self.payment_type == 'outbound':
+ # Send money.
+ liquidity_amount_currency = -self.amount
+ write_off_amount_currency *= -1
+ else:
+ liquidity_amount_currency = write_off_amount_currency = 0.0
+
+ write_off_balance = self.currency_id._convert(
+ write_off_amount_currency,
+ self.company_id.currency_id,
+ self.company_id,
+ self.date,
+ )
+ liquidity_balance = self.currency_id._convert(
+ liquidity_amount_currency,
+ self.company_id.currency_id,
+ self.company_id,
+ self.date,
+ )
+ counterpart_amount_currency = -liquidity_amount_currency - write_off_amount_currency
+ counterpart_balance = -liquidity_balance - write_off_balance
+ currency_id = self.currency_id.id
+
+ if self.is_internal_transfer:
+ if self.payment_type == 'inbound':
+ liquidity_line_name = _('Transfer to %s', self.journal_id.name)
+ else: # payment.payment_type == 'outbound':
+ liquidity_line_name = _('Transfer from %s', self.journal_id.name)
+ else:
+ liquidity_line_name = self.payment_reference
+
+ # Compute a default label to set on the journal items.
+
+ payment_display_name = {
+ 'outbound-customer': _("Customer Reimbursement"),
+ 'inbound-customer': _("Customer Payment"),
+ 'outbound-supplier': _("Vendor Payment"),
+ 'inbound-supplier': _("Vendor Reimbursement"),
+ }
+
+ default_line_name = self.env['account.move.line']._get_default_line_name(
+ _("Internal Transfer") if self.is_internal_transfer else payment_display_name['%s-%s' % (self.payment_type, self.partner_type)],
+ self.amount,
+ self.currency_id,
+ self.date,
+ partner=self.partner_id,
+ )
+
+ line_vals_list = [
+ # Liquidity line.
+ {
+ 'name': liquidity_line_name or default_line_name,
+ 'date_maturity': self.date,
+ 'amount_currency': liquidity_amount_currency,
+ 'currency_id': currency_id,
+ 'debit': liquidity_balance if liquidity_balance > 0.0 else 0.0,
+ 'credit': -liquidity_balance if liquidity_balance < 0.0 else 0.0,
+ 'partner_id': self.partner_id.id,
+ 'account_id': self.journal_id.payment_credit_account_id.id if liquidity_balance < 0.0 else self.journal_id.payment_debit_account_id.id,
+ },
+ # Receivable / Payable.
+ {
+ 'name': self.payment_reference or default_line_name,
+ 'date_maturity': self.date,
+ 'amount_currency': counterpart_amount_currency,
+ 'currency_id': currency_id,
+ 'debit': counterpart_balance if counterpart_balance > 0.0 else 0.0,
+ 'credit': -counterpart_balance if counterpart_balance < 0.0 else 0.0,
+ 'partner_id': self.partner_id.id,
+ 'account_id': self.destination_account_id.id,
+ },
+ ]
+ if not self.currency_id.is_zero(write_off_amount_currency):
+ # Write-off line.
+ line_vals_list.append({
+ 'name': write_off_line_vals.get('name') or default_line_name,
+ 'amount_currency': write_off_amount_currency,
+ 'currency_id': currency_id,
+ 'debit': write_off_balance if write_off_balance > 0.0 else 0.0,
+ 'credit': -write_off_balance if write_off_balance < 0.0 else 0.0,
+ 'partner_id': self.partner_id.id,
+ 'account_id': write_off_line_vals.get('account_id'),
+ })
+ return line_vals_list
+
+ # -------------------------------------------------------------------------
+ # COMPUTE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.depends('move_id.line_ids.amount_residual', 'move_id.line_ids.amount_residual_currency', 'move_id.line_ids.account_id')
+ def _compute_reconciliation_status(self):
+ ''' Compute the field indicating if the payments are already reconciled with something.
+ This field is used for display purpose (e.g. display the 'reconcile' button redirecting to the reconciliation
+ widget).
+ '''
+ for pay in self:
+ liquidity_lines, counterpart_lines, writeoff_lines = pay._seek_for_lines()
+
+ if not pay.currency_id or not pay.id:
+ pay.is_reconciled = False
+ pay.is_matched = False
+ elif pay.currency_id.is_zero(pay.amount):
+ pay.is_reconciled = True
+ pay.is_matched = True
+ else:
+ residual_field = 'amount_residual' if pay.currency_id == pay.company_id.currency_id else 'amount_residual_currency'
+ if pay.journal_id.default_account_id and pay.journal_id.default_account_id in liquidity_lines.account_id:
+ # Allow user managing payments without any statement lines by using the bank account directly.
+ # In that case, the user manages transactions only using the register payment wizard.
+ pay.is_matched = True
+ else:
+ pay.is_matched = pay.currency_id.is_zero(sum(liquidity_lines.mapped(residual_field)))
+
+ reconcile_lines = (counterpart_lines + writeoff_lines).filtered(lambda line: line.account_id.reconcile)
+ pay.is_reconciled = pay.currency_id.is_zero(sum(reconcile_lines.mapped(residual_field)))
+
+ @api.model
+ def _get_method_codes_using_bank_account(self):
+ return ['manual']
+
+ @api.model
+ def _get_method_codes_needing_bank_account(self):
+ return []
+
+ @api.depends('payment_method_code')
+ def _compute_show_require_partner_bank(self):
+ """ Computes if the destination bank account must be displayed in the payment form view. By default, it
+ won't be displayed but some modules might change that, depending on the payment type."""
+ for payment in self:
+ payment.show_partner_bank_account = payment.payment_method_code in self._get_method_codes_using_bank_account()
+ payment.require_partner_bank_account = payment.state == 'draft' and payment.payment_method_code in self._get_method_codes_needing_bank_account()
+
+ @api.depends('partner_id')
+ def _compute_partner_bank_id(self):
+ ''' The default partner_bank_id will be the first available on the partner. '''
+ for pay in self:
+ available_partner_bank_accounts = pay.partner_id.bank_ids.filtered(lambda x: x.company_id in (False, pay.company_id))
+ if available_partner_bank_accounts:
+ if pay.partner_bank_id not in available_partner_bank_accounts:
+ pay.partner_bank_id = available_partner_bank_accounts[0]._origin
+ else:
+ pay.partner_bank_id = False
+
+ @api.depends('partner_id', 'destination_account_id', 'journal_id')
+ def _compute_is_internal_transfer(self):
+ for payment in self:
+ is_partner_ok = payment.partner_id == payment.journal_id.company_id.partner_id
+ is_account_ok = payment.destination_account_id and payment.destination_account_id == payment.journal_id.company_id.transfer_account_id
+ payment.is_internal_transfer = is_partner_ok and is_account_ok
+
+ @api.depends('payment_type', 'journal_id')
+ def _compute_payment_method_id(self):
+ ''' Compute the 'payment_method_id' field.
+ This field is not computed in '_compute_payment_method_fields' because it's a stored editable one.
+ '''
+ for pay in self:
+ if pay.payment_type == 'inbound':
+ available_payment_methods = pay.journal_id.inbound_payment_method_ids
+ else:
+ available_payment_methods = pay.journal_id.outbound_payment_method_ids
+
+ # Select the first available one by default.
+ if pay.payment_method_id in available_payment_methods:
+ pay.payment_method_id = pay.payment_method_id
+ elif available_payment_methods:
+ pay.payment_method_id = available_payment_methods[0]._origin
+ else:
+ pay.payment_method_id = False
+
+ @api.depends('payment_type',
+ 'journal_id.inbound_payment_method_ids',
+ 'journal_id.outbound_payment_method_ids')
+ def _compute_payment_method_fields(self):
+ for pay in self:
+ if pay.payment_type == 'inbound':
+ pay.available_payment_method_ids = pay.journal_id.inbound_payment_method_ids
+ else:
+ pay.available_payment_method_ids = pay.journal_id.outbound_payment_method_ids
+
+ pay.hide_payment_method = len(pay.available_payment_method_ids) == 1 and pay.available_payment_method_ids.code == 'manual'
+
+ @api.depends('journal_id')
+ def _compute_currency_id(self):
+ for pay in self:
+ pay.currency_id = pay.journal_id.currency_id or pay.journal_id.company_id.currency_id
+
+ @api.depends('is_internal_transfer')
+ def _compute_partner_id(self):
+ for pay in self:
+ if pay.is_internal_transfer:
+ pay.partner_id = pay.journal_id.company_id.partner_id
+ elif pay.partner_id == pay.journal_id.company_id.partner_id:
+ pay.partner_id = False
+ else:
+ pay.partner_id = pay.partner_id
+
+ @api.depends('journal_id', 'partner_id', 'partner_type', 'is_internal_transfer')
+ def _compute_destination_account_id(self):
+ self.destination_account_id = False
+ for pay in self:
+ if pay.is_internal_transfer:
+ pay.destination_account_id = pay.journal_id.company_id.transfer_account_id
+ elif pay.partner_type == 'customer':
+ # Receive money from invoice or send money to refund it.
+ if pay.partner_id:
+ pay.destination_account_id = pay.partner_id.with_company(pay.company_id).property_account_receivable_id
+ else:
+ pay.destination_account_id = self.env['account.account'].search([
+ ('company_id', '=', pay.company_id.id),
+ ('internal_type', '=', 'receivable'),
+ ('deprecated', '=', False),
+ ], limit=1)
+ elif pay.partner_type == 'supplier':
+ # Send money to pay a bill or receive money to refund it.
+ if pay.partner_id:
+ pay.destination_account_id = pay.partner_id.with_company(pay.company_id).property_account_payable_id
+ else:
+ pay.destination_account_id = self.env['account.account'].search([
+ ('company_id', '=', pay.company_id.id),
+ ('internal_type', '=', 'payable'),
+ ('deprecated', '=', False),
+ ], limit=1)
+
+ @api.depends('partner_bank_id', 'amount', 'ref', 'currency_id', 'journal_id', 'move_id.state',
+ 'payment_method_id', 'payment_type')
+ def _compute_qr_code(self):
+ for pay in self:
+ if pay.state in ('draft', 'posted') \
+ and pay.partner_bank_id \
+ and pay.payment_method_id.code == 'manual' \
+ and pay.payment_type == 'outbound' \
+ and pay.currency_id:
+
+ if pay.partner_bank_id:
+ qr_code = pay.partner_bank_id.build_qr_code_url(pay.amount, pay.ref, pay.ref, pay.currency_id, pay.partner_id)
+ else:
+ qr_code = None
+
+ if qr_code:
+ pay.qr_code = '''
+ <br/>
+ <img class="border border-dark rounded" src="{qr_code}"/>
+ <br/>
+ <strong class="text-center">{txt}</strong>
+ '''.format(txt = _('Scan me with your banking app.'),
+ qr_code = qr_code)
+ continue
+
+ pay.qr_code = None
+
+ @api.depends('move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids')
+ def _compute_stat_buttons_from_reconciliation(self):
+ ''' Retrieve the invoices reconciled to the payments through the reconciliation (account.partial.reconcile). '''
+ stored_payments = self.filtered('id')
+ if not stored_payments:
+ self.reconciled_invoice_ids = False
+ self.reconciled_invoices_count = 0
+ self.reconciled_bill_ids = False
+ self.reconciled_bills_count = 0
+ self.reconciled_statement_ids = False
+ self.reconciled_statements_count = 0
+ return
+
+ self.env['account.move'].flush()
+ self.env['account.move.line'].flush()
+ self.env['account.partial.reconcile'].flush()
+
+ self._cr.execute('''
+ SELECT
+ payment.id,
+ ARRAY_AGG(DISTINCT invoice.id) AS invoice_ids,
+ invoice.move_type
+ FROM account_payment payment
+ JOIN account_move move ON move.id = payment.move_id
+ JOIN account_move_line line ON line.move_id = move.id
+ JOIN account_partial_reconcile part ON
+ part.debit_move_id = line.id
+ OR
+ part.credit_move_id = line.id
+ JOIN account_move_line counterpart_line ON
+ part.debit_move_id = counterpart_line.id
+ OR
+ part.credit_move_id = counterpart_line.id
+ JOIN account_move invoice ON invoice.id = counterpart_line.move_id
+ JOIN account_account account ON account.id = line.account_id
+ WHERE account.internal_type IN ('receivable', 'payable')
+ AND payment.id IN %(payment_ids)s
+ AND line.id != counterpart_line.id
+ AND invoice.move_type in ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')
+ GROUP BY payment.id, invoice.move_type
+ ''', {
+ 'payment_ids': tuple(stored_payments.ids)
+ })
+ query_res = self._cr.dictfetchall()
+ self.reconciled_invoice_ids = self.reconciled_invoices_count = False
+ self.reconciled_bill_ids = self.reconciled_bills_count = False
+ for res in query_res:
+ pay = self.browse(res['id'])
+ if res['move_type'] in self.env['account.move'].get_sale_types(True):
+ pay.reconciled_invoice_ids += self.env['account.move'].browse(res.get('invoice_ids', []))
+ pay.reconciled_invoices_count = len(res.get('invoice_ids', []))
+ else:
+ pay.reconciled_bill_ids += self.env['account.move'].browse(res.get('invoice_ids', []))
+ pay.reconciled_bills_count = len(res.get('invoice_ids', []))
+
+ self._cr.execute('''
+ SELECT
+ payment.id,
+ ARRAY_AGG(DISTINCT counterpart_line.statement_id) AS statement_ids
+ FROM account_payment payment
+ JOIN account_move move ON move.id = payment.move_id
+ JOIN account_journal journal ON journal.id = move.journal_id
+ JOIN account_move_line line ON line.move_id = move.id
+ JOIN account_account account ON account.id = line.account_id
+ JOIN account_partial_reconcile part ON
+ part.debit_move_id = line.id
+ OR
+ part.credit_move_id = line.id
+ JOIN account_move_line counterpart_line ON
+ part.debit_move_id = counterpart_line.id
+ OR
+ part.credit_move_id = counterpart_line.id
+ WHERE (account.id = journal.payment_debit_account_id OR account.id = journal.payment_credit_account_id)
+ AND payment.id IN %(payment_ids)s
+ AND line.id != counterpart_line.id
+ AND counterpart_line.statement_id IS NOT NULL
+ GROUP BY payment.id
+ ''', {
+ 'payment_ids': tuple(stored_payments.ids)
+ })
+ query_res = dict((payment_id, statement_ids) for payment_id, statement_ids in self._cr.fetchall())
+
+ for pay in self:
+ statement_ids = query_res.get(pay.id, [])
+ pay.reconciled_statement_ids = [(6, 0, statement_ids)]
+ pay.reconciled_statements_count = len(statement_ids)
+
+ # -------------------------------------------------------------------------
+ # ONCHANGE METHODS
+ # -------------------------------------------------------------------------
+
+ @api.onchange('posted_before', 'state', 'journal_id', 'date')
+ def _onchange_journal_date(self):
+ # Before the record is created, the move_id doesn't exist yet, and the name will not be
+ # recomputed correctly if we change the journal or the date, leading to inconsitencies
+ if not self.move_id:
+ self.name = False
+
+ # -------------------------------------------------------------------------
+ # CONSTRAINT METHODS
+ # -------------------------------------------------------------------------
+
+ @api.constrains('payment_method_id')
+ def _check_payment_method_id(self):
+ ''' Ensure the 'payment_method_id' field is not null.
+ Can't be done using the regular 'required=True' because the field is a computed editable stored one.
+ '''
+ for pay in self:
+ if not pay.payment_method_id:
+ raise ValidationError(_("Please define a payment method on your payment."))
+
+ # -------------------------------------------------------------------------
+ # LOW-LEVEL METHODS
+ # -------------------------------------------------------------------------
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ # OVERRIDE
+ write_off_line_vals_list = []
+
+ for vals in vals_list:
+
+ # Hack to add a custom write-off line.
+ write_off_line_vals_list.append(vals.pop('write_off_line_vals', None))
+
+ # Force the move_type to avoid inconsistency with residual 'default_move_type' inside the context.
+ vals['move_type'] = 'entry'
+
+ # Force the computation of 'journal_id' since this field is set on account.move but must have the
+ # bank/cash type.
+ if 'journal_id' not in vals:
+ vals['journal_id'] = self._get_default_journal().id
+
+ # Since 'currency_id' is a computed editable field, it will be computed later.
+ # Prevent the account.move to call the _get_default_currency method that could raise
+ # the 'Please define an accounting miscellaneous journal in your company' error.
+ if 'currency_id' not in vals:
+ journal = self.env['account.journal'].browse(vals['journal_id'])
+ vals['currency_id'] = journal.currency_id.id or journal.company_id.currency_id.id
+
+ payments = super().create(vals_list)
+
+ for i, pay in enumerate(payments):
+ write_off_line_vals = write_off_line_vals_list[i]
+
+ # Write payment_id on the journal entry plus the fields being stored in both models but having the same
+ # name, e.g. partner_bank_id. The ORM is currently not able to perform such synchronization and make things
+ # more difficult by creating related fields on the fly to handle the _inherits.
+ # Then, when partner_bank_id is in vals, the key is consumed by account.payment but is never written on
+ # account.move.
+ to_write = {'payment_id': pay.id}
+ for k, v in vals_list[i].items():
+ if k in self._fields and self._fields[k].store and k in pay.move_id._fields and pay.move_id._fields[k].store:
+ to_write[k] = v
+
+ if 'line_ids' not in vals_list[i]:
+ to_write['line_ids'] = [(0, 0, line_vals) for line_vals in pay._prepare_move_line_default_vals(write_off_line_vals=write_off_line_vals)]
+
+ pay.move_id.write(to_write)
+
+ return payments
+
+ def write(self, vals):
+ # OVERRIDE
+ res = super().write(vals)
+ self._synchronize_to_moves(set(vals.keys()))
+ return res
+
+ def unlink(self):
+ # OVERRIDE to unlink the inherited account.move (move_id field) as well.
+ moves = self.with_context(force_delete=True).move_id
+ res = super().unlink()
+ moves.unlink()
+ return res
+
+ @api.depends('move_id.name')
+ def name_get(self):
+ return [(payment.id, payment.move_id.name or _('Draft Payment')) for payment in self]
+
+ # -------------------------------------------------------------------------
+ # SYNCHRONIZATION account.payment <-> account.move
+ # -------------------------------------------------------------------------
+
+ def _synchronize_from_moves(self, changed_fields):
+ ''' Update the account.payment regarding its related account.move.
+ Also, check both models are still consistent.
+ :param changed_fields: A set containing all modified fields on account.move.
+ '''
+ if self._context.get('skip_account_move_synchronization'):
+ return
+
+ for pay in self.with_context(skip_account_move_synchronization=True):
+
+ # After the migration to 14.0, the journal entry could be shared between the account.payment and the
+ # account.bank.statement.line. In that case, the synchronization will only be made with the statement line.
+ if pay.move_id.statement_line_id:
+ continue
+
+ move = pay.move_id
+ move_vals_to_write = {}
+ payment_vals_to_write = {}
+
+ if 'journal_id' in changed_fields:
+ if pay.journal_id.type not in ('bank', 'cash'):
+ raise UserError(_("A payment must always belongs to a bank or cash journal."))
+
+ if 'line_ids' in changed_fields:
+ all_lines = move.line_ids
+ liquidity_lines, counterpart_lines, writeoff_lines = pay._seek_for_lines()
+
+ if len(liquidity_lines) != 1 or len(counterpart_lines) != 1:
+ raise UserError(_(
+ "The journal entry %s reached an invalid state relative to its payment.\n"
+ "To be consistent, the journal entry must always contains:\n"
+ "- one journal item involving the outstanding payment/receipts account.\n"
+ "- one journal item involving a receivable/payable account.\n"
+ "- optional journal items, all sharing the same account.\n\n"
+ ) % move.display_name)
+
+ if writeoff_lines and len(writeoff_lines.account_id) != 1:
+ raise UserError(_(
+ "The journal entry %s reached an invalid state relative to its payment.\n"
+ "To be consistent, all the write-off journal items must share the same account."
+ ) % move.display_name)
+
+ if any(line.currency_id != all_lines[0].currency_id for line in all_lines):
+ raise UserError(_(
+ "The journal entry %s reached an invalid state relative to its payment.\n"
+ "To be consistent, the journal items must share the same currency."
+ ) % move.display_name)
+
+ if any(line.partner_id != all_lines[0].partner_id for line in all_lines):
+ raise UserError(_(
+ "The journal entry %s reached an invalid state relative to its payment.\n"
+ "To be consistent, the journal items must share the same partner."
+ ) % move.display_name)
+
+ if counterpart_lines.account_id.user_type_id.type == 'receivable':
+ partner_type = 'customer'
+ else:
+ partner_type = 'supplier'
+
+ liquidity_amount = liquidity_lines.amount_currency
+
+ move_vals_to_write.update({
+ 'currency_id': liquidity_lines.currency_id.id,
+ 'partner_id': liquidity_lines.partner_id.id,
+ })
+ payment_vals_to_write.update({
+ 'amount': abs(liquidity_amount),
+ 'partner_type': partner_type,
+ 'currency_id': liquidity_lines.currency_id.id,
+ 'destination_account_id': counterpart_lines.account_id.id,
+ 'partner_id': liquidity_lines.partner_id.id,
+ })
+ if liquidity_amount > 0.0:
+ payment_vals_to_write.update({'payment_type': 'inbound'})
+ elif liquidity_amount < 0.0:
+ payment_vals_to_write.update({'payment_type': 'outbound'})
+
+ move.write(move._cleanup_write_orm_values(move, move_vals_to_write))
+ pay.write(move._cleanup_write_orm_values(pay, payment_vals_to_write))
+
+ def _synchronize_to_moves(self, changed_fields):
+ ''' Update the account.move regarding the modified account.payment.
+ :param changed_fields: A list containing all modified fields on account.payment.
+ '''
+ if self._context.get('skip_account_move_synchronization'):
+ return
+
+ if not any(field_name in changed_fields for field_name in (
+ 'date', 'amount', 'payment_type', 'partner_type', 'payment_reference', 'is_internal_transfer',
+ 'currency_id', 'partner_id', 'destination_account_id', 'partner_bank_id',
+ )):
+ return
+
+ for pay in self.with_context(skip_account_move_synchronization=True):
+ liquidity_lines, counterpart_lines, writeoff_lines = pay._seek_for_lines()
+
+ # Make sure to preserve the write-off amount.
+ # This allows to create a new payment with custom 'line_ids'.
+
+ if writeoff_lines:
+ counterpart_amount = sum(counterpart_lines.mapped('amount_currency'))
+ writeoff_amount = sum(writeoff_lines.mapped('amount_currency'))
+
+ # To be consistent with the payment_difference made in account.payment.register,
+ # 'writeoff_amount' needs to be signed regarding the 'amount' field before the write.
+ # Since the write is already done at this point, we need to base the computation on accounting values.
+ if (counterpart_amount > 0.0) == (writeoff_amount > 0.0):
+ sign = -1
+ else:
+ sign = 1
+ writeoff_amount = abs(writeoff_amount) * sign
+
+ write_off_line_vals = {
+ 'name': writeoff_lines[0].name,
+ 'amount': writeoff_amount,
+ 'account_id': writeoff_lines[0].account_id.id,
+ }
+ else:
+ write_off_line_vals = {}
+
+ line_vals_list = pay._prepare_move_line_default_vals(write_off_line_vals=write_off_line_vals)
+
+ line_ids_commands = [
+ (1, liquidity_lines.id, line_vals_list[0]),
+ (1, counterpart_lines.id, line_vals_list[1]),
+ ]
+
+ for line in writeoff_lines:
+ line_ids_commands.append((2, line.id))
+
+ for extra_line_vals in line_vals_list[2:]:
+ line_ids_commands.append((0, 0, extra_line_vals))
+
+ # Update the existing journal items.
+ # If dealing with multiple write-off lines, they are dropped and a new one is generated.
+
+ pay.move_id.write({
+ 'partner_id': pay.partner_id.id,
+ 'currency_id': pay.currency_id.id,
+ 'partner_bank_id': pay.partner_bank_id.id,
+ 'line_ids': line_ids_commands,
+ })
+
+ # -------------------------------------------------------------------------
+ # BUSINESS METHODS
+ # -------------------------------------------------------------------------
+
+ def mark_as_sent(self):
+ self.write({'is_move_sent': True})
+
+ def unmark_as_sent(self):
+ self.write({'is_move_sent': False})
+
+ def action_post(self):
+ ''' draft -> posted '''
+ self.move_id._post(soft=False)
+
+ def action_cancel(self):
+ ''' draft -> cancelled '''
+ self.move_id.button_cancel()
+
+ def action_draft(self):
+ ''' posted -> draft '''
+ self.move_id.button_draft()
+
+ def button_open_invoices(self):
+ ''' Redirect the user to the invoice(s) paid by this payment.
+ :return: An action on account.move.
+ '''
+ self.ensure_one()
+
+ action = {
+ 'name': _("Paid Invoices"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'context': {'create': False},
+ }
+ if len(self.reconciled_invoice_ids) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': self.reconciled_invoice_ids.id,
+ })
+ else:
+ action.update({
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', self.reconciled_invoice_ids.ids)],
+ })
+ return action
+
+ def button_open_bills(self):
+ ''' Redirect the user to the bill(s) paid by this payment.
+ :return: An action on account.move.
+ '''
+ self.ensure_one()
+
+ action = {
+ 'name': _("Paid Bills"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.move',
+ 'context': {'create': False},
+ }
+ if len(self.reconciled_bill_ids) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': self.reconciled_bill_ids.id,
+ })
+ else:
+ action.update({
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', self.reconciled_bill_ids.ids)],
+ })
+ return action
+
+ def button_open_statements(self):
+ ''' Redirect the user to the statement line(s) reconciled to this payment.
+ :return: An action on account.move.
+ '''
+ self.ensure_one()
+
+ action = {
+ 'name': _("Matched Statements"),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'account.bank.statement',
+ 'context': {'create': False},
+ }
+ if len(self.reconciled_statement_ids) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': self.reconciled_statement_ids.id,
+ })
+ else:
+ action.update({
+ 'view_mode': 'list,form',
+ 'domain': [('id', 'in', self.reconciled_statement_ids.ids)],
+ })
+ return action
diff --git a/addons/account/models/account_payment_term.py b/addons/account/models/account_payment_term.py
new file mode 100644
index 00000000..3713d901
--- /dev/null
+++ b/addons/account/models/account_payment_term.py
@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, exceptions, fields, models, _
+from odoo.exceptions import UserError, ValidationError
+
+from dateutil.relativedelta import relativedelta
+
+
+class AccountPaymentTerm(models.Model):
+ _name = "account.payment.term"
+ _description = "Payment Terms"
+ _order = "sequence, id"
+
+ def _default_line_ids(self):
+ return [(0, 0, {'value': 'balance', 'value_amount': 0.0, 'sequence': 9, 'days': 0, 'option': 'day_after_invoice_date'})]
+
+ name = fields.Char(string='Payment Terms', translate=True, required=True)
+ active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the payment terms without removing it.")
+ note = fields.Text(string='Description on the Invoice', translate=True)
+ line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids)
+ company_id = fields.Many2one('res.company', string='Company')
+ sequence = fields.Integer(required=True, default=10)
+
+ @api.constrains('line_ids')
+ def _check_lines(self):
+ for terms in self:
+ payment_term_lines = terms.line_ids.sorted()
+ if payment_term_lines and payment_term_lines[-1].value != 'balance':
+ raise ValidationError(_('The last line of a Payment Term should have the Balance type.'))
+ lines = terms.line_ids.filtered(lambda r: r.value == 'balance')
+ if len(lines) > 1:
+ raise ValidationError(_('A Payment Term should have only one line of type Balance.'))
+
+ def compute(self, value, date_ref=False, currency=None):
+ self.ensure_one()
+ date_ref = date_ref or fields.Date.context_today(self)
+ amount = value
+ sign = value < 0 and -1 or 1
+ result = []
+ if not currency and self.env.context.get('currency_id'):
+ currency = self.env['res.currency'].browse(self.env.context['currency_id'])
+ elif not currency:
+ currency = self.env.company.currency_id
+ for line in self.line_ids:
+ if line.value == 'fixed':
+ amt = sign * currency.round(line.value_amount)
+ elif line.value == 'percent':
+ amt = currency.round(value * (line.value_amount / 100.0))
+ elif line.value == 'balance':
+ amt = currency.round(amount)
+ next_date = fields.Date.from_string(date_ref)
+ if line.option == 'day_after_invoice_date':
+ next_date += relativedelta(days=line.days)
+ if line.day_of_the_month > 0:
+ months_delta = (line.day_of_the_month < next_date.day) and 1 or 0
+ next_date += relativedelta(day=line.day_of_the_month, months=months_delta)
+ elif line.option == 'after_invoice_month':
+ next_first_date = next_date + relativedelta(day=1, months=1) # Getting 1st of next month
+ next_date = next_first_date + relativedelta(days=line.days - 1)
+ elif line.option == 'day_following_month':
+ next_date += relativedelta(day=line.days, months=1)
+ elif line.option == 'day_current_month':
+ next_date += relativedelta(day=line.days, months=0)
+ result.append((fields.Date.to_string(next_date), amt))
+ amount -= amt
+ amount = sum(amt for _, amt in result)
+ dist = currency.round(value - amount)
+ if dist:
+ last_date = result and result[-1][0] or fields.Date.context_today(self)
+ result.append((last_date, dist))
+ return result
+
+ def unlink(self):
+ for terms in self:
+ if self.env['account.move'].search([('invoice_payment_term_id', 'in', terms.ids)]):
+ raise UserError(_('You can not delete payment terms as other records still reference it. However, you can archive it.'))
+ self.env['ir.property'].sudo().search(
+ [('value_reference', 'in', ['account.payment.term,%s'%payment_term.id for payment_term in terms])]
+ ).unlink()
+ return super(AccountPaymentTerm, self).unlink()
+
+
+class AccountPaymentTermLine(models.Model):
+ _name = "account.payment.term.line"
+ _description = "Payment Terms Line"
+ _order = "sequence, id"
+
+ value = fields.Selection([
+ ('balance', 'Balance'),
+ ('percent', 'Percent'),
+ ('fixed', 'Fixed Amount')
+ ], string='Type', required=True, default='balance',
+ help="Select here the kind of valuation related to this payment terms line.")
+ value_amount = fields.Float(string='Value', digits='Payment Terms', help="For percent enter a ratio between 0-100.")
+ days = fields.Integer(string='Number of Days', required=True, default=0)
+ day_of_the_month = fields.Integer(string='Day of the month', help="Day of the month on which the invoice must come to its term. If zero or negative, this value will be ignored, and no specific day will be set. If greater than the last day of a month, this number will instead select the last day of this month.")
+ option = fields.Selection([
+ ('day_after_invoice_date', "days after the invoice date"),
+ ('after_invoice_month', "days after the end of the invoice month"),
+ ('day_following_month', "of the following month"),
+ ('day_current_month', "of the current month"),
+ ],
+ default='day_after_invoice_date', required=True, string='Options'
+ )
+ payment_id = fields.Many2one('account.payment.term', string='Payment Terms', required=True, index=True, ondelete='cascade')
+ sequence = fields.Integer(default=10, help="Gives the sequence order when displaying a list of payment terms lines.")
+
+ @api.constrains('value', 'value_amount')
+ def _check_percent(self):
+ for term_line in self:
+ if term_line.value == 'percent' and (term_line.value_amount < 0.0 or term_line.value_amount > 100.0):
+ raise ValidationError(_('Percentages on the Payment Terms lines must be between 0 and 100.'))
+
+ @api.constrains('days')
+ def _check_days(self):
+ for term_line in self:
+ if term_line.option in ('day_following_month', 'day_current_month') and term_line.days <= 0:
+ raise ValidationError(_("The day of the month used for this term must be strictly positive."))
+ elif term_line.days < 0:
+ raise ValidationError(_("The number of days used for a payment term cannot be negative."))
+
+ @api.onchange('option')
+ def _onchange_option(self):
+ if self.option in ('day_current_month', 'day_following_month'):
+ self.days = 0
diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py
new file mode 100644
index 00000000..d6513689
--- /dev/null
+++ b/addons/account/models/account_reconcile_model.py
@@ -0,0 +1,988 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.tools import float_compare, float_is_zero
+from odoo.osv.expression import get_unaccent_wrapper
+from odoo.exceptions import UserError, ValidationError
+import re
+from math import copysign
+import itertools
+from collections import defaultdict
+from dateutil.relativedelta import relativedelta
+
+class AccountReconcileModelPartnerMapping(models.Model):
+ _name = 'account.reconcile.model.partner.mapping'
+ _description = 'Partner mapping for reconciliation models'
+
+ model_id = fields.Many2one(comodel_name='account.reconcile.model', readonly=True, required=True, ondelete='cascade')
+ partner_id = fields.Many2one(comodel_name='res.partner', string="Partner", required=True, ondelete='cascade')
+ payment_ref_regex = fields.Char(string="Find Text in Label")
+ narration_regex = fields.Char(string="Find Text in Notes")
+
+ @api.constrains('narration_regex', 'payment_ref_regex')
+ def validate_regex(self):
+ for record in self:
+ if not (record.narration_regex or record.payment_ref_regex):
+ raise ValidationError(_("Please set at least one of the match texts to create a partner mapping."))
+ try:
+ if record.payment_ref_regex:
+ current_regex = record.payment_ref_regex
+ re.compile(record.payment_ref_regex)
+ if record.narration_regex:
+ current_regex = record.narration_regex
+ re.compile(record.narration_regex)
+ except re.error:
+ raise ValidationError(_("The following regular expression is invalid to create a partner mapping: %s") % current_regex)
+
+class AccountReconcileModelLine(models.Model):
+ _name = 'account.reconcile.model.line'
+ _description = 'Rules for the reconciliation model'
+ _order = 'sequence, id'
+ _check_company_auto = True
+
+ model_id = fields.Many2one('account.reconcile.model', readonly=True, ondelete='cascade')
+ match_total_amount = fields.Boolean(related='model_id.match_total_amount')
+ match_total_amount_param = fields.Float(related='model_id.match_total_amount_param')
+ rule_type = fields.Selection(related='model_id.rule_type')
+ company_id = fields.Many2one(related='model_id.company_id', store=True, default=lambda self: self.env.company)
+ sequence = fields.Integer(required=True, default=10)
+ account_id = fields.Many2one('account.account', string='Account', ondelete='cascade',
+ domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('is_off_balance', '=', False)]",
+ required=True, check_company=True)
+ journal_id = fields.Many2one('account.journal', string='Journal', ondelete='cascade',
+ domain="[('type', '=', 'general'), ('company_id', '=', company_id)]",
+ help="This field is ignored in a bank statement reconciliation.", check_company=True)
+ label = fields.Char(string='Journal Item Label')
+ amount_type = fields.Selection([
+ ('fixed', 'Fixed'),
+ ('percentage', 'Percentage of balance'),
+ ('regex', 'From label'),
+ ], required=True, default='percentage')
+ show_force_tax_included = fields.Boolean(compute='_compute_show_force_tax_included', help='Technical field used to show the force tax included button')
+ force_tax_included = fields.Boolean(string='Tax Included in Price', help='Force the tax to be managed as a price included tax.')
+ amount = fields.Float(string="Float Amount", compute='_compute_float_amount', store=True, help="Technical shortcut to parse the amount to a float")
+ amount_string = fields.Char(string="Amount", default='100', required=True, help="""Value for the amount of the writeoff line
+ * Percentage: Percentage of the balance, between 0 and 100.
+ * Fixed: The fixed value of the writeoff. The amount will count as a debit if it is negative, as a credit if it is positive.
+ * From Label: There is no need for regex delimiter, only the regex is needed. For instance if you want to extract the amount from\nR:9672938 10/07 AX 9415126318 T:5L:NA BRT: 3358,07 C:\nYou could enter\nBRT: ([\d,]+)""")
+ tax_ids = fields.Many2many('account.tax', string='Taxes', ondelete='restrict', check_company=True)
+ analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', ondelete='set null', check_company=True)
+ analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags', check_company=True,
+ relation='account_reconcile_model_analytic_tag_rel')
+
+ @api.onchange('tax_ids')
+ def _onchange_tax_ids(self):
+ # Multiple taxes with force_tax_included results in wrong computation, so we
+ # only allow to set the force_tax_included field if we have one tax selected
+ if len(self.tax_ids) != 1:
+ self.force_tax_included = False
+
+ @api.depends('tax_ids')
+ def _compute_show_force_tax_included(self):
+ for record in self:
+ record.show_force_tax_included = False if len(record.tax_ids) != 1 else True
+
+ @api.onchange('amount_type')
+ def _onchange_amount_type(self):
+ self.amount_string = ''
+ if self.amount_type == 'percentage':
+ self.amount_string = '100'
+ elif self.amount_type == 'regex':
+ self.amount_string = '([\d,]+)'
+
+ @api.depends('amount_string')
+ def _compute_float_amount(self):
+ for record in self:
+ try:
+ record.amount = float(record.amount_string)
+ except ValueError:
+ record.amount = 0
+
+ @api.constrains('amount_string')
+ def _validate_amount(self):
+ for record in self:
+ if record.amount_type == 'fixed' and record.amount == 0:
+ raise UserError(_('The amount is not a number'))
+ if record.amount_type == 'percentage' and not 0 < record.amount:
+ raise UserError(_('The amount is not a percentage'))
+ if record.amount_type == 'regex':
+ try:
+ re.compile(record.amount_string)
+ except re.error:
+ raise UserError(_('The regex is not valid'))
+
+
+class AccountReconcileModel(models.Model):
+ _name = 'account.reconcile.model'
+ _description = 'Preset to create journal entries during a invoices and payments matching'
+ _order = 'sequence, id'
+ _check_company_auto = True
+
+ # Base fields.
+ active = fields.Boolean(default=True)
+ name = fields.Char(string='Name', required=True)
+ sequence = fields.Integer(required=True, default=10)
+ company_id = fields.Many2one(
+ comodel_name='res.company',
+ string='Company', required=True, readonly=True,
+ default=lambda self: self.env.company)
+
+ rule_type = fields.Selection(selection=[
+ ('writeoff_button', 'Manually create a write-off on clicked button'),
+ ('writeoff_suggestion', 'Suggest counterpart values'),
+ ('invoice_matching', 'Match existing invoices/bills'),
+ ], string='Type', default='writeoff_button', required=True)
+ auto_reconcile = fields.Boolean(string='Auto-validate',
+ help='Validate the statement line automatically (reconciliation based on your rule).')
+ to_check = fields.Boolean(string='To Check', default=False, help='This matching rule is used when the user is not certain of all the information of the counterpart.')
+ matching_order = fields.Selection(
+ selection=[
+ ('old_first', 'Oldest first'),
+ ('new_first', 'Newest first'),
+ ],
+ required=True,
+ default='old_first',
+ )
+
+ # ===== Conditions =====
+ match_text_location_label = fields.Boolean(
+ default=True,
+ help="Search in the Statement's Label to find the Invoice/Payment's reference",
+ )
+ match_text_location_note = fields.Boolean(
+ default=False,
+ help="Search in the Statement's Note to find the Invoice/Payment's reference",
+ )
+ match_text_location_reference = fields.Boolean(
+ default=False,
+ help="Search in the Statement's Reference to find the Invoice/Payment's reference",
+ )
+ match_journal_ids = fields.Many2many('account.journal', string='Journals',
+ domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]",
+ check_company=True,
+ help='The reconciliation model will only be available from the selected journals.')
+ match_nature = fields.Selection(selection=[
+ ('amount_received', 'Amount Received'),
+ ('amount_paid', 'Amount Paid'),
+ ('both', 'Amount Paid/Received')
+ ], string='Amount Nature', required=True, default='both',
+ help='''The reconciliation model will only be applied to the selected transaction type:
+ * Amount Received: Only applied when receiving an amount.
+ * Amount Paid: Only applied when paying an amount.
+ * Amount Paid/Received: Applied in both cases.''')
+ match_amount = fields.Selection(selection=[
+ ('lower', 'Is Lower Than'),
+ ('greater', 'Is Greater Than'),
+ ('between', 'Is Between'),
+ ], string='Amount',
+ help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
+ match_amount_min = fields.Float(string='Amount Min Parameter')
+ match_amount_max = fields.Float(string='Amount Max Parameter')
+ match_label = fields.Selection(selection=[
+ ('contains', 'Contains'),
+ ('not_contains', 'Not Contains'),
+ ('match_regex', 'Match Regex'),
+ ], string='Label', help='''The reconciliation model will only be applied when the label:
+ * Contains: The proposition label must contains this string (case insensitive).
+ * Not Contains: Negation of "Contains".
+ * Match Regex: Define your own regular expression.''')
+ match_label_param = fields.Char(string='Label Parameter')
+ match_note = fields.Selection(selection=[
+ ('contains', 'Contains'),
+ ('not_contains', 'Not Contains'),
+ ('match_regex', 'Match Regex'),
+ ], string='Note', help='''The reconciliation model will only be applied when the note:
+ * Contains: The proposition note must contains this string (case insensitive).
+ * Not Contains: Negation of "Contains".
+ * Match Regex: Define your own regular expression.''')
+ match_note_param = fields.Char(string='Note Parameter')
+ match_transaction_type = fields.Selection(selection=[
+ ('contains', 'Contains'),
+ ('not_contains', 'Not Contains'),
+ ('match_regex', 'Match Regex'),
+ ], string='Transaction Type', help='''The reconciliation model will only be applied when the transaction type:
+ * Contains: The proposition transaction type must contains this string (case insensitive).
+ * Not Contains: Negation of "Contains".
+ * Match Regex: Define your own regular expression.''')
+ match_transaction_type_param = fields.Char(string='Transaction Type Parameter')
+ match_same_currency = fields.Boolean(string='Same Currency Matching', default=True,
+ help='Restrict to propositions having the same currency as the statement line.')
+ match_total_amount = fields.Boolean(string='Amount Matching', default=True,
+ help='The sum of total residual amount propositions matches the statement line amount.')
+ match_total_amount_param = fields.Float(string='Amount Matching %', default=100,
+ help='The sum of total residual amount propositions matches the statement line amount under this percentage.')
+ match_partner = fields.Boolean(string='Partner Is Set',
+ help='The reconciliation model will only be applied when a customer/vendor is set.')
+ match_partner_ids = fields.Many2many('res.partner', string='Restrict Partners to',
+ help='The reconciliation model will only be applied to the selected customers/vendors.')
+ match_partner_category_ids = fields.Many2many('res.partner.category', string='Restrict Partner Categories to',
+ help='The reconciliation model will only be applied to the selected customer/vendor categories.')
+
+ line_ids = fields.One2many('account.reconcile.model.line', 'model_id')
+ partner_mapping_line_ids = fields.One2many(string="Partner Mapping Lines",
+ comodel_name='account.reconcile.model.partner.mapping',
+ inverse_name='model_id',
+ help="The mapping uses regular expressions.\n"
+ "- To Match the text at the beginning of the line (in label or notes), simply fill in your text.\n"
+ "- To Match the text anywhere (in label or notes), put your text between .*\n"
+ " e.g: .*N°48748 abc123.*")
+
+ past_months_limit = fields.Integer(string="Past Months Limit", default=18, help="Number of months in the past to consider entries from when applying this model.")
+
+ decimal_separator = fields.Char(default=lambda self: self.env['res.lang']._lang_get(self.env.user.lang).decimal_point, help="Every character that is nor a digit nor this separator will be removed from the matching string")
+ show_decimal_separator = fields.Boolean(compute='_compute_show_decimal_separator', help="Technical field to decide if we should show the decimal separator for the regex matching field.")
+ number_entries = fields.Integer(string='Number of entries related to this model', compute='_compute_number_entries')
+
+ def action_reconcile_stat(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
+ self._cr.execute('''
+ SELECT ARRAY_AGG(DISTINCT move_id)
+ FROM account_move_line
+ WHERE reconcile_model_id = %s
+ ''', [self.id])
+ action.update({
+ 'context': {},
+ 'domain': [('id', 'in', self._cr.fetchone()[0])],
+ 'help': """<p class="o_view_nocontent_empty_folder">{}</p>""".format(_('This reconciliation model has created no entry so far')),
+ })
+ return action
+
+ def _compute_number_entries(self):
+ data = self.env['account.move.line'].read_group([('reconcile_model_id', 'in', self.ids)], ['reconcile_model_id'], 'reconcile_model_id')
+ mapped_data = dict([(d['reconcile_model_id'][0], d['reconcile_model_id_count']) for d in data])
+ for model in self:
+ model.number_entries = mapped_data.get(model.id, 0)
+
+ @api.depends('line_ids.amount_type')
+ def _compute_show_decimal_separator(self):
+ for record in self:
+ record.show_decimal_separator = any(l.amount_type == 'regex' for l in record.line_ids)
+
+ @api.onchange('match_total_amount_param')
+ def _onchange_match_total_amount_param(self):
+ if self.match_total_amount_param < 0 or self.match_total_amount_param > 100:
+ self.match_total_amount_param = min(max(0, self.match_total_amount_param), 100)
+
+ ####################################################
+ # RECONCILIATION PROCESS
+ ####################################################
+
+ def _get_taxes_move_lines_dict(self, tax, base_line_dict):
+ ''' Get move.lines dict (to be passed to the create()) corresponding to a tax.
+ :param tax: An account.tax record.
+ :param base_line_dict: A dict representing the move.line containing the base amount.
+ :return: A list of dict representing move.lines to be created corresponding to the tax.
+ '''
+ self.ensure_one()
+ balance = base_line_dict['balance']
+ tax_type = tax.type_tax_use
+ is_refund = (tax_type == 'sale' and balance < 0) or (tax_type == 'purchase' and balance > 0)
+
+ res = tax.compute_all(balance, is_refund=is_refund)
+
+ if (tax_type == 'sale' and not is_refund) or (tax_type == 'purchase' and is_refund):
+ base_tags = self.env['account.account.tag'].browse(res['base_tags'])
+ res['base_tags'] = self.env['account.move.line']._revert_signed_tags(base_tags).ids
+
+ for tax_result in res['taxes']:
+ tax_tags = self.env['account.account.tag'].browse(tax_result['tag_ids'])
+ tax_result['tag_ids'] = self.env['account.move.line']._revert_signed_tags(tax_tags).ids
+
+ new_aml_dicts = []
+ for tax_res in res['taxes']:
+ tax = self.env['account.tax'].browse(tax_res['id'])
+ balance = tax_res['amount']
+
+ new_aml_dicts.append({
+ 'account_id': tax_res['account_id'] or base_line_dict['account_id'],
+ 'name': tax_res['name'],
+ 'partner_id': base_line_dict.get('partner_id'),
+ 'balance': balance,
+ 'debit': balance > 0 and balance or 0,
+ 'credit': balance < 0 and -balance or 0,
+ 'analytic_account_id': tax.analytic and base_line_dict['analytic_account_id'],
+ 'analytic_tag_ids': tax.analytic and base_line_dict['analytic_tag_ids'],
+ 'tax_exigible': tax_res['tax_exigibility'],
+ 'tax_repartition_line_id': tax_res['tax_repartition_line_id'],
+ 'tax_ids': [(6, 0, tax_res['tax_ids'])],
+ 'tax_tag_ids': [(6, 0, tax_res['tag_ids'])],
+ 'currency_id': False,
+ 'reconcile_model_id': self.id,
+ })
+
+ # Handle price included taxes.
+ base_balance = tax_res['base']
+ base_line_dict.update({
+ 'balance': base_balance,
+ 'debit': base_balance > 0 and base_balance or 0,
+ 'credit': base_balance < 0 and -base_balance or 0,
+ })
+
+ base_line_dict['tax_tag_ids'] = [(6, 0, res['base_tags'])]
+ return new_aml_dicts
+
+ def _get_write_off_move_lines_dict(self, st_line, residual_balance):
+ ''' Get move.lines dict (to be passed to the create()) corresponding to the reconciliation model's write-off lines.
+ :param st_line: An account.bank.statement.line record.(possibly empty, if performing manual reconciliation)
+ :param residual_balance: The residual balance of the statement line.
+ :return: A list of dict representing move.lines to be created corresponding to the write-off lines.
+ '''
+ self.ensure_one()
+
+ if self.rule_type == 'invoice_matching' and (not self.match_total_amount or (self.match_total_amount_param == 100)):
+ return []
+
+ lines_vals_list = []
+
+ for line in self.line_ids:
+ currency_id = st_line.currency_id or st_line.journal_id.currency_id or self.company_id.currency_id
+ if not line.account_id or currency_id.is_zero(residual_balance):
+ return []
+
+ if line.amount_type == 'percentage':
+ balance = residual_balance * (line.amount / 100.0)
+ elif line.amount_type == "regex":
+ match = re.search(line.amount_string, st_line.payment_ref)
+ if match:
+ sign = 1 if residual_balance > 0.0 else -1
+ try:
+ extracted_balance = float(re.sub(r'\D' + self.decimal_separator, '', match.group(1)).replace(self.decimal_separator, '.'))
+ balance = copysign(extracted_balance * sign, residual_balance)
+ except ValueError:
+ balance = 0
+ else:
+ balance = 0
+ else:
+ balance = line.amount * (1 if residual_balance > 0.0 else -1)
+
+ writeoff_line = {
+ 'name': line.label or st_line.payment_ref,
+ 'balance': balance,
+ 'debit': balance > 0 and balance or 0,
+ 'credit': balance < 0 and -balance or 0,
+ 'account_id': line.account_id.id,
+ 'currency_id': False,
+ 'analytic_account_id': line.analytic_account_id.id,
+ 'analytic_tag_ids': [(6, 0, line.analytic_tag_ids.ids)],
+ 'reconcile_model_id': self.id,
+ }
+ lines_vals_list.append(writeoff_line)
+
+ residual_balance -= balance
+
+ if line.tax_ids:
+ writeoff_line['tax_ids'] = [(6, None, line.tax_ids.ids)]
+ tax = line.tax_ids
+ # Multiple taxes with force_tax_included results in wrong computation, so we
+ # only allow to set the force_tax_included field if we have one tax selected
+ if line.force_tax_included:
+ tax = tax[0].with_context(force_price_include=True)
+ tax_vals_list = self._get_taxes_move_lines_dict(tax, writeoff_line)
+ lines_vals_list += tax_vals_list
+ if not line.force_tax_included:
+ for tax_line in tax_vals_list:
+ residual_balance -= tax_line['balance']
+
+ return lines_vals_list
+
+ def _prepare_reconciliation(self, st_line, aml_ids=[], partner=None):
+ ''' Prepare the reconciliation of the statement line with some counterpart line but
+ also with some auto-generated write-off lines.
+
+ The complexity of this method comes from the fact the reconciliation will be soft meaning
+ it will be done only if the reconciliation will not trigger an error.
+ For example, the reconciliation will be skipped if we need to create an open balance but we
+ don't have a partner to get the receivable/payable account.
+
+ This method works in two major steps. First, simulate the reconciliation of the account.move.line.
+ Then, add some write-off lines depending the rule's fields.
+
+ :param st_line: An account.bank.statement.line record.
+ :param aml_ids: The ids of some account.move.line to reconcile.
+ :param partner: An optional res.partner record. If not specified, fallback on the statement line's partner.
+ :return: A list of dictionary to be passed to the account.bank.statement.line's 'reconcile' method.
+ '''
+ self.ensure_one()
+ liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
+
+ if st_line.to_check:
+ st_line_residual = -liquidity_lines.balance
+ elif suspense_lines.account_id.reconcile:
+ st_line_residual = sum(suspense_lines.mapped('amount_residual'))
+ else:
+ st_line_residual = sum(suspense_lines.mapped('balance'))
+
+ partner = partner or st_line.partner_id
+
+ has_full_write_off= any(rec_mod_line.amount == 100.0 for rec_mod_line in self.line_ids)
+
+ lines_vals_list = []
+ amls = self.env['account.move.line'].browse(aml_ids)
+ st_line_residual_before = st_line_residual
+ aml_total_residual = 0
+ for aml in amls:
+ aml_total_residual += aml.amount_residual
+
+ if aml.balance * st_line_residual > 0:
+ # Meaning they have the same signs, so they can't be reconciled together
+ assigned_balance = -aml.amount_residual
+ elif has_full_write_off:
+ assigned_balance = -aml.amount_residual
+ st_line_residual -= min(-aml.amount_residual, st_line_residual, key=abs)
+ else:
+ assigned_balance = min(-aml.amount_residual, st_line_residual, key=abs)
+ st_line_residual -= assigned_balance
+
+ lines_vals_list.append({
+ 'id': aml.id,
+ 'balance': assigned_balance,
+ 'currency_id': st_line.move_id.company_id.currency_id.id,
+ })
+
+ write_off_amount = max(aml_total_residual, -st_line_residual_before, key=abs) + st_line_residual_before + st_line_residual
+
+ reconciliation_overview, open_balance_vals = st_line._prepare_reconciliation(lines_vals_list)
+
+ writeoff_vals_list = self._get_write_off_move_lines_dict(st_line, write_off_amount)
+
+ for line_vals in writeoff_vals_list:
+ st_line_residual -= st_line.company_currency_id.round(line_vals['balance'])
+
+ # Check we have enough information to create an open balance.
+ if open_balance_vals and not open_balance_vals.get('account_id'):
+ return []
+
+ return lines_vals_list + writeoff_vals_list
+
+ ####################################################
+ # RECONCILIATION CRITERIA
+ ####################################################
+
+ def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None):
+ ''' Apply criteria to get candidates for all reconciliation models.
+
+ This function is called in enterprise by the reconciliation widget to match
+ the statement lines with the available candidates (using the reconciliation models).
+
+ :param st_lines: Account.bank.statement.lines recordset.
+ :param excluded_ids: Account.move.lines to exclude.
+ :param partner_map: Dict mapping each line with new partner eventually.
+ :return: A dict mapping each statement line id with:
+ * aml_ids: A list of account.move.line ids.
+ * model: An account.reconcile.model record (optional).
+ * status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off must be
+ applied on the statement line.
+ '''
+ # This functions uses SQL to compute its results. We need to flush before doing anything more.
+ for model_name in ('account.bank.statement', 'account.bank.statement.line', 'account.move', 'account.move.line', 'res.company', 'account.journal', 'account.account'):
+ self.env[model_name].flush(self.env[model_name]._fields)
+
+ results = {line.id: {'aml_ids': []} for line in st_lines}
+
+ available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button').sorted()
+ aml_ids_to_exclude = set() # Keep track of already processed amls.
+ reconciled_amls_ids = set() # Keep track of already reconciled amls.
+
+ # First associate with each rec models all the statement lines for which it is applicable
+ lines_with_partner_per_model = defaultdict(lambda: [])
+ for st_line in st_lines:
+
+ # Statement lines created in old versions could have a residual amount of zero. In that case, don't try to
+ # match anything.
+ if not st_line.amount_residual:
+ continue
+
+ mapped_partner = (partner_map and partner_map.get(st_line.id) and self.env['res.partner'].browse(partner_map[st_line.id])) or st_line.partner_id
+
+ for rec_model in available_models:
+ partner = mapped_partner or rec_model._get_partner_from_mapping(st_line)
+
+ if rec_model._is_applicable_for(st_line, partner):
+ lines_with_partner_per_model[rec_model].append((st_line, partner))
+
+ # Execute only one SQL query for each model (for performance)
+ matched_lines = self.env['account.bank.statement.line']
+ for rec_model in available_models:
+
+ # We filter the lines for this model, in case a previous one has already found something for them
+ filtered_st_lines_with_partner = [x for x in lines_with_partner_per_model[rec_model] if x[0] not in matched_lines]
+
+ if not filtered_st_lines_with_partner:
+ # No unreconciled statement line for this model
+ continue
+
+ all_model_candidates = rec_model._get_candidates(filtered_st_lines_with_partner, excluded_ids)
+
+ for st_line, partner in filtered_st_lines_with_partner:
+ candidates = all_model_candidates[st_line.id]
+ if candidates:
+ model_rslt, new_reconciled_aml_ids, new_treated_aml_ids = rec_model._get_rule_result(st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner)
+
+ if model_rslt:
+ # We inject the selected partner (possibly coming from the rec model)
+ model_rslt['partner']= partner
+
+ results[st_line.id] = model_rslt
+ reconciled_amls_ids |= new_reconciled_aml_ids
+ aml_ids_to_exclude |= new_treated_aml_ids
+ matched_lines += st_line
+
+ return results
+
+ def _is_applicable_for(self, st_line, partner):
+ """ Returns true iff this reconciliation model can be used to search for matches
+ for the provided statement line and partner.
+ """
+ self.ensure_one()
+
+ # Filter on journals, amount nature, amount and partners
+ # All the conditions defined in this block are non-match conditions.
+ if ((self.match_journal_ids and st_line.move_id.journal_id not in self.match_journal_ids)
+ or (self.match_nature == 'amount_received' and st_line.amount < 0)
+ or (self.match_nature == 'amount_paid' and st_line.amount > 0)
+ or (self.match_amount == 'lower' and abs(st_line.amount) >= self.match_amount_max)
+ or (self.match_amount == 'greater' and abs(st_line.amount) <= self.match_amount_min)
+ or (self.match_amount == 'between' and (abs(st_line.amount) > self.match_amount_max or abs(st_line.amount) < self.match_amount_min))
+ or (self.match_partner and not partner)
+ or (self.match_partner and self.match_partner_ids and partner not in self.match_partner_ids)
+ or (self.match_partner and self.match_partner_category_ids and partner.category_id not in self.match_partner_category_ids)
+ ):
+ return False
+
+ # Filter on label, note and transaction_type
+ for record, rule_field, record_field in [(st_line, 'label', 'payment_ref'), (st_line.move_id, 'note', 'narration'), (st_line, 'transaction_type', 'transaction_type')]:
+ rule_term = (self['match_' + rule_field + '_param'] or '').lower()
+ record_term = (record[record_field] or '').lower()
+
+ # This defines non-match conditions
+ if ((self['match_' + rule_field] == 'contains' and rule_term not in record_term)
+ or (self['match_' + rule_field] == 'not_contains' and rule_term in record_term)
+ or (self['match_' + rule_field] == 'match_regex' and not re.match(rule_term, record_term))
+ ):
+ return False
+
+ return True
+
+ def _get_candidates(self, st_lines_with_partner, excluded_ids):
+ """ Returns the match candidates for this rule, with respect to the provided parameters.
+
+ :param st_lines_with_partner: A list of tuples (statement_line, partner),
+ associating each statement line to treate with
+ the corresponding partner, given by the partner map
+ :param excluded_ids: a set containing the ids of the amls to ignore during the search
+ (because they already been matched by another rule)
+ """
+ self.ensure_one()
+
+ treatment_map = {
+ 'invoice_matching': lambda x: x._get_invoice_matching_query(st_lines_with_partner, excluded_ids),
+ 'writeoff_suggestion': lambda x: x._get_writeoff_suggestion_query(st_lines_with_partner, excluded_ids),
+ }
+
+ query_generator = treatment_map[self.rule_type]
+ query, params = query_generator(self)
+ self._cr.execute(query, params)
+
+ rslt = defaultdict(lambda: [])
+ for candidate_dict in self._cr.dictfetchall():
+ rslt[candidate_dict['id']].append(candidate_dict)
+
+ return rslt
+
+ def _get_invoice_matching_query(self, st_lines_with_partner, excluded_ids):
+ ''' Returns the query applying the current invoice_matching reconciliation
+ model to the provided statement lines.
+
+ :param st_lines_with_partner: A list of tuples (statement_line, partner),
+ associating each statement line to treate with
+ the corresponding partner, given by the partner map
+ :param excluded_ids: Account.move.lines to exclude.
+ :return: (query, params)
+ '''
+ self.ensure_one()
+ if self.rule_type != 'invoice_matching':
+ raise UserError(_('Programmation Error: Can\'t call _get_invoice_matching_query() for different rules than \'invoice_matching\''))
+
+ unaccent = get_unaccent_wrapper(self._cr)
+
+ # N.B: 'communication_flag' is there to distinguish invoice matching through the number/reference
+ # (higher priority) from invoice matching using the partner (lower priority).
+ query = r'''
+ SELECT
+ st_line.id AS id,
+ aml.id AS aml_id,
+ aml.currency_id AS aml_currency_id,
+ aml.date_maturity AS aml_date_maturity,
+ aml.amount_residual AS aml_amount_residual,
+ aml.amount_residual_currency AS aml_amount_residual_currency,
+ ''' + self._get_select_communication_flag() + r''' AS communication_flag,
+ ''' + self._get_select_payment_reference_flag() + r''' AS payment_reference_flag
+ FROM account_bank_statement_line st_line
+ JOIN account_move st_line_move ON st_line_move.id = st_line.move_id
+ JOIN res_company company ON company.id = st_line_move.company_id
+ , account_move_line aml
+ LEFT JOIN account_move move ON move.id = aml.move_id AND move.state = 'posted'
+ LEFT JOIN account_account account ON account.id = aml.account_id
+ LEFT JOIN res_partner aml_partner ON aml.partner_id = aml_partner.id
+ LEFT JOIN account_payment payment ON payment.move_id = move.id
+ WHERE
+ aml.company_id = st_line_move.company_id
+ AND move.state = 'posted'
+ AND account.reconcile IS TRUE
+ AND aml.reconciled IS FALSE
+ '''
+
+ # Add conditions to handle each of the statement lines we want to match
+ st_lines_queries = []
+ for st_line, partner in st_lines_with_partner:
+ # In case we don't have any partner for this line, we try assigning one with the rule mapping
+ if st_line.amount > 0:
+ st_line_subquery = r"aml.balance > 0"
+ else:
+ st_line_subquery = r"aml.balance < 0"
+
+ if self.match_same_currency:
+ st_line_subquery += r" AND COALESCE(aml.currency_id, company.currency_id) = %s" % (st_line.foreign_currency_id.id or st_line.move_id.currency_id.id)
+
+ if partner:
+ st_line_subquery += r" AND aml.partner_id = %s" % partner.id
+ else:
+ st_line_subquery += r"""
+ AND
+ (
+ substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*') != ''
+ AND
+ (
+ (""" + self._get_select_communication_flag() + """)
+ OR
+ (""" + self._get_select_payment_reference_flag() + """)
+ )
+ )
+ OR
+ (
+ /* We also match statement lines without partners with amls
+ whose partner's name's parts (splitting on space) are all present
+ within the payment_ref, in any order, with any characters between them. */
+
+ aml_partner.name IS NOT NULL
+ AND """ + unaccent("st_line.payment_ref") + r""" ~* ('^' || (
+ SELECT string_agg(concat('(?=.*\m', chunk[1], '\M)'), '')
+ FROM regexp_matches(""" + unaccent("aml_partner.name") + r""", '\w{3,}', 'g') AS chunk
+ ))
+ )
+ """
+
+ st_lines_queries.append(r"st_line.id = %s AND (%s)" % (st_line.id, st_line_subquery))
+
+ query += r" AND (%s) " % " OR ".join(st_lines_queries)
+
+ params = {}
+
+ # If this reconciliation model defines a past_months_limit, we add a condition
+ # to the query to only search on move lines that are younger than this limit.
+ if self.past_months_limit:
+ date_limit = fields.Date.context_today(self) - relativedelta(months=self.past_months_limit)
+ query += "AND aml.date >= %(aml_date_limit)s"
+ params['aml_date_limit'] = date_limit
+
+ # Filter out excluded account.move.line.
+ if excluded_ids:
+ query += 'AND aml.id NOT IN %(excluded_aml_ids)s'
+ params['excluded_aml_ids'] = tuple(excluded_ids)
+
+ if self.matching_order == 'new_first':
+ query += ' ORDER BY aml_date_maturity DESC, aml_id DESC'
+ else:
+ query += ' ORDER BY aml_date_maturity ASC, aml_id ASC'
+
+ return query, params
+
+ def _get_select_communication_flag(self):
+ self.ensure_one()
+ # Determine a matching or not with the statement line communication using the aml.name, move.name or move.ref.
+ st_ref_list = []
+ if self.match_text_location_label:
+ st_ref_list += ['st_line.payment_ref']
+ if self.match_text_location_note:
+ st_ref_list += ['st_line_move.narration']
+ if self.match_text_location_reference:
+ st_ref_list += ['st_line_move.ref']
+
+ st_ref = " || ' ' || ".join(
+ "COALESCE(%s, '')" % st_ref_name
+ for st_ref_name in st_ref_list
+ )
+ if not st_ref:
+ return "FALSE"
+
+ statement_compare = r"""(
+ {move_field} IS NOT NULL AND substring(REGEXP_REPLACE({move_field}, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*') != ''
+ AND (
+ regexp_split_to_array(substring(REGEXP_REPLACE({move_field}, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*'),'\s+')
+ && regexp_split_to_array(substring(REGEXP_REPLACE({st_ref}, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*'), '\s+')
+ )
+ )"""
+ return " OR ".join(
+ statement_compare.format(move_field=field, st_ref=st_ref)
+ for field in ['aml.name', 'move.name', 'move.ref']
+ )
+
+ def _get_select_payment_reference_flag(self):
+ # Determine a matching or not with the statement line communication using the move.payment_reference.
+ st_ref_list = []
+ if self.match_text_location_label:
+ st_ref_list += ['st_line.payment_ref']
+ if self.match_text_location_note:
+ st_ref_list += ['st_line_move.narration']
+ if self.match_text_location_reference:
+ st_ref_list += ['st_line_move.ref']
+ if not st_ref_list:
+ return "FALSE"
+
+ # payment_reference is not used on account.move for payments; ref is used instead
+ return r'''((move.payment_reference IS NOT NULL OR (payment.id IS NOT NULL AND move.ref IS NOT NULL)) AND ({}))'''.format(
+ ' OR '.join(
+ rf"regexp_replace(CASE WHEN payment.id IS NULL THEN move.payment_reference ELSE move.ref END, '\s+', '', 'g') = regexp_replace({st_ref}, '\s+', '', 'g')"
+ for st_ref in st_ref_list
+ )
+ )
+
+ def _get_partner_from_mapping(self, st_line):
+ """Find partner with mapping defined on model.
+
+ For invoice matching rules, matches the statement line against each
+ regex defined in partner mapping, and returns the partner corresponding
+ to the first one matching.
+
+ :param st_line (Model<account.bank.statement.line>):
+ The statement line that needs a partner to be found
+ :return Model<res.partner>:
+ The partner found from the mapping. Can be empty an empty recordset
+ if there was nothing found from the mapping or if the function is
+ not applicable.
+ """
+ self.ensure_one()
+
+ if self.rule_type not in ('invoice_matching', 'writeoff_suggestion'):
+ return self.env['res.partner']
+
+ for partner_mapping in self.partner_mapping_line_ids:
+ match_payment_ref = re.match(partner_mapping.payment_ref_regex, st_line.payment_ref) if partner_mapping.payment_ref_regex else True
+ match_narration = re.match(partner_mapping.narration_regex, st_line.narration or '') if partner_mapping.narration_regex else True
+
+ if match_payment_ref and match_narration:
+ return partner_mapping.partner_id
+ return self.env['res.partner']
+
+ def _get_writeoff_suggestion_query(self, st_lines_with_partner, excluded_ids=None):
+ ''' Returns the query applying the current writeoff_suggestion reconciliation
+ model to the provided statement lines.
+
+ :param st_lines_with_partner: A list of tuples (statement_line, partner),
+ associating each statement line to treate with
+ the corresponding partner, given by the partner map
+ :param excluded_ids: Account.move.lines to exclude.
+ :return: (query, params)
+ '''
+ self.ensure_one()
+
+ if self.rule_type != 'writeoff_suggestion':
+ raise UserError(_("Programmation Error: Can't call _get_writeoff_suggestion_query() for different rules than 'writeoff_suggestion'"))
+
+ query = '''
+ SELECT
+ st_line.id AS id
+ FROM account_bank_statement_line st_line
+ WHERE st_line.id IN %(st_line_ids)s
+ '''
+ params = {
+ 'st_line_ids': tuple(st_line.id for (st_line, partner) in st_lines_with_partner),
+ }
+
+ return query, params
+
+ def _get_rule_result(self, st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner_map):
+ """ Get the result of a rule from the list of available candidates, depending on the
+ other reconciliations performed by previous rules.
+ """
+ self.ensure_one()
+
+ if self.rule_type == 'invoice_matching':
+ return self._get_invoice_matching_rule_result(st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner_map)
+ elif self.rule_type == 'writeoff_suggestion':
+ return self._get_writeoff_suggestion_rule_result(st_line, partner_map), set(), set()
+ else:
+ return None, set(), set()
+
+ def _get_invoice_matching_rule_result(self, st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner):
+ new_reconciled_aml_ids = set()
+ new_treated_aml_ids = set()
+ candidates, priorities = self._filter_candidates(candidates, aml_ids_to_exclude, reconciled_amls_ids)
+
+ # Special case: the amounts are the same, submit the line directly.
+ st_line_currency = st_line.foreign_currency_id or st_line.currency_id
+ candidate_currencies = set(candidate['aml_currency_id'] or st_line.company_id.currency_id.id for candidate in candidates)
+ if candidate_currencies == {st_line_currency.id}:
+ for candidate in candidates:
+ residual_amount = candidate['aml_currency_id'] and candidate['aml_amount_residual_currency'] or candidate['aml_amount_residual']
+ if st_line_currency.is_zero(residual_amount + st_line.amount_residual):
+ candidates, priorities = self._filter_candidates([candidate], aml_ids_to_exclude, reconciled_amls_ids)
+ break
+
+ # We check the amount criteria of the reconciliation model, and select the
+ # candidates if they pass the verification. Candidates from the first priority
+ # level (even already selected) bypass this check, and are selected anyway.
+ disable_bypass = self.env['ir.config_parameter'].sudo().get_param('account.disable_rec_models_bypass')
+ if (not disable_bypass and priorities & {1,2}) or self._check_rule_propositions(st_line, candidates):
+ rslt = {
+ 'model': self,
+ 'aml_ids': [candidate['aml_id'] for candidate in candidates],
+ }
+ new_treated_aml_ids = set(rslt['aml_ids'])
+
+ # Create write-off lines.
+ lines_vals_list = self._prepare_reconciliation(st_line, aml_ids=rslt['aml_ids'], partner=partner)
+
+ # A write-off must be applied if there are some 'new' lines to propose.
+ write_off_lines_vals = list(filter(lambda x: 'id' not in x, lines_vals_list))
+ if not lines_vals_list or write_off_lines_vals:
+ rslt['status'] = 'write_off'
+ rslt['write_off_vals'] = write_off_lines_vals
+
+ # Process auto-reconciliation. We only do that for the first two priorities, if they are not matched elsewhere.
+ if lines_vals_list and priorities & {1, 3} and self.auto_reconcile:
+ if not st_line.partner_id and partner:
+ st_line.partner_id = partner
+
+ st_line.reconcile(lines_vals_list)
+ rslt['status'] = 'reconciled'
+ rslt['reconciled_lines'] = st_line.line_ids
+ new_reconciled_aml_ids = new_treated_aml_ids
+ else:
+ rslt = None
+
+ return rslt, new_reconciled_aml_ids, new_treated_aml_ids
+
+ def _check_rule_propositions(self, statement_line, candidates):
+ ''' Check restrictions that can't be handled for each move.line separately.
+ /!\ Only used by models having a type equals to 'invoice_matching'.
+ :param statement_line: An account.bank.statement.line record.
+ :param candidates: Fetched account.move.lines from query (dict).
+ :return: True if the reconciliation propositions are accepted. False otherwise.
+ '''
+ if not self.match_total_amount:
+ return True
+ if not candidates:
+ return False
+
+ reconciliation_overview, open_balance_vals = statement_line._prepare_reconciliation([{
+ 'currency_id': aml['aml_currency_id'],
+ 'amount_residual': aml['aml_amount_residual'],
+ 'amount_residual_currency': aml['aml_amount_residual_currency'],
+ } for aml in candidates])
+
+ # Match total residual amount.
+ line_currency = statement_line.foreign_currency_id or statement_line.currency_id
+ line_residual = statement_line.amount_residual
+ line_residual_after_reconciliation = line_residual
+
+ for reconciliation_vals in reconciliation_overview:
+ line_vals = reconciliation_vals['line_vals']
+ if line_vals['currency_id']:
+ line_residual_after_reconciliation -= line_vals['amount_currency']
+ else:
+ line_residual_after_reconciliation -= line_vals['debit'] - line_vals['credit']
+
+ # Statement line amount is equal to the total residual.
+ if line_currency.is_zero(line_residual_after_reconciliation):
+ return True
+ residual_difference = line_residual - line_residual_after_reconciliation
+ reconciled_percentage = 100 - abs(line_residual_after_reconciliation) / abs(residual_difference) * 100 if (residual_difference != 0) else 0
+ return reconciled_percentage >= self.match_total_amount_param
+
+ def _filter_candidates(self, candidates, aml_ids_to_exclude, reconciled_amls_ids):
+ """ Sorts reconciliation candidates by priority and filters them so that only
+ the most prioritary are kept.
+ """
+ candidates_by_priority = self._sort_reconciliation_candidates_by_priority(candidates, aml_ids_to_exclude, reconciled_amls_ids)
+
+ # This can happen if the candidates were already reconciled at this point
+ if not candidates_by_priority:
+ return [], set()
+
+ max_priority = min(candidates_by_priority.keys())
+
+ filtered_candidates = candidates_by_priority[max_priority]
+ filtered_priorities = {max_priority,}
+
+ if max_priority in (1, 3, 5):
+ # We also keep the already proposed values of the same priority level
+ proposed_priority = max_priority + 1
+ filtered_candidates += candidates_by_priority[proposed_priority]
+ if candidates_by_priority[proposed_priority]:
+ filtered_priorities.add(proposed_priority)
+
+ return filtered_candidates, filtered_priorities
+
+ def _sort_reconciliation_candidates_by_priority(self, candidates, already_proposed_aml_ids, already_reconciled_aml_ids):
+ """ Sorts the provided candidates and returns a mapping of candidates by
+ priority (1 being the highest).
+
+ The priorities are defined as follows:
+
+ 1: payment_reference_flag is true, so the move's payment_reference
+ field matches the statement line's.
+
+ 2: Same as 1, but the candidates have already been proposed for a previous statement line
+
+ 3: communication_flag is true, so either the move's ref, move's name or
+ aml's name match the statement line's payment reference.
+
+ 4: Same as 3, but the candidates have already been proposed for a previous statement line
+
+ 5: candidates proposed by the query, but no match with the statement
+ line's payment ref could be found.
+
+ 6: Same as 5, but the candidates have already been proposed for a previous statement line
+ """
+ candidates_by_priority = defaultdict(lambda: [])
+
+ for candidate in filter(lambda x: x['aml_id'] not in already_reconciled_aml_ids, candidates):
+
+ if candidate['payment_reference_flag']:
+ priority = 1
+ elif candidate['communication_flag']:
+ priority = 3
+ else:
+ priority = 5
+
+ if candidate['aml_id'] in already_proposed_aml_ids:
+ # So, priorities 2, 4 and 6 are created here
+ priority += 1
+
+ candidates_by_priority[priority].append(candidate)
+
+ return candidates_by_priority
+
+ def _get_writeoff_suggestion_rule_result(self, st_line, partner):
+ # Create write-off lines.
+ lines_vals_list = self._prepare_reconciliation(st_line, partner=partner)
+
+ rslt = {
+ 'model': self,
+ 'status': 'write_off',
+ 'aml_ids': [],
+ 'write_off_vals': lines_vals_list,
+ }
+
+ # Process auto-reconciliation.
+ if lines_vals_list and self.auto_reconcile:
+ if not st_line.partner_id and partner:
+ st_line.partner_id = partner
+
+ st_line.reconcile(lines_vals_list)
+ rslt['status'] = 'reconciled'
+ rslt['reconciled_lines'] = st_line.line_ids
+
+ return rslt
diff --git a/addons/account/models/account_tax.py b/addons/account/models/account_tax.py
new file mode 100644
index 00000000..3fc75773
--- /dev/null
+++ b/addons/account/models/account_tax.py
@@ -0,0 +1,685 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _
+from odoo.osv import expression
+from odoo.tools.float_utils import float_round as round
+from odoo.exceptions import UserError, ValidationError
+
+import math
+import logging
+
+
+TYPE_TAX_USE = [
+ ('sale', 'Sales'),
+ ('purchase', 'Purchases'),
+ ('none', 'None'),
+]
+
+
+class AccountTaxGroup(models.Model):
+ _name = 'account.tax.group'
+ _description = 'Tax Group'
+ _order = 'sequence asc'
+
+ name = fields.Char(required=True, translate=True)
+ sequence = fields.Integer(default=10)
+ property_tax_payable_account_id = fields.Many2one('account.account', company_dependent=True, string='Tax current account (payable)')
+ property_tax_receivable_account_id = fields.Many2one('account.account', company_dependent=True, string='Tax current account (receivable)')
+ property_advance_tax_payment_account_id = fields.Many2one('account.account', company_dependent=True, string='Advance Tax payment account')
+
+ def _any_is_configured(self, company_id):
+ domain = expression.OR([[('property_tax_payable_account_id', '!=', False)],
+ [('property_tax_receivable_account_id', '!=', False)],
+ [('property_advance_tax_payment_account_id', '!=', False)]])
+ group_with_config = self.with_company(company_id).search_count(domain)
+ return group_with_config > 0
+
+
+class AccountTax(models.Model):
+ _name = 'account.tax'
+ _description = 'Tax'
+ _order = 'sequence,id'
+ _check_company_auto = True
+
+ @api.model
+ def _default_tax_group(self):
+ return self.env['account.tax.group'].search([], limit=1)
+
+ name = fields.Char(string='Tax Name', required=True)
+ type_tax_use = fields.Selection(TYPE_TAX_USE, string='Tax Type', 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. 'adjustment' is used to perform tax adjustment.")
+ tax_scope = fields.Selection([('service', 'Services'), ('consu', 'Goods')], string="Tax Scope", help="Restrict the use of taxes to a type of product.")
+ 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')],
+ help="""
+ - Group of Taxes: The tax is a set of sub taxes.
+ - Fixed: The tax amount stays the same whatever the price.
+ - Percentage of Price: The tax amount is a % of the price:
+ e.g 100 * (1 + 10%) = 110 (not price included)
+ e.g 110 / (1 + 10%) = 100 (price included)
+ - Percentage of Price Tax Included: The tax amount is a division of the price:
+ e.g 180 / (1 - 10%) = 200 (not price included)
+ e.g 200 * (1 - 10%) = 180 (price included)
+ """)
+ active = fields.Boolean(default=True, help="Set active to false to hide the tax without removing it.")
+ company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, default=lambda self: self.env.company)
+ children_tax_ids = fields.Many2many('account.tax',
+ 'account_tax_filiation_rel', 'parent_tax', 'child_tax',
+ check_company=True,
+ string='Children Taxes')
+ sequence = fields.Integer(required=True, default=1,
+ help="The sequence field is used to define order in which the tax lines are applied.")
+ amount = fields.Float(required=True, digits=(16, 4), default=0.0)
+ description = fields.Char(string='Label on Invoices')
+ price_include = fields.Boolean(string='Included in Price', default=False,
+ help="Check this if the price you use on the product and invoices includes this tax.")
+ include_base_amount = fields.Boolean(string='Affect Base of Subsequent Taxes', default=False,
+ help="If set, taxes which are computed after this one will be computed based on the price tax included.")
+ 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)")
+ tax_group_id = fields.Many2one('account.tax.group', string="Tax Group", default=_default_tax_group, required=True)
+ # Technical field to make the 'tax_exigibility' field invisible if the same named field is set to false in 'res.company' model
+ hide_tax_exigibility = fields.Boolean(string='Hide Use Cash Basis Option', related='company_id.tax_exigibility', readonly=True)
+ tax_exigibility = fields.Selection(
+ [('on_invoice', 'Based on Invoice'),
+ ('on_payment', 'Based on Payment'),
+ ], string='Tax Due', default='on_invoice',
+ help="Based on Invoice: the tax is due as soon as the invoice is validated.\n"
+ "Based on Payment: the tax is due as soon as the payment of the invoice is received.")
+ cash_basis_transition_account_id = fields.Many2one(string="Cash Basis Transition Account",
+ check_company=True,
+ 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.")
+ invoice_repartition_line_ids = fields.One2many(string="Distribution for Invoices", comodel_name="account.tax.repartition.line", inverse_name="invoice_tax_id", copy=True, help="Distribution when the tax is used on an invoice")
+ refund_repartition_line_ids = fields.One2many(string="Distribution for Refund Invoices", comodel_name="account.tax.repartition.line", inverse_name="refund_tax_id", copy=True, help="Distribution when the tax is used on a refund")
+ tax_fiscal_country_id = fields.Many2one(string='Fiscal Country', comodel_name='res.country', related='company_id.account_tax_fiscal_country_id', help="Technical field used to restrict the domain of account tags for tax repartition lines created for this tax.")
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
+
+ _sql_constraints = [
+ ('name_company_uniq', 'unique(name, company_id, type_tax_use, tax_scope)', 'Tax names must be unique !'),
+ ]
+
+ @api.model
+ def default_get(self, fields_list):
+ # company_id is added so that we are sure to fetch a default value from it to use in repartition lines, below
+ rslt = super(AccountTax, self).default_get(fields_list + ['company_id'])
+
+ company_id = rslt.get('company_id')
+ company = self.env['res.company'].browse(company_id)
+
+ if 'refund_repartition_line_ids' in fields_list:
+ # We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record.
+ rslt['refund_repartition_line_ids'] = [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'tax_fiscal_country_id': company.country_id.id}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'tax_fiscal_country_id': company.country_id.id}),
+ ]
+
+ if 'invoice_repartition_line_ids' in fields_list:
+ # We write on the related country_id field so that the field is recomputed. Without that, it will stay empty until we save the record.
+ rslt['invoice_repartition_line_ids'] = [
+ (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'tax_fiscal_country_id': company.country_id.id}),
+ (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0, 'tag_ids': [], 'company_id': company_id, 'tax_fiscal_country_id': company.country_id.id}),
+ ]
+
+ return rslt
+
+ def _check_repartition_lines(self, lines):
+ self.ensure_one()
+
+ base_line = lines.filtered(lambda x: x.repartition_type == 'base')
+ if len(base_line) != 1:
+ raise ValidationError(_("Invoice and credit note distribution should each contain exactly one line for the base."))
+
+ @api.constrains('invoice_repartition_line_ids', 'refund_repartition_line_ids')
+ def _validate_repartition_lines(self):
+ for record in self:
+ invoice_repartition_line_ids = record.invoice_repartition_line_ids.sorted()
+ refund_repartition_line_ids = record.refund_repartition_line_ids.sorted()
+ record._check_repartition_lines(invoice_repartition_line_ids)
+ record._check_repartition_lines(refund_repartition_line_ids)
+
+ if len(invoice_repartition_line_ids) != len(refund_repartition_line_ids):
+ raise ValidationError(_("Invoice and credit note distribution should have the same number of lines."))
+
+ index = 0
+ while index < len(invoice_repartition_line_ids):
+ inv_rep_ln = invoice_repartition_line_ids[index]
+ ref_rep_ln = refund_repartition_line_ids[index]
+ if inv_rep_ln.repartition_type != ref_rep_ln.repartition_type or inv_rep_ln.factor_percent != ref_rep_ln.factor_percent:
+ raise ValidationError(_("Invoice and credit note distribution should match (same percentages, in the same order)."))
+ index += 1
+
+ @api.constrains('children_tax_ids', 'type_tax_use')
+ def _check_children_scope(self):
+ for tax in self:
+ if not tax._check_m2m_recursion('children_tax_ids'):
+ raise ValidationError(_("Recursion found for tax '%s'.") % (tax.name,))
+ if any(child.type_tax_use not in ('none', tax.type_tax_use) or child.tax_scope != tax.tax_scope for child in tax.children_tax_ids):
+ raise ValidationError(_('The application scope of taxes in a group must be either the same as the group or left empty.'))
+
+ @api.constrains('company_id')
+ def _check_company_consistency(self):
+ if not self:
+ return
+
+ self.flush(['company_id'])
+ self._cr.execute('''
+ SELECT line.id
+ FROM account_move_line line
+ JOIN account_tax tax ON tax.id = line.tax_line_id
+ WHERE line.tax_line_id IN %s
+ AND line.company_id != tax.company_id
+
+ UNION ALL
+
+ SELECT line.id
+ FROM account_move_line_account_tax_rel tax_rel
+ JOIN account_tax tax ON tax.id = tax_rel.account_tax_id
+ JOIN account_move_line line ON line.id = tax_rel.account_move_line_id
+ WHERE tax_rel.account_tax_id IN %s
+ AND line.company_id != tax.company_id
+ ''', [tuple(self.ids)] * 2)
+ if self._cr.fetchone():
+ raise UserError(_("You can't change the company of your tax since there are some journal items linked to it."))
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ default = dict(default or {}, name=_("%s (Copy)", self.name))
+ return super(AccountTax, self).copy(default=default)
+
+ def name_get(self):
+ name_list = []
+ type_tax_use = dict(self._fields['type_tax_use']._description_selection(self.env))
+ tax_scope = dict(self._fields['tax_scope']._description_selection(self.env))
+ for record in self:
+ name = record.name
+ if self._context.get('append_type_to_tax_name'):
+ name += ' (%s)' % type_tax_use.get(record.type_tax_use)
+ if record.tax_scope:
+ name += ' (%s)' % tax_scope.get(record.tax_scope)
+ name_list += [(record.id, name)]
+ return name_list
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ """ Returns a list of tuples containing id, name, as internally it is called {def name_get}
+ result format: {[(id, name), (id, name), ...]}
+ """
+ args = args or []
+ if operator == 'ilike' and not (name or '').strip():
+ domain = []
+ else:
+ connector = '&' if operator in expression.NEGATIVE_TERM_OPERATORS else '|'
+ domain = [connector, ('description', operator, name), ('name', operator, name)]
+ return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
+
+ @api.model
+ def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
+ context = self._context or {}
+
+ if context.get('move_type'):
+ if context.get('move_type') in ('out_invoice', 'out_refund'):
+ args += [('type_tax_use', '=', 'sale')]
+ elif context.get('move_type') in ('in_invoice', 'in_refund'):
+ args += [('type_tax_use', '=', 'purchase')]
+
+ if context.get('journal_id'):
+ journal = self.env['account.journal'].browse(context.get('journal_id'))
+ if journal.type in ('sale', 'purchase'):
+ args += [('type_tax_use', '=', journal.type)]
+
+ return super(AccountTax, self)._search(args, offset, limit, order, count=count, access_rights_uid=access_rights_uid)
+
+ @api.onchange('amount')
+ def onchange_amount(self):
+ if self.amount_type in ('percent', 'division') and self.amount != 0.0 and not self.description:
+ self.description = "{0:.4g}%".format(self.amount)
+
+ @api.onchange('amount_type')
+ def onchange_amount_type(self):
+ if self.amount_type != 'group':
+ self.children_tax_ids = [(5,)]
+ if self.amount_type == 'group':
+ self.description = None
+
+ @api.onchange('price_include')
+ def onchange_price_include(self):
+ if self.price_include:
+ self.include_base_amount = True
+
+ def _compute_amount(self, base_amount, price_unit, quantity=1.0, product=None, partner=None):
+ """ Returns the amount of a single tax. base_amount is the actual amount on which the tax is applied, which is
+ price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include)
+ """
+ self.ensure_one()
+
+ if self.amount_type == 'fixed':
+ # Use copysign to take into account the sign of the base amount which includes the sign
+ # of the quantity and the sign of the price_unit
+ # Amount is the fixed price for the tax, it can be negative
+ # Base amount included the sign of the quantity and the sign of the unit price and when
+ # a product is returned, it can be done either by changing the sign of quantity or by changing the
+ # sign of the price unit.
+ # When the price unit is equal to 0, the sign of the quantity is absorbed in base_amount then
+ # a "else" case is needed.
+ if base_amount:
+ return math.copysign(quantity, base_amount) * self.amount
+ else:
+ return quantity * self.amount
+
+ price_include = self._context.get('force_price_include', self.price_include)
+
+ # base * (1 + tax_amount) = new_base
+ if self.amount_type == 'percent' and not price_include:
+ return base_amount * self.amount / 100
+ # <=> new_base = base / (1 + tax_amount)
+ if self.amount_type == 'percent' and price_include:
+ return base_amount - (base_amount / (1 + self.amount / 100))
+ # base / (1 - tax_amount) = new_base
+ if self.amount_type == 'division' and not price_include:
+ return base_amount / (1 - self.amount / 100) - base_amount if (1 - self.amount / 100) else 0.0
+ # <=> new_base * (1 - tax_amount) = base
+ if self.amount_type == 'division' and price_include:
+ return base_amount - (base_amount * (self.amount / 100))
+
+ def json_friendly_compute_all(self, price_unit, currency_id=None, quantity=1.0, product_id=None, partner_id=None, is_refund=False):
+ """ Called by the reconciliation to compute taxes on writeoff during bank reconciliation
+ """
+ if currency_id:
+ currency_id = self.env['res.currency'].browse(currency_id)
+ if product_id:
+ product_id = self.env['product.product'].browse(product_id)
+ if partner_id:
+ partner_id = self.env['res.partner'].browse(partner_id)
+
+ # We first need to find out whether this tax computation is made for a refund
+ tax_type = self and self[0].type_tax_use
+ is_refund = is_refund or (tax_type == 'sale' and price_unit < 0) or (tax_type == 'purchase' and price_unit > 0)
+
+ rslt = self.compute_all(price_unit, currency=currency_id, quantity=quantity, product=product_id, partner=partner_id, is_refund=is_refund)
+
+ # The reconciliation widget calls this function to generate writeoffs on bank journals,
+ # so the sign of the tags might need to be inverted, so that the tax report
+ # computation can treat them as any other miscellaneous operations, while
+ # keeping a computation in line with the effect the tax would have had on an invoice.
+
+ if (tax_type == 'sale' and not is_refund) or (tax_type == 'purchase' and is_refund):
+ base_tags = self.env['account.account.tag'].browse(rslt['base_tags'])
+ rslt['base_tags'] = self.env['account.move.line']._revert_signed_tags(base_tags).ids
+
+ for tax_result in rslt['taxes']:
+ tax_tags = self.env['account.account.tag'].browse(tax_result['tag_ids'])
+ tax_result['tag_ids'] = self.env['account.move.line']._revert_signed_tags(tax_tags).ids
+
+ return rslt
+
+ def flatten_taxes_hierarchy(self, create_map=False):
+ # Flattens the taxes contained in this recordset, returning all the
+ # children at the bottom of the hierarchy, in a recordset, ordered by sequence.
+ # Eg. considering letters as taxes and alphabetic order as sequence :
+ # [G, B([A, D, F]), E, C] will be computed as [A, D, F, C, E, G]
+ # If create_map is True, an additional value is returned, a dictionary
+ # mapping each child tax to its parent group
+ all_taxes = self.env['account.tax']
+ groups_map = {}
+ for tax in self.sorted(key=lambda r: r.sequence):
+ if tax.amount_type == 'group':
+ flattened_children = tax.children_tax_ids.flatten_taxes_hierarchy()
+ all_taxes += flattened_children
+ for flat_child in flattened_children:
+ groups_map[flat_child] = tax
+ else:
+ all_taxes += tax
+
+ if create_map:
+ return all_taxes, groups_map
+
+ return all_taxes
+
+ def get_tax_tags(self, is_refund, repartition_type):
+ rep_lines = self.mapped(is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids')
+ return rep_lines.filtered(lambda x: x.repartition_type == repartition_type).mapped('tag_ids')
+
+ def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None, is_refund=False, handle_price_include=True):
+ """ Returns all information required to apply taxes (in self + their children in case of a tax group).
+ We consider the sequence of the parent for group of taxes.
+ Eg. considering letters as taxes and alphabetic order as sequence :
+ [G, B([A, D, F]), E, C] will be computed as [A, D, F, C, E, G]
+
+ 'handle_price_include' is used when we need to ignore all tax included in price. If False, it means the
+ amount passed to this method will be considered as the base of all computations.
+
+ RETURN: {
+ 'total_excluded': 0.0, # Total without taxes
+ 'total_included': 0.0, # Total with taxes
+ 'total_void' : 0.0, # Total with those taxes, that don't have an account set
+ 'taxes': [{ # One dict for each tax in self and their children
+ 'id': int,
+ 'name': str,
+ 'amount': float,
+ 'sequence': int,
+ 'account_id': int,
+ 'refund_account_id': int,
+ 'analytic': boolean,
+ }],
+ } """
+ if not self:
+ company = self.env.company
+ else:
+ company = self[0].company_id
+
+ # 1) Flatten the taxes.
+ taxes, groups_map = self.flatten_taxes_hierarchy(create_map=True)
+
+ # 2) Deal with the rounding methods
+ if not currency:
+ currency = company.currency_id
+
+ # By default, for each tax, tax amount will first be computed
+ # and rounded at the 'Account' decimal precision for each
+ # PO/SO/invoice line and then these rounded amounts will be
+ # summed, leading to the total amount for that tax. But, if the
+ # company has tax_calculation_rounding_method = round_globally,
+ # we still follow the same method, but we use a much larger
+ # precision when we round the tax amount for each line (we use
+ # the 'Account' decimal precision + 5), and that way it's like
+ # rounding after the sum of the tax amounts of each line
+ prec = currency.rounding
+
+ # In some cases, it is necessary to force/prevent the rounding of the tax and the total
+ # amounts. For example, in SO/PO line, we don't want to round the price unit at the
+ # precision of the currency.
+ # The context key 'round' allows to force the standard behavior.
+ round_tax = False if company.tax_calculation_rounding_method == 'round_globally' else True
+ if 'round' in self.env.context:
+ round_tax = bool(self.env.context['round'])
+
+ if not round_tax:
+ prec *= 1e-5
+
+ # 3) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
+ # tax | base | amount |
+ # /\ ----------------------------
+ # || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded
+ # || tax_2 | .. | |
+ # || tax_3 | .. | |
+ # || ... | .. | .. |
+ # ----------------------------
+ def recompute_base(base_amount, fixed_amount, percent_amount, division_amount):
+ # Recompute the new base amount based on included fixed/percent amounts and the current base amount.
+ # Example:
+ # tax | amount | type | price_include |
+ # -----------------------------------------------
+ # tax_1 | 10% | percent | t
+ # tax_2 | 15 | fix | t
+ # tax_3 | 20% | percent | t
+ # tax_4 | 10% | division | t
+ # -----------------------------------------------
+
+ # if base_amount = 145, the new base is computed as:
+ # (145 - 15) / (1.0 + 30%) * 90% = 130 / 1.3 * 90% = 90
+ return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0) * (100 - division_amount) / 100
+
+ # The first/last base must absolutely be rounded to work in round globally.
+ # Indeed, the sum of all taxes ('taxes' key in the result dictionary) must be strictly equals to
+ # 'price_included' - 'price_excluded' whatever the rounding method.
+ #
+ # Example using the global rounding without any decimals:
+ # Suppose two invoice lines: 27000 and 10920, both having a 19% price included tax.
+ #
+ # Line 1 Line 2
+ # -----------------------------------------------------------------------
+ # total_included: 27000 10920
+ # tax: 27000 / 1.19 = 4310.924 10920 / 1.19 = 1743.529
+ # total_excluded: 22689.076 9176.471
+ #
+ # If the rounding of the total_excluded isn't made at the end, it could lead to some rounding issues
+ # when summing the tax amounts, e.g. on invoices.
+ # In that case:
+ # - amount_untaxed will be 22689 + 9176 = 31865
+ # - amount_tax will be 4310.924 + 1743.529 = 6054.453 ~ 6054
+ # - amount_total will be 31865 + 6054 = 37919 != 37920 = 27000 + 10920
+ #
+ # By performing a rounding at the end to compute the price_excluded amount, the amount_tax will be strictly
+ # equals to 'price_included' - 'price_excluded' after rounding and then:
+ # Line 1: sum(taxes) = 27000 - 22689 = 4311
+ # Line 2: sum(taxes) = 10920 - 2176 = 8744
+ # amount_tax = 4311 + 8744 = 13055
+ # amount_total = 31865 + 13055 = 37920
+ base = currency.round(price_unit * quantity)
+
+ # For the computation of move lines, we could have a negative base value.
+ # In this case, compute all with positive values and negate them at the end.
+ sign = 1
+ if currency.is_zero(base):
+ sign = self._context.get('force_sign', 1)
+ elif base < 0:
+ sign = -1
+ if base < 0:
+ base = -base
+
+ # Store the totals to reach when using price_include taxes (only the last price included in row)
+ total_included_checkpoints = {}
+ i = len(taxes) - 1
+ store_included_tax_total = True
+ # Keep track of the accumulated included fixed/percent amount.
+ incl_fixed_amount = incl_percent_amount = incl_division_amount = 0
+ # Store the tax amounts we compute while searching for the total_excluded
+ 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 * sum_repartition_factor
+ elif tax.amount_type == 'division':
+ incl_division_amount += tax.amount * sum_repartition_factor
+ elif tax.amount_type == 'fixed':
+ incl_fixed_amount += abs(quantity) * tax.amount * sum_repartition_factor
+ else:
+ # tax.amount_type == other (python)
+ tax_amount = tax._compute_amount(base, sign * price_unit, quantity, product, partner) * sum_repartition_factor
+ incl_fixed_amount += tax_amount
+ # Avoid unecessary re-computation
+ cached_tax_amounts[i] = tax_amount
+ # In case of a zero tax, do not store the base amount since the tax amount will
+ # be zero anyway. Group and Python taxes have an amount of zero, so do not take
+ # them into account.
+ if store_included_tax_total and (
+ tax.amount or tax.amount_type not in ("percent", "division", "fixed")
+ ):
+ total_included_checkpoints[i] = base
+ store_included_tax_total = False
+ i -= 1
+
+ total_excluded = currency.round(recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount))
+
+ # 4) Iterate the taxes in the sequence order to compute missing tax amounts.
+ # Start the computation of accumulated amounts at the total_excluded value.
+ base = total_included = total_void = total_excluded
+
+ # Flag indicating the checkpoint used in price_include to avoid rounding issue must be skipped since the base
+ # amount has changed because we are currently mixing price-included and price-excluded include_base_amount
+ # taxes.
+ skip_checkpoint = False
+
+ taxes_vals = []
+ 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'))
+
+ price_include = self._context.get('force_price_include', tax.price_include)
+
+ #compute the tax_amount
+ if not skip_checkpoint and 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
+ tax_amount = total_included_checkpoints[i] - (base + cumulated_tax_included_amount)
+ cumulated_tax_included_amount = 0
+ else:
+ tax_amount = tax.with_context(force_price_include=False)._compute_amount(
+ base, sign * price_unit, quantity, product, partner)
+
+ # Round the tax_amount multiplied by the computed repartition lines factor.
+ tax_amount = round(tax_amount, precision_rounding=prec)
+ factorized_tax_amount = round(tax_amount * sum_repartition_factor, precision_rounding=prec)
+
+ if price_include and not total_included_checkpoints.get(i):
+ 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
+ # the right total
+ subsequent_taxes = self.env['account.tax']
+ subsequent_tags = self.env['account.account.tag']
+ if tax.include_base_amount:
+ subsequent_taxes = taxes[i+1:]
+ subsequent_tags = subsequent_taxes.get_tax_tags(is_refund, 'base')
+
+ # 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, precision_rounding=prec) for line in tax_repartition_lines]
+ total_rounding_error = round(factorized_tax_amount - sum(repartition_line_amounts), precision_rounding=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, precision_rounding=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,
+ 'name': partner and tax.with_context(lang=partner.lang).name or tax.name,
+ 'amount': sign * line_amount,
+ 'base': round(sign * base, precision_rounding=prec),
+ 'sequence': tax.sequence,
+ 'account_id': tax.cash_basis_transition_account_id.id if tax.tax_exigibility == 'on_payment' else repartition_line.account_id.id,
+ 'analytic': tax.analytic,
+ 'price_include': price_include,
+ 'tax_exigibility': tax.tax_exigibility,
+ 'tax_repartition_line_id': repartition_line.id,
+ 'group': groups_map.get(tax),
+ 'tag_ids': (repartition_line.tag_ids + subsequent_tags).ids,
+ 'tax_ids': subsequent_taxes.ids,
+ })
+
+ if not repartition_line.account_id:
+ total_void += line_amount
+
+ # Affect subsequent taxes
+ if tax.include_base_amount:
+ base += factorized_tax_amount
+ if not price_include:
+ skip_checkpoint = True
+
+ total_included += factorized_tax_amount
+ i += 1
+
+ return {
+ 'base_tags': taxes.mapped(is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids').filtered(lambda x: x.repartition_type == 'base').mapped('tag_ids').ids,
+ 'taxes': taxes_vals,
+ 'total_excluded': sign * total_excluded,
+ 'total_included': sign * currency.round(total_included),
+ 'total_void': sign * currency.round(total_void),
+ }
+
+ @api.model
+ def _fix_tax_included_price(self, price, prod_taxes, line_taxes):
+ """Subtract tax amount from price when corresponding "price included" taxes do not apply"""
+ # FIXME get currency in param?
+ prod_taxes = prod_taxes._origin
+ line_taxes = line_taxes._origin
+ incl_tax = prod_taxes.filtered(lambda tax: tax not in line_taxes and tax.price_include)
+ if incl_tax:
+ return incl_tax.compute_all(price)['total_excluded']
+ return price
+
+ @api.model
+ def _fix_tax_included_price_company(self, price, prod_taxes, line_taxes, company_id):
+ if company_id:
+ #To keep the same behavior as in _compute_tax_id
+ prod_taxes = prod_taxes.filtered(lambda tax: tax.company_id == company_id)
+ line_taxes = line_taxes.filtered(lambda tax: tax.company_id == company_id)
+ return self._fix_tax_included_price(price, prod_taxes, line_taxes)
+
+
+class AccountTaxRepartitionLine(models.Model):
+ _name = "account.tax.repartition.line"
+ _description = "Tax Repartition Line"
+ _order = 'sequence, repartition_type, id'
+ _check_company_auto = True
+
+ factor_percent = fields.Float(string="%", required=True, help="Factor to apply on the account move lines generated from this distribution line, in percents")
+ factor = fields.Float(string="Factor Ratio", compute="_compute_factor", help="Factor to apply on the account move lines generated from this distribution line")
+ repartition_type = fields.Selection(string="Based On", selection=[('base', 'Base'), ('tax', 'of tax')], required=True, default='tax', help="Base on which the factor will be applied.")
+ account_id = fields.Many2one(string="Account",
+ comodel_name='account.account',
+ domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('internal_type', 'not in', ('receivable', 'payable'))]",
+ check_company=True,
+ help="Account on which to post the tax amount")
+ tag_ids = fields.Many2many(string="Tax Grids", comodel_name='account.account.tag', domain=[('applicability', '=', 'taxes')], copy=True)
+ invoice_tax_id = fields.Many2one(comodel_name='account.tax',
+ ondelete='cascade',
+ check_company=True,
+ help="The tax set to apply this distribution on invoices. Mutually exclusive with refund_tax_id")
+ refund_tax_id = fields.Many2one(comodel_name='account.tax',
+ ondelete='cascade',
+ check_company=True,
+ help="The tax set to apply this distribution on refund invoices. Mutually exclusive with invoice_tax_id")
+ tax_id = fields.Many2one(comodel_name='account.tax', compute='_compute_tax_id')
+ tax_fiscal_country_id = fields.Many2one(string="Fiscal Country", comodel_name='res.country', related='company_id.account_tax_fiscal_country_id', help="Technical field used to restrict tags domain in form view.")
+ company_id = fields.Many2one(string="Company", comodel_name='res.company', compute="_compute_company", store=True, help="The company this distribution line belongs to.")
+ sequence = fields.Integer(string="Sequence", default=1,
+ help="The order in which distribution lines are displayed and matched. For refunds to work properly, invoice distribution lines should be arranged in the same order as the credit note distribution lines they correspond to.")
+ use_in_tax_closing = fields.Boolean(string="Tax Closing Entry")
+
+ @api.onchange('account_id', 'repartition_type')
+ def _on_change_account_id(self):
+ if not self.account_id or self.repartition_type == 'base':
+ self.use_in_tax_closing = False
+ else:
+ self.use_in_tax_closing = self.account_id.internal_group not in ('income', 'expense')
+
+ @api.constrains('invoice_tax_id', 'refund_tax_id')
+ def validate_tax_template_link(self):
+ for record in self:
+ if record.invoice_tax_id and record.refund_tax_id:
+ raise ValidationError(_("Tax distribution lines should apply to either invoices or refunds, not both at the same time. invoice_tax_id and refund_tax_id should not be set together."))
+
+ @api.depends('factor_percent')
+ def _compute_factor(self):
+ for record in self:
+ record.factor = record.factor_percent / 100.0
+
+ @api.depends('invoice_tax_id.company_id', 'refund_tax_id.company_id')
+ def _compute_company(self):
+ for record in self:
+ record.company_id = record.invoice_tax_id and record.invoice_tax_id.company_id.id or record.refund_tax_id.company_id.id
+
+ @api.depends('invoice_tax_id', 'refund_tax_id')
+ def _compute_tax_id(self):
+ for record in self:
+ record.tax_id = record.invoice_tax_id or record.refund_tax_id
+
+ @api.onchange('repartition_type')
+ def _onchange_repartition_type(self):
+ if self.repartition_type == 'base':
+ self.account_id = None
diff --git a/addons/account/models/account_tax_report.py b/addons/account/models/account_tax_report.py
new file mode 100644
index 00000000..d6a5fd2e
--- /dev/null
+++ b/addons/account/models/account_tax_report.py
@@ -0,0 +1,277 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+
+
+class AccountTaxReport(models.Model):
+ _name = "account.tax.report"
+ _description = 'Account Tax Report'
+ _order = 'country_id, name'
+
+ name = fields.Char(string="Name", required=True, help="Name of this tax report")
+ country_id = fields.Many2one(string="Country", comodel_name='res.country', required=True, default=lambda x: x.env.company.country_id.id, help="Country for which this report is available.")
+ line_ids = fields.One2many(string="Report Lines", comodel_name='account.tax.report.line', inverse_name='report_id', help="Content of this tax report")
+ root_line_ids = fields.One2many(string="Root Report Lines", comodel_name='account.tax.report.line', inverse_name='report_id', domain=[('parent_id', '=', None)], help="Subset of line_ids, containing the lines at the root of the report.")
+
+ def write(self, vals):
+ # Overridden so that we change the country _id of the existing tags
+ # when writing the country_id of the report, or create new tags
+ # for the new country if the tags are shared with some other report.
+
+ if 'country_id' in vals:
+ tags_cache = {}
+ for record in self.filtered(lambda x: x.country_id.id != vals['country_id']):
+ for line in record.line_ids:
+ if line.tag_ids:
+ #The tags for this country may have been created by a previous line in this loop
+ cache_key = (vals['country_id'], line.tag_name)
+ if cache_key not in tags_cache:
+ tags_cache[cache_key] = self.env['account.account.tag']._get_tax_tags(line.tag_name, vals['country_id'])
+
+ new_tags = tags_cache[cache_key]
+
+ if new_tags:
+ line._remove_tags_used_only_by_self()
+ line.write({'tag_ids': [(6, 0, new_tags.ids)]})
+
+ elif line.mapped('tag_ids.tax_report_line_ids.report_id').filtered(lambda x: x not in self):
+ line._remove_tags_used_only_by_self()
+ line.write({'tag_ids': [(5, 0, 0)] + line._get_tags_create_vals(line.tag_name, vals['country_id'])})
+ tags_cache[cache_key] = line.tag_ids
+
+ else:
+ line.tag_ids.write({'country_id': vals['country_id']})
+
+ return super(AccountTaxReport, self).write(vals)
+
+ def copy(self, default=None):
+ # Overridden from regular copy, since the ORM does not manage
+ # the copy of the lines hierarchy properly (all the parent_id fields
+ # need to be reassigned to the corresponding copies).
+
+ copy_default = {k:v for k, v in default.items() if k != 'line_ids'} if default else None
+ copied_report = super(AccountTaxReport, self).copy(default=copy_default) #This copies the report without its lines
+
+ lines_map = {} # maps original lines to their copies (using ids)
+ lines_to_treat = list(self.line_ids.filtered(lambda x: not x.parent_id))
+ while lines_to_treat:
+ line = lines_to_treat.pop()
+ lines_to_treat += list(line.children_line_ids)
+
+ copy = line.copy({'parent_id': lines_map.get(line.parent_id.id, None), 'report_id': copied_report.id})
+ lines_map[line.id] = copy.id
+
+ return copied_report
+
+ def get_lines_in_hierarchy(self):
+ """ Returns an interator to the lines of this tax report, were parent lines
+ ar all directly followed by their children.
+ """
+ self.ensure_one()
+ lines_to_treat = list(self.line_ids.filtered(lambda x: not x.parent_id).sorted(lambda x: x.sequence)) # Used as a stack, whose index 0 is the top
+ while lines_to_treat:
+ to_yield = lines_to_treat[0]
+ lines_to_treat = list(to_yield.children_line_ids.sorted(lambda x: x.sequence)) + lines_to_treat[1:]
+ yield to_yield
+
+ def get_checks_to_perform(self, d):
+ """ To override in localizations
+ If value is a float, it will be formatted with format_value
+ The line is not displayed if it is falsy (0, 0.0, False, ...)
+ :param d: the mapping dictionay between codes and values
+ :return: iterable of tuple (name, value)
+ """
+ self.ensure_one()
+ return []
+
+ def validate_country_id(self):
+ for record in self:
+ if any(line.tag_ids.mapped('country_id') != record.country_id for line in record.line_ids):
+ raise ValidationError(_("The tags associated with tax report line objects should all have the same country set as the tax report containing these lines."))
+
+
+class AccountTaxReportLine(models.Model):
+ _name = "account.tax.report.line"
+ _description = 'Account Tax Report Line'
+ _order = 'sequence'
+ _parent_store = True
+
+ name = fields.Char(string="Name", required=True, help="Complete name for this report line, to be used in report.")
+ tag_ids = fields.Many2many(string="Tags", comodel_name='account.account.tag', relation='account_tax_report_line_tags_rel', help="Tax tags populating this line")
+ report_action_id = fields.Many2one(string="Report Action", comodel_name='ir.actions.act_window', help="The optional action to call when clicking on this line in accounting reports.")
+ children_line_ids = fields.One2many(string="Children Lines", comodel_name='account.tax.report.line', inverse_name='parent_id', help="Lines that should be rendered as children of this one")
+ parent_id = fields.Many2one(string="Parent Line", comodel_name='account.tax.report.line')
+ sequence = fields.Integer(string='Sequence', required=True,
+ help="Sequence determining the order of the lines in the report (smaller ones come first). This order is applied locally per section (so, children of the same line are always rendered one after the other).")
+ parent_path = fields.Char(index=True)
+ report_id = fields.Many2one(string="Tax Report", required=True, comodel_name='account.tax.report', ondelete='cascade', help="The parent tax report of this line")
+
+ #helper to create tags (positive and negative) on report line creation
+ tag_name = fields.Char(string="Tag Name", help="Short name for the tax grid corresponding to this report line. Leave empty if this report line should not correspond to any such grid.")
+
+ #fields used in specific localization reports, where a report line isn't simply the given by the sum of account.move.line with selected tags
+ code = fields.Char(string="Code", help="Optional unique code to refer to this line in total formulas")
+ formula = fields.Char(string="Formula", help="Python expression used to compute the value of a total line. This field is mutually exclusive with tag_name, setting it turns the line to a total line. Tax report line codes can be used as variables in this expression to refer to the balance of the corresponding lines in the report. A formula cannot refer to another line using a formula.")
+
+ @api.model
+ def create(self, vals):
+ # Manage tags
+ tag_name = vals.get('tag_name', '')
+ if tag_name and vals.get('report_id'):
+ report = self.env['account.tax.report'].browse(vals['report_id'])
+ country = report.country_id
+
+ existing_tags = self.env['account.account.tag']._get_tax_tags(tag_name, country.id)
+
+ if existing_tags:
+ # We connect the new report line to the already existing tags
+ vals['tag_ids'] = [(6, 0, existing_tags.ids)]
+ else:
+ # We create new ones
+ vals['tag_ids'] = self._get_tags_create_vals(tag_name, country.id)
+
+ return super(AccountTaxReportLine, self).create(vals)
+
+ @api.model
+ def _get_tags_create_vals(self, tag_name, country_id):
+ minus_tag_vals = {
+ 'name': '-' + tag_name,
+ 'applicability': 'taxes',
+ 'tax_negate': True,
+ 'country_id': country_id,
+ }
+ plus_tag_vals = {
+ 'name': '+' + tag_name,
+ 'applicability': 'taxes',
+ 'tax_negate': False,
+ 'country_id': country_id,
+ }
+ return [(0, 0, minus_tag_vals), (0, 0, plus_tag_vals)]
+
+ def write(self, vals):
+ tag_name_postponed = None
+
+ # If tag_name was set, but not tag_ids, we postpone the write of
+ # tag_name, and perform it only after having generated/retrieved the tags.
+ # Otherwise, tag_name and tags' name would not match, breaking
+ # _validate_tags constaint.
+ postpone_tag_name = 'tag_name' in vals and not 'tag_ids' in vals
+
+ if postpone_tag_name:
+ tag_name_postponed = vals.pop('tag_name')
+
+ rslt = super(AccountTaxReportLine, self).write(vals)
+
+ if postpone_tag_name:
+ # If tag_name modification has been postponed,
+ # we need to search for existing tags corresponding to the new tag name
+ # (or create them if they don't exist yet) and assign them to the records
+
+ records_by_country = {}
+ for record in self.filtered(lambda x: x.tag_name != tag_name_postponed):
+ records_by_country[record.report_id.country_id.id] = records_by_country.get(record.report_id.country_id.id, self.env['account.tax.report.line']) + record
+
+ for country_id, records in records_by_country.items():
+ if tag_name_postponed:
+ record_tag_names = records.mapped('tag_name')
+ if len(record_tag_names) == 1 and record_tag_names[0]:
+ # If all the records already have the same tag_name before writing,
+ # we simply want to change the name of the existing tags
+ to_update = records.mapped('tag_ids.tax_report_line_ids')
+ tags_to_update = to_update.mapped('tag_ids')
+ minus_child_tags = tags_to_update.filtered(lambda x: x.tax_negate)
+ minus_child_tags.write({'name': '-' + tag_name_postponed})
+ plus_child_tags = tags_to_update.filtered(lambda x: not x.tax_negate)
+ plus_child_tags.write({'name': '+' + tag_name_postponed})
+ super(AccountTaxReportLine, to_update).write({'tag_name': tag_name_postponed})
+
+ else:
+ existing_tags = self.env['account.account.tag']._get_tax_tags(tag_name_postponed, country_id)
+ records_to_link = records
+ tags_to_remove = self.env['account.account.tag']
+
+ if not existing_tags and records_to_link:
+ # If the tag does not exist yet, we first create it by
+ # linking it to the first report line of the record set
+ first_record = records_to_link[0]
+ tags_to_remove += first_record.tag_ids
+ first_record.write({'tag_name': tag_name_postponed, 'tag_ids': [(5, 0, 0)] + self._get_tags_create_vals(tag_name_postponed, country_id)})
+ existing_tags = first_record.tag_ids
+ records_to_link -= first_record
+
+ # All the lines sharing their tags must always be synchronized,
+ tags_to_remove += records_to_link.mapped('tag_ids')
+ records_to_link = tags_to_remove.mapped('tax_report_line_ids')
+ tags_to_remove.mapped('tax_report_line_ids')._remove_tags_used_only_by_self()
+ records_to_link.write({'tag_name': tag_name_postponed, 'tag_ids': [(2, tag.id) for tag in tags_to_remove] + [(6, 0, existing_tags.ids)]})
+
+ else:
+ # tag_name was set empty, so we remove the tags on current lines
+ # If some tags are still referenced by other report lines,
+ # we keep them ; else, we delete them from DB
+ line_tags = records.mapped('tag_ids')
+ other_lines_same_tag = line_tags.mapped('tax_report_line_ids').filtered(lambda x: x not in records)
+ if not other_lines_same_tag:
+ self._delete_tags_from_taxes(line_tags.ids)
+ orm_cmd_code = other_lines_same_tag and 3 or 2
+ records.write({'tag_name': None, 'tag_ids': [(orm_cmd_code, tag.id) for tag in line_tags]})
+
+ return rslt
+
+ def unlink(self):
+ self._remove_tags_used_only_by_self()
+ children = self.mapped('children_line_ids')
+ if children:
+ children.unlink()
+ return super(AccountTaxReportLine, self).unlink()
+
+ def _remove_tags_used_only_by_self(self):
+ """ Deletes and removes from taxes and move lines all the
+ tags from the provided tax report lines that are not linked
+ to any other tax report lines.
+ """
+ all_tags = self.mapped('tag_ids')
+ tags_to_unlink = all_tags.filtered(lambda x: not (x.tax_report_line_ids - self))
+ self.write({'tag_ids': [(3, tag.id, 0) for tag in tags_to_unlink]})
+ self._delete_tags_from_taxes(tags_to_unlink.ids)
+
+ @api.model
+ def _delete_tags_from_taxes(self, tag_ids_to_delete):
+ """ Based on a list of tag ids, removes them first from the
+ repartition lines they are linked to, then deletes them
+ from the account move lines, and finally unlink them.
+ """
+ if not tag_ids_to_delete:
+ # Nothing to do, then!
+ return
+
+ self.env.cr.execute("""
+ delete from account_account_tag_account_tax_repartition_line_rel
+ where account_account_tag_id in %(tag_ids_to_delete)s;
+
+ delete from account_account_tag_account_move_line_rel
+ where account_account_tag_id in %(tag_ids_to_delete)s;
+ """, {'tag_ids_to_delete': tuple(tag_ids_to_delete)})
+
+ self.env['account.move.line'].invalidate_cache(fnames=['tax_tag_ids'])
+ self.env['account.tax.repartition.line'].invalidate_cache(fnames=['tag_ids'])
+
+ self.env['account.account.tag'].browse(tag_ids_to_delete).unlink()
+
+ @api.constrains('formula', 'tag_name')
+ def _validate_formula(self):
+ for record in self:
+ if record.formula and record.tag_name:
+ raise ValidationError(_("Tag name and formula are mutually exclusive, they should not be set together on the same tax report line."))
+
+ @api.constrains('tag_name', 'tag_ids')
+ def _validate_tags(self):
+ for record in self.filtered(lambda x: x.tag_ids):
+ neg_tags = record.tag_ids.filtered(lambda x: x.tax_negate)
+ pos_tags = record.tag_ids.filtered(lambda x: not x.tax_negate)
+
+ if (len(neg_tags) != 1 or len(pos_tags) != 1):
+ raise ValidationError(_("If tags are defined for a tax report line, only two are allowed on it: a positive and a negative one."))
+
+ if neg_tags.name != '-'+record.tag_name or pos_tags.name != '+'+record.tag_name:
+ raise ValidationError(_("The tags linked to a tax report line should always match its tag name."))
diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py
new file mode 100644
index 00000000..ade616ce
--- /dev/null
+++ b/addons/account/models/chart_template.py
@@ -0,0 +1,1242 @@
+# -*- coding: utf-8 -*-
+
+from odoo.exceptions import AccessError
+from odoo import api, fields, models, _
+from odoo import SUPERUSER_ID
+from odoo.exceptions import UserError, ValidationError
+from odoo.http import request
+from odoo.addons.account.models.account_tax import TYPE_TAX_USE
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+def migrate_set_tags_and_taxes_updatable(cr, registry, module):
+ ''' This is a utility function used to manually set the flag noupdate to False on tags and account tax templates on localization modules
+ that need migration (for example in case of VAT report improvements)
+ '''
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ xml_record_ids = env['ir.model.data'].search([
+ ('model', 'in', ['account.tax.template', 'account.account.tag']),
+ ('module', 'like', module)
+ ]).ids
+ if xml_record_ids:
+ cr.execute("update ir_model_data set noupdate = 'f' where id in %s", (tuple(xml_record_ids),))
+
+def preserve_existing_tags_on_taxes(cr, registry, module):
+ ''' This is a utility function used to preserve existing previous tags during upgrade of the module.'''
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ xml_records = env['ir.model.data'].search([('model', '=', 'account.account.tag'), ('module', 'like', module)])
+ if xml_records:
+ cr.execute("update ir_model_data set noupdate = 't' where id in %s", [tuple(xml_records.ids)])
+
+# ---------------------------------------------------------------
+# Account Templates: Account, Tax, Tax Code and chart. + Wizard
+# ---------------------------------------------------------------
+
+
+class AccountGroupTemplate(models.Model):
+ _name = "account.group.template"
+ _description = 'Template for Account Groups'
+ _order = 'code_prefix_start'
+
+ parent_id = fields.Many2one('account.group.template', index=True, ondelete='cascade')
+ name = fields.Char(required=True)
+ code_prefix_start = fields.Char()
+ code_prefix_end = fields.Char()
+ chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True)
+
+
+class AccountAccountTemplate(models.Model):
+ _name = "account.account.template"
+ _description = 'Templates for Accounts'
+ _order = "code"
+
+ name = fields.Char(required=True, index=True)
+ currency_id = fields.Many2one('res.currency', string='Account Currency', help="Forces all moves for this account to have this secondary currency.")
+ code = fields.Char(size=64, required=True, index=True)
+ user_type_id = fields.Many2one('account.account.type', string='Type', required=True,
+ help="These types are defined according to your country. The type contains more information "\
+ "about the account and its specificities.")
+ reconcile = fields.Boolean(string='Allow Invoices & payments Matching', default=False,
+ help="Check this option if you want the user to reconcile entries in this account.")
+ note = fields.Text()
+ tax_ids = fields.Many2many('account.tax.template', 'account_account_template_tax_rel', 'account_id', 'tax_id', string='Default Taxes')
+ nocreate = fields.Boolean(string='Optional Create', default=False,
+ help="If checked, the new chart of accounts will not contain this by default.")
+ chart_template_id = fields.Many2one('account.chart.template', string='Chart Template',
+ help="This optional field allow you to link an account template to a specific chart template that may differ from the one its root parent belongs to. This allow you "
+ "to define chart templates that extend another and complete it with few new accounts (You don't need to define the whole structure that is common to both several times).")
+ tag_ids = fields.Many2many('account.account.tag', 'account_account_template_account_tag', string='Account tag', help="Optional tags you may want to assign for custom reporting")
+
+ @api.depends('name', 'code')
+ def name_get(self):
+ res = []
+ for record in self:
+ name = record.name
+ if record.code:
+ name = record.code + ' ' + name
+ res.append((record.id, name))
+ return res
+
+
+class AccountChartTemplate(models.Model):
+ _name = "account.chart.template"
+ _description = "Account Chart Template"
+
+ name = fields.Char(required=True)
+ parent_id = fields.Many2one('account.chart.template', string='Parent Chart Template')
+ code_digits = fields.Integer(string='# of Digits', required=True, default=6, help="No. of Digits to use for account code")
+ visible = fields.Boolean(string='Can be Visible?', default=True,
+ help="Set this to False if you don't want this template to be used actively in the wizard that generate Chart of Accounts from "
+ "templates, this is useful when you want to generate accounts of this template only when loading its child template.")
+ currency_id = fields.Many2one('res.currency', string='Currency', required=True)
+ use_anglo_saxon = fields.Boolean(string="Use Anglo-Saxon accounting", default=False)
+ complete_tax_set = fields.Boolean(string='Complete Set of Taxes', default=True,
+ help="This boolean helps you to choose if you want to propose to the user to encode the sale and purchase rates or choose from list "
+ "of taxes. This last choice assumes that the set of tax defined on this template is complete")
+ account_ids = fields.One2many('account.account.template', 'chart_template_id', string='Associated Account Templates')
+ tax_template_ids = fields.One2many('account.tax.template', 'chart_template_id', string='Tax Template List',
+ help='List of all the taxes that have to be installed by the wizard')
+ bank_account_code_prefix = fields.Char(string='Prefix of the bank accounts', required=True)
+ cash_account_code_prefix = fields.Char(string='Prefix of the main cash accounts', required=True)
+ transfer_account_code_prefix = fields.Char(string='Prefix of the main transfer accounts', required=True)
+ income_currency_exchange_account_id = fields.Many2one('account.account.template',
+ string="Gain Exchange Rate Account", domain=[('internal_type', '=', 'other'), ('deprecated', '=', False)])
+ expense_currency_exchange_account_id = fields.Many2one('account.account.template',
+ string="Loss Exchange Rate Account", domain=[('internal_type', '=', 'other'), ('deprecated', '=', False)])
+ account_journal_suspense_account_id = fields.Many2one('account.account.template', string='Journal Suspense Account')
+ default_cash_difference_income_account_id = fields.Many2one('account.account.template', string="Cash Difference Income Account")
+ default_cash_difference_expense_account_id = fields.Many2one('account.account.template', string="Cash Difference Expense Account")
+ default_pos_receivable_account_id = fields.Many2one('account.account.template', string="PoS receivable account")
+ property_account_receivable_id = fields.Many2one('account.account.template', string='Receivable Account')
+ property_account_payable_id = fields.Many2one('account.account.template', string='Payable Account')
+ property_account_expense_categ_id = fields.Many2one('account.account.template', string='Category of Expense Account')
+ property_account_income_categ_id = fields.Many2one('account.account.template', string='Category of Income Account')
+ property_account_expense_id = fields.Many2one('account.account.template', string='Expense Account on Product Template')
+ property_account_income_id = fields.Many2one('account.account.template', string='Income Account on Product Template')
+ property_stock_account_input_categ_id = fields.Many2one('account.account.template', string="Input Account for Stock Valuation")
+ property_stock_account_output_categ_id = fields.Many2one('account.account.template', string="Output Account for Stock Valuation")
+ property_stock_valuation_account_id = fields.Many2one('account.account.template', string="Account Template for Stock Valuation")
+ 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):
+ ''' Prepare values to create the transfer account that is an intermediary account used when moving money
+ from a liquidity account to another.
+
+ :return: A dictionary of values to create a new account.account.
+ '''
+ digits = self.code_digits
+ prefix = prefix or self.transfer_account_code_prefix or ''
+ # Flatten the hierarchy of chart templates.
+ chart_template = self
+ chart_templates = self
+ while chart_template.parent_id:
+ chart_templates += chart_template.parent_id
+ chart_template = chart_template.parent_id
+ new_code = ''
+ for num in range(1, 100):
+ new_code = str(prefix.ljust(digits - 1, '0')) + str(num)
+ rec = self.env['account.account.template'].search(
+ [('code', '=', new_code), ('chart_template_id', 'in', chart_templates.ids)], limit=1)
+ if not rec:
+ break
+ else:
+ raise UserError(_('Cannot generate an unused account code.'))
+ current_assets_type = self.env.ref('account.data_account_type_current_assets', raise_if_not_found=False)
+ return {
+ 'name': _('Liquidity Transfer'),
+ 'code': new_code,
+ 'user_type_id': current_assets_type and current_assets_type.id or False,
+ 'reconcile': True,
+ 'chart_template_id': self.id,
+ }
+
+ @api.model
+ def _create_liquidity_journal_suspense_account(self, company, code_digits):
+ return self.env['account.account'].create({
+ 'name': _("Bank Suspense Account"),
+ 'code': self.env['account.account']._search_new_account_code(company, code_digits, company.bank_account_code_prefix or ''),
+ 'user_type_id': self.env.ref('account.data_account_type_current_liabilities').id,
+ 'company_id': company.id,
+ })
+
+ def try_loading(self, company=False):
+ """ Installs this chart of accounts for the current company if not chart
+ of accounts had been created for it yet.
+ """
+ # do not use `request.env` here, it can cause deadlocks
+ if not company:
+ if request and hasattr(request, 'allowed_company_ids'):
+ company = self.env['res.company'].browse(request.allowed_company_ids[0])
+ else:
+ company = self.env.company
+ # If we don't have any chart of account on this company, install this chart of account
+ if not company.chart_template_id and not self.existing_accounting(company):
+ for template in self:
+ template.with_context(default_company_id=company.id)._load(15.0, 15.0, company)
+
+
+ def _load(self, sale_tax_rate, purchase_tax_rate, company):
+ """ Installs this chart of accounts on the current company, replacing
+ the existing one if it had already one defined. If some accounting entries
+ had already been made, this function fails instead, triggering a UserError.
+
+ Also, note that this function can only be run by someone with administration
+ rights.
+ """
+ self.ensure_one()
+ # do not use `request.env` here, it can cause deadlocks
+ # Ensure everything is translated to the company's language, not the user's one.
+ self = self.with_context(lang=company.partner_id.lang).with_company(company)
+ if not self.env.is_admin():
+ raise AccessError(_("Only administrators can load a chart of accounts"))
+
+ existing_accounts = self.env['account.account'].search([('company_id', '=', company.id)])
+ if existing_accounts:
+ # we tolerate switching from accounting package (localization module) as long as there isn't yet any accounting
+ # entries created for the company.
+ if self.existing_accounting(company):
+ raise UserError(_('Could not install new chart of account as there are already accounting entries existing.'))
+
+ # delete accounting properties
+ prop_values = ['account.account,%s' % (account_id,) for account_id in existing_accounts.ids]
+ existing_journals = self.env['account.journal'].search([('company_id', '=', company.id)])
+ if existing_journals:
+ prop_values.extend(['account.journal,%s' % (journal_id,) for journal_id in existing_journals.ids])
+ self.env['ir.property'].sudo().search(
+ [('value_reference', 'in', prop_values)]
+ ).unlink()
+
+ # delete account, journal, tax, fiscal position and reconciliation model
+ models_to_delete = ['account.reconcile.model', 'account.fiscal.position', 'account.tax', 'account.move', 'account.journal', 'account.group']
+ for model in models_to_delete:
+ res = self.env[model].sudo().search([('company_id', '=', company.id)])
+ if len(res):
+ res.unlink()
+ existing_accounts.unlink()
+
+ company.write({'currency_id': self.currency_id.id,
+ 'anglo_saxon_accounting': self.use_anglo_saxon,
+ 'bank_account_code_prefix': self.bank_account_code_prefix,
+ 'cash_account_code_prefix': self.cash_account_code_prefix,
+ 'transfer_account_code_prefix': self.transfer_account_code_prefix,
+ 'chart_template_id': self.id
+ })
+
+ #set the coa currency to active
+ self.currency_id.write({'active': True})
+
+ # When we install the CoA of first company, set the currency to price types and pricelists
+ if company.id == 1:
+ for reference in ['product.list_price', 'product.standard_price', 'product.list0']:
+ try:
+ tmp2 = self.env.ref(reference).write({'currency_id': self.currency_id.id})
+ except ValueError:
+ pass
+
+ # If the floats for sale/purchase rates have been filled, create templates from them
+ self._create_tax_templates_from_rates(company.id, sale_tax_rate, purchase_tax_rate)
+
+ # Install all the templates objects and generate the real objects
+ acc_template_ref, taxes_ref = self._install_template(company, code_digits=self.code_digits)
+
+ # Set default cash difference account on company
+ if not company.account_journal_suspense_account_id:
+ company.account_journal_suspense_account_id = self._create_liquidity_journal_suspense_account(company, self.code_digits)
+
+ if not company.default_cash_difference_expense_account_id:
+ company.default_cash_difference_expense_account_id = self.env['account.account'].create({
+ 'name': _('Cash Difference Loss'),
+ 'code': self.env['account.account']._search_new_account_code(company, self.code_digits, '999'),
+ 'user_type_id': self.env.ref('account.data_account_type_expenses').id,
+ 'tag_ids': [(6, 0, self.env.ref('account.account_tag_investing').ids)],
+ 'company_id': company.id,
+ })
+
+ if not company.default_cash_difference_income_account_id:
+ company.default_cash_difference_income_account_id = self.env['account.account'].create({
+ 'name': _('Cash Difference Gain'),
+ 'code': self.env['account.account']._search_new_account_code(company, self.code_digits, '999'),
+ 'user_type_id': self.env.ref('account.data_account_type_revenue').id,
+ 'tag_ids': [(6, 0, self.env.ref('account.account_tag_investing').ids)],
+ 'company_id': company.id,
+ })
+
+ # Set the transfer account on the company
+ company.transfer_account_id = self.env['account.account'].search([
+ ('code', '=like', self.transfer_account_code_prefix + '%'), ('company_id', '=', company.id)], limit=1)
+
+ # Create Bank journals
+ self._create_bank_journals(company, acc_template_ref)
+
+ # Create the current year earning account if it wasn't present in the CoA
+ company.get_unaffected_earnings_account()
+
+ # set the default taxes on the company
+ company.account_sale_tax_id = self.env['account.tax'].search([('type_tax_use', 'in', ('sale', 'all')), ('company_id', '=', company.id)], limit=1).id
+ company.account_purchase_tax_id = self.env['account.tax'].search([('type_tax_use', 'in', ('purchase', 'all')), ('company_id', '=', company.id)], limit=1).id
+
+ return {}
+
+ @api.model
+ def existing_accounting(self, company_id):
+ """ Returns True iff some accounting entries have already been made for
+ the provided company (meaning hence that its chart of accounts cannot
+ be changed anymore).
+ """
+ model_to_check = ['account.move.line', 'account.payment', 'account.bank.statement']
+ for model in model_to_check:
+ if self.env[model].sudo().search([('company_id', '=', company_id.id)], limit=1):
+ return True
+ if self.env['account.move'].sudo().search([('company_id', '=', company_id.id), ('name', '!=', '/')], limit=1):
+ return True
+ return False
+
+ def _create_tax_templates_from_rates(self, company_id, sale_tax_rate, purchase_tax_rate):
+ '''
+ This function checks if this chart template is configured as containing a full set of taxes, and if
+ it's not the case, it creates the templates for account.tax object accordingly to the provided sale/purchase rates.
+ Then it saves the new tax templates as default taxes to use for this chart template.
+
+ :param company_id: id of the company for which the wizard is running
+ :param sale_tax_rate: the rate to use for created sales tax
+ :param purchase_tax_rate: the rate to use for created purchase tax
+ :return: True
+ '''
+ self.ensure_one()
+ obj_tax_temp = self.env['account.tax.template']
+ all_parents = self._get_chart_parent_ids()
+ # create tax templates from purchase_tax_rate and sale_tax_rate fields
+ if not self.complete_tax_set:
+ ref_taxs = obj_tax_temp.search([('type_tax_use', '=', 'sale'), ('chart_template_id', 'in', all_parents)], order="sequence, id desc", limit=1)
+ ref_taxs.write({'amount': sale_tax_rate, 'name': _('Tax %.2f%%') % sale_tax_rate, 'description': '%.2f%%' % sale_tax_rate})
+ ref_taxs = obj_tax_temp.search([('type_tax_use', '=', 'purchase'), ('chart_template_id', 'in', all_parents)], order="sequence, id desc", limit=1)
+ ref_taxs.write({'amount': purchase_tax_rate, 'name': _('Tax %.2f%%') % purchase_tax_rate, 'description': '%.2f%%' % purchase_tax_rate})
+ return True
+
+ def _get_chart_parent_ids(self):
+ """ Returns the IDs of all ancestor charts, including the chart itself.
+ (inverse of child_of operator)
+
+ :return: the IDS of all ancestor charts, including the chart itself.
+ """
+ chart_template = self
+ result = [chart_template.id]
+ while chart_template.parent_id:
+ chart_template = chart_template.parent_id
+ result.append(chart_template.id)
+ return result
+
+ def _create_bank_journals(self, company, acc_template_ref):
+ '''
+ This function creates bank journals and their account for each line
+ data returned by the function _get_default_bank_journals_data.
+
+ :param company: the company for which the wizard is running.
+ :param acc_template_ref: the dictionary containing the mapping between the ids of account templates and the ids
+ of the accounts that have been generated from them.
+ '''
+ self.ensure_one()
+ bank_journals = self.env['account.journal']
+ # Create the journals that will trigger the account.account creation
+ for acc in self._get_default_bank_journals_data():
+ bank_journals += self.env['account.journal'].create({
+ 'name': acc['acc_name'],
+ 'type': acc['account_type'],
+ 'company_id': company.id,
+ 'currency_id': acc.get('currency_id', self.env['res.currency']).id,
+ 'sequence': 10,
+ })
+
+ return bank_journals
+
+ @api.model
+ def _get_default_bank_journals_data(self):
+ """ Returns the data needed to create the default bank journals when
+ installing this chart of accounts, in the form of a list of dictionaries.
+ The allowed keys in these dictionaries are:
+ - acc_name: string (mandatory)
+ - account_type: 'cash' or 'bank' (mandatory)
+ - currency_id (optional, only to be specified if != company.currency_id)
+ """
+ return [{'acc_name': _('Cash'), 'account_type': 'cash'}, {'acc_name': _('Bank'), 'account_type': 'bank'}]
+
+ def open_select_template_wizard(self):
+ # Add action to open wizard to select between several templates
+ if not self.company_id.chart_template_id:
+ todo = self.env['ir.actions.todo']
+ action_rec = self.env['ir.model.data'].xmlid_to_object('account.action_wizard_multi_chart')
+ if action_rec:
+ todo.create({'action_id': action_rec.id, 'name': _('Choose Accounting Template')})
+ return True
+
+ @api.model
+ def _prepare_transfer_account_for_direct_creation(self, name, company):
+ """ Prepare values to create a transfer account directly, based on the
+ method _prepare_transfer_account_template().
+
+ This is needed when dealing with installation of payment modules
+ that requires the creation of their own transfer account.
+
+ :param name: The transfer account name.
+ :param company: The company owning this account.
+ :return: A dictionary of values to create a new account.account.
+ """
+ vals = self._prepare_transfer_account_template()
+ digits = self.code_digits or 6
+ prefix = self.transfer_account_code_prefix or ''
+ vals.update({
+ 'code': self.env['account.account']._search_new_account_code(company, digits, prefix),
+ 'name': name,
+ 'company_id': company.id,
+ })
+ del(vals['chart_template_id'])
+ return vals
+
+ @api.model
+ def generate_journals(self, acc_template_ref, company, journals_dict=None):
+ """
+ This method is used for creating journals.
+
+ :param acc_template_ref: Account templates reference.
+ :param company_id: company to generate journals for.
+ :returns: True
+ """
+ JournalObj = self.env['account.journal']
+ for vals_journal in self._prepare_all_journals(acc_template_ref, company, journals_dict=journals_dict):
+ journal = JournalObj.create(vals_journal)
+ if vals_journal['type'] == 'general' and vals_journal['code'] == _('EXCH'):
+ company.write({'currency_exchange_journal_id': journal.id})
+ if vals_journal['type'] == 'general' and vals_journal['code'] == _('CABA'):
+ company.write({'tax_cash_basis_journal_id': journal.id})
+ return True
+
+ def _prepare_all_journals(self, acc_template_ref, company, journals_dict=None):
+ def _get_default_account(journal_vals, type='debit'):
+ # Get the default accounts
+ default_account = False
+ if journal['type'] == 'sale':
+ default_account = acc_template_ref.get(self.property_account_income_categ_id.id)
+ elif journal['type'] == 'purchase':
+ default_account = acc_template_ref.get(self.property_account_expense_categ_id.id)
+
+ return default_account
+
+ journals = [{'name': _('Customer Invoices'), 'type': 'sale', 'code': _('INV'), 'favorite': True, 'color': 11, 'sequence': 5},
+ {'name': _('Vendor Bills'), 'type': 'purchase', 'code': _('BILL'), 'favorite': True, 'color': 11, 'sequence': 6},
+ {'name': _('Miscellaneous Operations'), 'type': 'general', 'code': _('MISC'), 'favorite': True, 'sequence': 7},
+ {'name': _('Exchange Difference'), 'type': 'general', 'code': _('EXCH'), 'favorite': False, 'sequence': 9},
+ {'name': _('Cash Basis Taxes'), 'type': 'general', 'code': _('CABA'), 'favorite': False, 'sequence': 10}]
+ if journals_dict != None:
+ journals.extend(journals_dict)
+
+ self.ensure_one()
+ journal_data = []
+ for journal in journals:
+ vals = {
+ 'type': journal['type'],
+ 'name': journal['name'],
+ 'code': journal['code'],
+ 'company_id': company.id,
+ 'default_account_id': _get_default_account(journal),
+ 'show_on_dashboard': journal['favorite'],
+ 'color': journal.get('color', False),
+ 'sequence': journal['sequence']
+ }
+ journal_data.append(vals)
+ return journal_data
+
+ def generate_properties(self, acc_template_ref, company):
+ """
+ This method used for creating properties.
+
+ :param acc_template_ref: Mapping between ids of account templates and real accounts created from them
+ :param company_id: company to generate properties for.
+ :returns: True
+ """
+ self.ensure_one()
+ PropertyObj = self.env['ir.property']
+ todo_list = [
+ ('property_account_receivable_id', 'res.partner'),
+ ('property_account_payable_id', 'res.partner'),
+ ('property_account_expense_categ_id', 'product.category'),
+ ('property_account_income_categ_id', 'product.category'),
+ ('property_account_expense_id', 'product.template'),
+ ('property_account_income_id', 'product.template'),
+ ('property_tax_payable_account_id', 'account.tax.group'),
+ ('property_tax_receivable_account_id', 'account.tax.group'),
+ ('property_advance_tax_payment_account_id', 'account.tax.group'),
+ ]
+ for field, model in todo_list:
+ account = self[field]
+ value = acc_template_ref[account.id] if account else False
+ if value:
+ PropertyObj._set_default(field, model, value, company=company)
+
+ stock_properties = [
+ 'property_stock_account_input_categ_id',
+ 'property_stock_account_output_categ_id',
+ 'property_stock_valuation_account_id',
+ ]
+ for stock_property in stock_properties:
+ account = getattr(self, stock_property)
+ value = account and acc_template_ref[account.id] or False
+ if value:
+ company.write({stock_property: value})
+ return True
+
+ def _install_template(self, company, code_digits=None, obj_wizard=None, acc_ref=None, taxes_ref=None):
+ """ Recursively load the template objects and create the real objects from them.
+
+ :param company: company the wizard is running for
+ :param code_digits: number of digits the accounts code should have in the COA
+ :param obj_wizard: the current wizard for generating the COA from the templates
+ :param acc_ref: Mapping between ids of account templates and real accounts created from them
+ :param taxes_ref: Mapping between ids of tax templates and real taxes created from them
+ :returns: tuple with a dictionary containing
+ * the mapping between the account template ids and the ids of the real accounts that have been generated
+ from them, as first item,
+ * a similar dictionary for mapping the tax templates and taxes, as second item,
+ :rtype: tuple(dict, dict, dict)
+ """
+ self.ensure_one()
+ if acc_ref is None:
+ acc_ref = {}
+ if taxes_ref is None:
+ taxes_ref = {}
+ if self.parent_id:
+ tmp1, tmp2 = self.parent_id._install_template(company, code_digits=code_digits, acc_ref=acc_ref, taxes_ref=taxes_ref)
+ acc_ref.update(tmp1)
+ taxes_ref.update(tmp2)
+ # Ensure, even if individually, that everything is translated according to the company's language.
+ tmp1, tmp2 = self.with_context(lang=company.partner_id.lang)._load_template(company, code_digits=code_digits, account_ref=acc_ref, taxes_ref=taxes_ref)
+ acc_ref.update(tmp1)
+ taxes_ref.update(tmp2)
+ return acc_ref, taxes_ref
+
+ def _load_template(self, company, code_digits=None, account_ref=None, taxes_ref=None):
+ """ Generate all the objects from the templates
+
+ :param company: company the wizard is running for
+ :param code_digits: number of digits the accounts code should have in the COA
+ :param acc_ref: Mapping between ids of account templates and real accounts created from them
+ :param taxes_ref: Mapping between ids of tax templates and real taxes created from them
+ :returns: tuple with a dictionary containing
+ * the mapping between the account template ids and the ids of the real accounts that have been generated
+ from them, as first item,
+ * a similar dictionary for mapping the tax templates and taxes, as second item,
+ :rtype: tuple(dict, dict, dict)
+ """
+ self.ensure_one()
+ if account_ref is None:
+ account_ref = {}
+ if taxes_ref is None:
+ taxes_ref = {}
+ if not code_digits:
+ code_digits = self.code_digits
+ AccountTaxObj = self.env['account.tax']
+
+ # Generate taxes from templates.
+ generated_tax_res = self.with_context(active_test=False).tax_template_ids._generate_tax(company)
+ taxes_ref.update(generated_tax_res['tax_template_to_tax'])
+
+ # Generating Accounts from templates.
+ account_template_ref = self.generate_account(taxes_ref, account_ref, code_digits, company)
+ account_ref.update(account_template_ref)
+
+ # Generate account groups, from template
+ self.generate_account_groups(company)
+
+ # 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']:
+ AccountTaxObj.browse(key).write({
+ 'cash_basis_transition_account_id': account_ref.get(value['cash_basis_transition_account_id'], False),
+ })
+
+ AccountTaxRepartitionLineObj = self.env['account.tax.repartition.line']
+ for key, value in generated_tax_res['account_dict']['account.tax.repartition.line'].items():
+ if value['account_id']:
+ AccountTaxRepartitionLineObj.browse(key).write({
+ 'account_id': account_ref.get(value['account_id']),
+ })
+
+ # Set the company accounts
+ self._load_company_accounts(account_ref, company)
+
+ # Create Journals - Only done for root chart template
+ if not self.parent_id:
+ self.generate_journals(account_ref, company)
+
+ # generate properties function
+ self.generate_properties(account_ref, company)
+
+ # Generate Fiscal Position , Fiscal Position Accounts and Fiscal Position Taxes from templates
+ self.generate_fiscal_position(taxes_ref, account_ref, company)
+
+ # Generate account operation template templates
+ self.generate_account_reconcile_model(taxes_ref, account_ref, company)
+
+ return account_ref, taxes_ref
+
+ def _load_company_accounts(self, account_ref, company):
+ # Set the default accounts on the company
+ accounts = {
+ 'default_cash_difference_income_account_id': self.default_cash_difference_income_account_id.id,
+ 'default_cash_difference_expense_account_id': self.default_cash_difference_expense_account_id.id,
+ 'account_journal_suspense_account_id': self.account_journal_suspense_account_id.id,
+ 'account_cash_basis_base_account_id': self.property_cash_basis_base_account_id.id,
+ 'account_default_pos_receivable_account_id': self.default_pos_receivable_account_id.id,
+ 'income_currency_exchange_account_id': self.income_currency_exchange_account_id.id,
+ 'expense_currency_exchange_account_id': self.expense_currency_exchange_account_id.id,
+ }
+
+ values = {}
+
+ # The loop is to avoid writing when we have no values, thus avoiding erasing the account from the parent
+ for key, account in accounts.items():
+ if account_ref.get(account):
+ values[key] = account_ref.get(account)
+
+ company.write(values)
+
+ def create_record_with_xmlid(self, company, template, model, vals):
+ return self._create_records_with_xmlid(model, [(template, vals)], company).id
+
+ def _create_records_with_xmlid(self, model, template_vals, company):
+ """ Create records for the given model name with the given vals, and
+ create xml ids based on each record's template and company id.
+ """
+ if not template_vals:
+ return self.env[model]
+ template_model = template_vals[0][0]
+ template_ids = [template.id for template, vals in template_vals]
+ template_xmlids = template_model.browse(template_ids).get_external_id()
+ data_list = []
+ for template, vals in template_vals:
+ module, name = template_xmlids[template.id].split('.', 1)
+ xml_id = "%s.%s_%s" % (module, company.id, name)
+ data_list.append(dict(xml_id=xml_id, values=vals, noupdate=True))
+ return self.env[model]._load_records(data_list)
+
+ @api.model
+ def _load_records(self, data_list, update=False):
+ # When creating a chart template create, for the liquidity transfer account
+ # - an account.account.template: this allow to define account.reconcile.model.template objects refering that liquidity transfer
+ # account although it's not existing in any xml file
+ # - an entry in ir_model_data: this allow to still use the method create_record_with_xmlid() and don't make any difference between
+ # regular accounts created and that liquidity transfer account
+ records = super(AccountChartTemplate, self)._load_records(data_list, update)
+ account_data_list = []
+ for data, record in zip(data_list, records):
+ # Create the transfer account only for leaf chart template in the hierarchy.
+ if record.parent_id:
+ continue
+ if data.get('xml_id'):
+ account_xml_id = data['xml_id'] + '_liquidity_transfer'
+ if not self.env.ref(account_xml_id, raise_if_not_found=False):
+ account_vals = record._prepare_transfer_account_template()
+ account_data_list.append(dict(
+ xml_id=account_xml_id,
+ values=account_vals,
+ noupdate=data.get('noupdate'),
+ ))
+ self.env['account.account.template']._load_records(account_data_list, update)
+ return records
+
+ def _get_account_vals(self, company, account_template, code_acc, tax_template_ref):
+ """ This method generates a dictionary of all the values for the account that will be created.
+ """
+ self.ensure_one()
+ tax_ids = []
+ for tax in account_template.tax_ids:
+ tax_ids.append(tax_template_ref[tax.id])
+ val = {
+ 'name': account_template.name,
+ 'currency_id': account_template.currency_id and account_template.currency_id.id or False,
+ 'code': code_acc,
+ 'user_type_id': account_template.user_type_id and account_template.user_type_id.id or False,
+ 'reconcile': account_template.reconcile,
+ 'note': account_template.note,
+ 'tax_ids': [(6, 0, tax_ids)],
+ 'company_id': company.id,
+ 'tag_ids': [(6, 0, [t.id for t in account_template.tag_ids])],
+ }
+ return val
+
+ def generate_account(self, tax_template_ref, acc_template_ref, code_digits, company):
+ """ This method generates accounts from account templates.
+
+ :param tax_template_ref: Taxes templates reference for write taxes_id in account_account.
+ :param acc_template_ref: dictionary containing the mapping between the account templates and generated accounts (will be populated)
+ :param code_digits: number of digits to use for account code.
+ :param company_id: company to generate accounts for.
+ :returns: return acc_template_ref for reference purpose.
+ :rtype: dict
+ """
+ self.ensure_one()
+ account_tmpl_obj = self.env['account.account.template']
+ acc_template = account_tmpl_obj.search([('nocreate', '!=', True), ('chart_template_id', '=', self.id)], order='id')
+ template_vals = []
+ for account_template in acc_template:
+ code_main = account_template.code and len(account_template.code) or 0
+ code_acc = account_template.code or ''
+ if code_main > 0 and code_main <= code_digits:
+ code_acc = str(code_acc) + (str('0'*(code_digits-code_main)))
+ vals = self._get_account_vals(company, account_template, code_acc, tax_template_ref)
+ template_vals.append((account_template, vals))
+ accounts = self._create_records_with_xmlid('account.account', template_vals, company)
+ for template, account in zip(acc_template, accounts):
+ acc_template_ref[template.id] = account.id
+ return acc_template_ref
+
+ def generate_account_groups(self, company):
+ """ This method generates account groups from account groups templates.
+ :param company: company to generate the account groups for
+ """
+ self.ensure_one()
+ group_templates = self.env['account.group.template'].search([('chart_template_id', '=', self.id)])
+ template_vals = []
+ for group_template in group_templates:
+ vals = {
+ 'name': group_template.name,
+ 'code_prefix_start': group_template.code_prefix_start,
+ 'code_prefix_end': group_template.code_prefix_end,
+ 'company_id': company.id,
+ }
+ template_vals.append((group_template, vals))
+ groups = self._create_records_with_xmlid('account.group', template_vals, company)
+
+ def _prepare_reconcile_model_vals(self, company, account_reconcile_model, acc_template_ref, tax_template_ref):
+ """ This method generates a dictionary of all the values for the account.reconcile.model that will be created.
+ """
+ self.ensure_one()
+ account_reconcile_model_lines = self.env['account.reconcile.model.line.template'].search([
+ ('model_id', '=', account_reconcile_model.id)
+ ])
+ return {
+ 'name': account_reconcile_model.name,
+ 'sequence': account_reconcile_model.sequence,
+ 'company_id': company.id,
+ 'rule_type': account_reconcile_model.rule_type,
+ 'auto_reconcile': account_reconcile_model.auto_reconcile,
+ 'to_check': account_reconcile_model.to_check,
+ 'match_journal_ids': [(6, None, account_reconcile_model.match_journal_ids.ids)],
+ 'match_nature': account_reconcile_model.match_nature,
+ 'match_amount': account_reconcile_model.match_amount,
+ 'match_amount_min': account_reconcile_model.match_amount_min,
+ 'match_amount_max': account_reconcile_model.match_amount_max,
+ 'match_label': account_reconcile_model.match_label,
+ 'match_label_param': account_reconcile_model.match_label_param,
+ 'match_note': account_reconcile_model.match_note,
+ 'match_note_param': account_reconcile_model.match_note_param,
+ 'match_transaction_type': account_reconcile_model.match_transaction_type,
+ 'match_transaction_type_param': account_reconcile_model.match_transaction_type_param,
+ 'match_same_currency': account_reconcile_model.match_same_currency,
+ 'match_total_amount': account_reconcile_model.match_total_amount,
+ 'match_total_amount_param': account_reconcile_model.match_total_amount_param,
+ 'match_partner': account_reconcile_model.match_partner,
+ 'match_partner_ids': [(6, None, account_reconcile_model.match_partner_ids.ids)],
+ 'match_partner_category_ids': [(6, None, account_reconcile_model.match_partner_category_ids.ids)],
+ 'line_ids': [(0, 0, {
+ 'account_id': acc_template_ref[line.account_id.id],
+ 'label': line.label,
+ 'amount_type': line.amount_type,
+ 'force_tax_included': line.force_tax_included,
+ 'amount_string': line.amount_string,
+ 'tax_ids': [[4, tax_template_ref[tax.id], 0] for tax in line.tax_ids],
+ }) for line in account_reconcile_model_lines],
+ }
+
+ def generate_account_reconcile_model(self, tax_template_ref, acc_template_ref, company):
+ """ This method creates account reconcile models
+
+ :param tax_template_ref: Taxes templates reference for write taxes_id in account_account.
+ :param acc_template_ref: dictionary with the mapping between the account templates and the real accounts.
+ :param company_id: company to create models for
+ :returns: return new_account_reconcile_model for reference purpose.
+ :rtype: dict
+ """
+ self.ensure_one()
+ account_reconcile_models = self.env['account.reconcile.model.template'].search([
+ ('chart_template_id', '=', self.id)
+ ])
+ for account_reconcile_model in account_reconcile_models:
+ vals = self._prepare_reconcile_model_vals(company, account_reconcile_model, acc_template_ref, tax_template_ref)
+ self.create_record_with_xmlid(company, account_reconcile_model, 'account.reconcile.model', vals)
+ # Create a default rule for the reconciliation widget matching invoices automatically.
+ self.env['account.reconcile.model'].sudo().create({
+ "name": _('Invoices Matching Rule'),
+ "sequence": '1',
+ "rule_type": 'invoice_matching',
+ "auto_reconcile": False,
+ "match_nature": 'both',
+ "match_same_currency": True,
+ "match_total_amount": True,
+ "match_total_amount_param": 100,
+ "match_partner": True,
+ "company_id": company.id,
+ })
+ return True
+
+ def _get_fp_vals(self, company, position):
+ return {
+ 'company_id': company.id,
+ 'sequence': position.sequence,
+ 'name': position.name,
+ 'note': position.note,
+ 'auto_apply': position.auto_apply,
+ 'vat_required': position.vat_required,
+ 'country_id': position.country_id.id,
+ 'country_group_id': position.country_group_id.id,
+ 'state_ids': position.state_ids and [(6,0, position.state_ids.ids)] or [],
+ 'zip_from': position.zip_from,
+ 'zip_to': position.zip_to,
+ }
+
+ def generate_fiscal_position(self, tax_template_ref, acc_template_ref, company):
+ """ This method generates Fiscal Position, Fiscal Position Accounts
+ and Fiscal Position Taxes from templates.
+
+ :param taxes_ids: Taxes templates reference for generating account.fiscal.position.tax.
+ :param acc_template_ref: Account templates reference for generating account.fiscal.position.account.
+ :param company_id: the company to generate fiscal position data for
+ :returns: True
+ """
+ self.ensure_one()
+ positions = self.env['account.fiscal.position.template'].search([('chart_template_id', '=', self.id)])
+
+ # first create fiscal positions in batch
+ template_vals = []
+ for position in positions:
+ fp_vals = self._get_fp_vals(company, position)
+ template_vals.append((position, fp_vals))
+ fps = self._create_records_with_xmlid('account.fiscal.position', template_vals, company)
+
+ # then create fiscal position taxes and accounts
+ tax_template_vals = []
+ account_template_vals = []
+ for position, fp in zip(positions, fps):
+ for tax in position.tax_ids:
+ tax_template_vals.append((tax, {
+ 'tax_src_id': tax_template_ref[tax.tax_src_id.id],
+ 'tax_dest_id': tax.tax_dest_id and tax_template_ref[tax.tax_dest_id.id] or False,
+ 'position_id': fp.id,
+ }))
+ for acc in position.account_ids:
+ account_template_vals.append((acc, {
+ 'account_src_id': acc_template_ref[acc.account_src_id.id],
+ 'account_dest_id': acc_template_ref[acc.account_dest_id.id],
+ 'position_id': fp.id,
+ }))
+ self._create_records_with_xmlid('account.fiscal.position.tax', tax_template_vals, company)
+ self._create_records_with_xmlid('account.fiscal.position.account', account_template_vals, company)
+
+ return True
+
+
+class AccountTaxTemplate(models.Model):
+ _name = 'account.tax.template'
+ _description = 'Templates for Taxes'
+ _order = 'id'
+
+ chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True)
+
+ name = fields.Char(string='Tax Name', required=True)
+ type_tax_use = fields.Selection(TYPE_TAX_USE, string='Tax Type', 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_scope = fields.Selection([('service', 'Service'), ('consu', 'Consumable')], help="Restrict the use of taxes to a type of product.")
+ 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.")
+ children_tax_ids = fields.Many2many('account.tax.template', 'account_tax_template_filiation_rel', 'parent_tax', 'child_tax', string='Children Taxes')
+ sequence = fields.Integer(required=True, default=1,
+ help="The sequence field is used to define order in which the tax lines are applied.")
+ amount = fields.Float(required=True, digits=(16, 4), default=0)
+ description = fields.Char(string='Display on Invoices')
+ price_include = fields.Boolean(string='Included in Price', default=False,
+ help="Check this if the price you use on the product and invoices includes this tax.")
+ include_base_amount = fields.Boolean(string='Affect Subsequent Taxes', default=False,
+ help="If set, taxes which are computed after this one will be computed based on the price tax included.")
+ 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)")
+ invoice_repartition_line_ids = fields.One2many(string="Repartition for Invoices", comodel_name="account.tax.repartition.line.template", 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.template", inverse_name="refund_tax_id", copy=True, help="Repartition when the tax is used on a refund")
+ tax_group_id = fields.Many2one('account.tax.group', string="Tax Group")
+ tax_exigibility = fields.Selection(
+ [('on_invoice', 'Based on Invoice'),
+ ('on_payment', 'Based on Payment'),
+ ], string='Tax Due', default='on_invoice',
+ help="Based on Invoice: the tax is due as soon as the invoice is validated.\n"
+ "Based on Payment: the tax is due as soon as the payment of the invoice is received.")
+ cash_basis_transition_account_id = fields.Many2one(
+ comodel_name='account.account.template',
+ 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.")
+
+ _sql_constraints = [
+ ('name_company_uniq', 'unique(name, type_tax_use, tax_scope, chart_template_id)', 'Tax names must be unique !'),
+ ]
+
+ @api.depends('name', 'description')
+ def name_get(self):
+ res = []
+ for record in self:
+ name = record.description and record.description or record.name
+ res.append((record.id, name))
+ return res
+
+ def _get_tax_vals(self, company, tax_template_to_tax):
+ """ This method generates a dictionary of all the values for the tax that will be created.
+ """
+ # Compute children tax ids
+ children_ids = []
+ for child_tax in self.children_tax_ids:
+ if tax_template_to_tax.get(child_tax.id):
+ children_ids.append(tax_template_to_tax[child_tax.id])
+ self.ensure_one()
+ val = {
+ 'name': self.name,
+ 'type_tax_use': self.type_tax_use,
+ 'tax_scope': self.tax_scope,
+ 'amount_type': self.amount_type,
+ 'active': self.active,
+ 'company_id': company.id,
+ 'sequence': self.sequence,
+ 'amount': self.amount,
+ 'description': self.description,
+ 'price_include': self.price_include,
+ 'include_base_amount': self.include_base_amount,
+ 'analytic': self.analytic,
+ 'children_tax_ids': [(6, 0, children_ids)],
+ 'tax_exigibility': self.tax_exigibility,
+ }
+
+ # We add repartition lines if there are some, so that if there are none,
+ # default_get is called and creates the default ones properly.
+ if self.invoice_repartition_line_ids:
+ val['invoice_repartition_line_ids'] = self.invoice_repartition_line_ids.get_repartition_line_create_vals(company)
+ if self.refund_repartition_line_ids:
+ val['refund_repartition_line_ids'] = self.refund_repartition_line_ids.get_repartition_line_create_vals(company)
+
+ if self.tax_group_id:
+ val['tax_group_id'] = self.tax_group_id.id
+ return val
+
+ def _generate_tax(self, company):
+ """ This method generate taxes from templates.
+
+ :param company: the company for which the taxes should be created from templates in self
+ :returns: {
+ 'tax_template_to_tax': mapping between tax template and the newly generated taxes corresponding,
+ 'account_dict': dictionary containing a to-do list with all the accounts to assign on new taxes
+ }
+ """
+ # default_company_id is needed in context to allow creation of default
+ # repartition lines on taxes
+ ChartTemplate = self.env['account.chart.template'].with_context(default_company_id=company.id)
+ todo_dict = {'account.tax': {}, 'account.tax.repartition.line': {}}
+ tax_template_to_tax = {}
+
+ templates_todo = list(self)
+ while templates_todo:
+ templates = templates_todo
+ templates_todo = []
+
+ # create taxes in batch
+ tax_template_vals = []
+ for template in templates:
+ if all(child.id in tax_template_to_tax for child in template.children_tax_ids):
+ vals = template._get_tax_vals(company, tax_template_to_tax)
+ tax_template_vals.append((template, vals))
+ else:
+ # defer the creation of this tax to the next batch
+ templates_todo.append(template)
+ taxes = ChartTemplate._create_records_with_xmlid('account.tax', tax_template_vals, company)
+
+ # fill in tax_template_to_tax and todo_dict
+ for tax, (template, vals) in zip(taxes, tax_template_vals):
+ tax_template_to_tax[template.id] = tax.id
+ # 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,
+ }
+
+ # We also have to delay the assignation of accounts to repartition lines
+ # The below code assigns the account_id to the repartition lines according
+ # to the corresponding repartition line in the template, based on the order.
+ # As we just created the repartition lines, tax.invoice_repartition_line_ids is not well sorted.
+ # But we can force the sort by calling sort()
+ all_tax_rep_lines = tax.invoice_repartition_line_ids.sorted() + tax.refund_repartition_line_ids.sorted()
+ all_template_rep_lines = template.invoice_repartition_line_ids + template.refund_repartition_line_ids
+ for i in range(0, len(all_template_rep_lines)):
+ # We assume template and tax repartition lines are in the same order
+ template_account = all_template_rep_lines[i].account_id
+ if template_account:
+ todo_dict['account.tax.repartition.line'][all_tax_rep_lines[i].id] = {
+ 'account_id': template_account.id,
+ }
+
+ if any(template.tax_exigibility == 'on_payment' for template in self):
+ # When a CoA is being installed automatically and if it is creating account tax(es) whose field `Use Cash Basis`(tax_exigibility) is set to True by default
+ # (example of such CoA's are l10n_fr and l10n_mx) then in the `Accounting Settings` the option `Cash Basis` should be checked by default.
+ company.tax_exigibility = True
+
+ return {
+ 'tax_template_to_tax': tax_template_to_tax,
+ 'account_dict': todo_dict
+ }
+
+# Tax Repartition Line Template
+
+
+class AccountTaxRepartitionLineTemplate(models.Model):
+ _name = "account.tax.repartition.line.template"
+ _description = "Tax Repartition Line Template"
+
+ factor_percent = fields.Float(string="%", required=True, help="Factor to apply on the account move lines generated from this distribution line, in percents")
+ repartition_type = fields.Selection(string="Based On", selection=[('base', 'Base'), ('tax', 'of tax')], required=True, default='tax', help="Base on which the factor will be applied.")
+ account_id = fields.Many2one(string="Account", comodel_name='account.account.template', help="Account on which to post the tax amount")
+ invoice_tax_id = fields.Many2one(comodel_name='account.tax.template', help="The tax set to apply this distribution on invoices. Mutually exclusive with refund_tax_id")
+ refund_tax_id = fields.Many2one(comodel_name='account.tax.template', help="The tax set to apply this distribution on refund invoices. Mutually exclusive with invoice_tax_id")
+ tag_ids = fields.Many2many(string="Financial Tags", relation='account_tax_repartition_financial_tags', comodel_name='account.account.tag', copy=True, help="Additional tags that will be assigned by this repartition line for use in financial reports")
+ use_in_tax_closing = fields.Boolean(string="Tax Closing Entry")
+
+ # These last two fields are helpers used to ease the declaration of account.account.tag objects in XML.
+ # They are directly linked to account.tax.report.line objects, which create corresponding + and - tags
+ # at creation. This way, we avoid declaring + and - separately every time.
+ plus_report_line_ids = fields.Many2many(string="Plus Tax Report Lines", relation='account_tax_repartition_plus_report_line', comodel_name='account.tax.report.line', copy=True, help="Tax report lines whose '+' tag will be assigned to move lines by this repartition line")
+ minus_report_line_ids = fields.Many2many(string="Minus Report Lines", relation='account_tax_repartition_minus_report_line', comodel_name='account.tax.report.line', copy=True, help="Tax report lines whose '-' tag will be assigned to move lines by this repartition line")
+
+ @api.model
+ def create(self, vals):
+ if vals.get('plus_report_line_ids'):
+ vals['plus_report_line_ids'] = self._convert_tag_syntax_to_orm(vals['plus_report_line_ids'])
+
+ if vals.get('minus_report_line_ids'):
+ vals['minus_report_line_ids'] = self._convert_tag_syntax_to_orm(vals['minus_report_line_ids'])
+
+ if vals.get('tag_ids'):
+ vals['tag_ids'] = self._convert_tag_syntax_to_orm(vals['tag_ids'])
+
+ if vals.get('use_in_tax_closing') is None:
+ if not vals.get('account_id'):
+ vals['use_in_tax_closing'] = False
+ else:
+ internal_group = self.env['account.account.template'].browse(vals.get('account_id')).user_type_id.internal_group
+ vals['use_in_tax_closing'] = not (internal_group == 'income' or internal_group == 'expense')
+
+ return super(AccountTaxRepartitionLineTemplate, self).create(vals)
+
+ @api.model
+ def _convert_tag_syntax_to_orm(self, tags_list):
+ """ Repartition lines give the possibility to directly give
+ a list of ids to create for tags instead of a list of ORM commands.
+
+ This function checks that tags_list uses this syntactic sugar and returns
+ an ORM-compliant version of it if it does.
+ """
+ if tags_list and all(isinstance(elem, int) for elem in tags_list):
+ return [(6, False, tags_list)]
+ return tags_list
+
+ @api.constrains('invoice_tax_id', 'refund_tax_id')
+ def validate_tax_template_link(self):
+ for record in self:
+ if record.invoice_tax_id and record.refund_tax_id:
+ raise ValidationError(_("Tax distribution line templates should apply to either invoices or refunds, not both at the same time. invoice_tax_id and refund_tax_id should not be set together."))
+
+ @api.constrains('plus_report_line_ids', 'minus_report_line_ids')
+ def validate_tags(self):
+ all_tax_rep_lines = self.mapped('plus_report_line_ids') + self.mapped('minus_report_line_ids')
+ lines_without_tag = all_tax_rep_lines.filtered(lambda x: not x.tag_name)
+ if lines_without_tag:
+ raise ValidationError(_("The following tax report lines are used in some tax distribution template though they don't generate any tag: %s . This probably means you forgot to set a tag_name on these lines.", str(lines_without_tag.mapped('name'))))
+
+ def get_repartition_line_create_vals(self, company):
+ rslt = [(5, 0, 0)]
+ for record in self:
+ tags_to_add = self.env['account.account.tag']
+ tags_to_add += record.plus_report_line_ids.mapped('tag_ids').filtered(lambda x: not x.tax_negate)
+ tags_to_add += record.minus_report_line_ids.mapped('tag_ids').filtered(lambda x: x.tax_negate)
+ tags_to_add += record.tag_ids
+
+ rslt.append((0, 0, {
+ 'factor_percent': record.factor_percent,
+ 'repartition_type': record.repartition_type,
+ 'tag_ids': [(6, 0, tags_to_add.ids)],
+ 'company_id': company.id,
+ 'use_in_tax_closing': record.use_in_tax_closing
+ }))
+ return rslt
+
+# Fiscal Position Templates
+
+class AccountFiscalPositionTemplate(models.Model):
+ _name = 'account.fiscal.position.template'
+ _description = 'Template for Fiscal Position'
+
+ sequence = fields.Integer()
+ name = fields.Char(string='Fiscal Position Template', required=True)
+ chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True)
+ account_ids = fields.One2many('account.fiscal.position.account.template', 'position_id', string='Account Mapping')
+ tax_ids = fields.One2many('account.fiscal.position.tax.template', 'position_id', string='Tax Mapping')
+ note = fields.Text(string='Notes')
+ auto_apply = fields.Boolean(string='Detect Automatically', help="Apply automatically this fiscal position.")
+ vat_required = fields.Boolean(string='VAT required', help="Apply only if partner has a VAT number.")
+ country_id = fields.Many2one('res.country', string='Country',
+ help="Apply only if delivery country matches.")
+ country_group_id = fields.Many2one('res.country.group', string='Country Group',
+ help="Apply only if delivery country matches the group.")
+ state_ids = fields.Many2many('res.country.state', string='Federal States')
+ zip_from = fields.Char(string='Zip Range From')
+ zip_to = fields.Char(string='Zip Range To')
+
+
+class AccountFiscalPositionTaxTemplate(models.Model):
+ _name = 'account.fiscal.position.tax.template'
+ _description = 'Tax Mapping Template of Fiscal Position'
+ _rec_name = 'position_id'
+
+ position_id = fields.Many2one('account.fiscal.position.template', string='Fiscal Position', required=True, ondelete='cascade')
+ tax_src_id = fields.Many2one('account.tax.template', string='Tax Source', required=True)
+ tax_dest_id = fields.Many2one('account.tax.template', string='Replacement Tax')
+
+
+class AccountFiscalPositionAccountTemplate(models.Model):
+ _name = 'account.fiscal.position.account.template'
+ _description = 'Accounts Mapping Template of Fiscal Position'
+ _rec_name = 'position_id'
+
+ position_id = fields.Many2one('account.fiscal.position.template', string='Fiscal Mapping', required=True, ondelete='cascade')
+ account_src_id = fields.Many2one('account.account.template', string='Account Source', required=True)
+ account_dest_id = fields.Many2one('account.account.template', string='Account Destination', required=True)
+
+
+class AccountReconcileModelTemplate(models.Model):
+ _name = "account.reconcile.model.template"
+ _description = 'Reconcile Model Template'
+
+ # Base fields.
+ chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True)
+ name = fields.Char(string='Button Label', required=True)
+ sequence = fields.Integer(required=True, default=10)
+
+ rule_type = fields.Selection(selection=[
+ ('writeoff_button', 'Manually create a write-off on clicked button'),
+ ('writeoff_suggestion', 'Suggest a write-off'),
+ ('invoice_matching', 'Match existing invoices/bills')
+ ], string='Type', default='writeoff_button', required=True)
+ auto_reconcile = fields.Boolean(string='Auto-validate',
+ help='Validate the statement line automatically (reconciliation based on your rule).')
+ to_check = fields.Boolean(string='To Check', default=False, help='This matching rule is used when the user is not certain of all the information of the counterpart.')
+ matching_order = fields.Selection(
+ selection=[
+ ('old_first', 'Oldest first'),
+ ('new_first', 'Newest first'),
+ ]
+ )
+
+ # ===== Conditions =====
+ match_text_location_label = fields.Boolean(
+ default=True,
+ help="Search in the Statement's Label to find the Invoice/Payment's reference",
+ )
+ match_text_location_note = fields.Boolean(
+ default=False,
+ help="Search in the Statement's Note to find the Invoice/Payment's reference",
+ )
+ match_text_location_reference = fields.Boolean(
+ default=False,
+ help="Search in the Statement's Reference to find the Invoice/Payment's reference",
+ )
+ match_journal_ids = fields.Many2many('account.journal', string='Journals',
+ domain="[('type', 'in', ('bank', 'cash'))]",
+ help='The reconciliation model will only be available from the selected journals.')
+ match_nature = fields.Selection(selection=[
+ ('amount_received', 'Amount Received'),
+ ('amount_paid', 'Amount Paid'),
+ ('both', 'Amount Paid/Received')
+ ], string='Amount Nature', required=True, default='both',
+ help='''The reconciliation model will only be applied to the selected transaction type:
+ * Amount Received: Only applied when receiving an amount.
+ * Amount Paid: Only applied when paying an amount.
+ * Amount Paid/Received: Applied in both cases.''')
+ match_amount = fields.Selection(selection=[
+ ('lower', 'Is Lower Than'),
+ ('greater', 'Is Greater Than'),
+ ('between', 'Is Between'),
+ ], string='Amount',
+ help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
+ match_amount_min = fields.Float(string='Amount Min Parameter')
+ match_amount_max = fields.Float(string='Amount Max Parameter')
+ match_label = fields.Selection(selection=[
+ ('contains', 'Contains'),
+ ('not_contains', 'Not Contains'),
+ ('match_regex', 'Match Regex'),
+ ], string='Label', help='''The reconciliation model will only be applied when the label:
+ * Contains: The proposition label must contains this string (case insensitive).
+ * Not Contains: Negation of "Contains".
+ * Match Regex: Define your own regular expression.''')
+ match_label_param = fields.Char(string='Label Parameter')
+ match_note = fields.Selection(selection=[
+ ('contains', 'Contains'),
+ ('not_contains', 'Not Contains'),
+ ('match_regex', 'Match Regex'),
+ ], string='Note', help='''The reconciliation model will only be applied when the note:
+ * Contains: The proposition note must contains this string (case insensitive).
+ * Not Contains: Negation of "Contains".
+ * Match Regex: Define your own regular expression.''')
+ match_note_param = fields.Char(string='Note Parameter')
+ match_transaction_type = fields.Selection(selection=[
+ ('contains', 'Contains'),
+ ('not_contains', 'Not Contains'),
+ ('match_regex', 'Match Regex'),
+ ], string='Transaction Type', help='''The reconciliation model will only be applied when the transaction type:
+ * Contains: The proposition transaction type must contains this string (case insensitive).
+ * Not Contains: Negation of "Contains".
+ * Match Regex: Define your own regular expression.''')
+ match_transaction_type_param = fields.Char(string='Transaction Type Parameter')
+ match_same_currency = fields.Boolean(string='Same Currency Matching', default=True,
+ help='Restrict to propositions having the same currency as the statement line.')
+ match_total_amount = fields.Boolean(string='Amount Matching', default=True,
+ help='The sum of total residual amount propositions matches the statement line amount.')
+ match_total_amount_param = fields.Float(string='Amount Matching %', default=100,
+ help='The sum of total residual amount propositions matches the statement line amount under this percentage.')
+ match_partner = fields.Boolean(string='Partner Is Set',
+ help='The reconciliation model will only be applied when a customer/vendor is set.')
+ match_partner_ids = fields.Many2many('res.partner', string='Restrict Partners to',
+ help='The reconciliation model will only be applied to the selected customers/vendors.')
+ match_partner_category_ids = fields.Many2many('res.partner.category', string='Restrict Partner Categories to',
+ help='The reconciliation model will only be applied to the selected customer/vendor categories.')
+
+ line_ids = fields.One2many('account.reconcile.model.line.template', 'model_id')
+ decimal_separator = fields.Char(help="Every character that is nor a digit nor this separator will be removed from the matching string")
+
+
+class AccountReconcileModelLineTemplate(models.Model):
+ _name = "account.reconcile.model.line.template"
+ _description = 'Reconcile Model Line Template'
+
+ model_id = fields.Many2one('account.reconcile.model.template')
+ sequence = fields.Integer(required=True, default=10)
+ account_id = fields.Many2one('account.account.template', string='Account', ondelete='cascade', domain=[('deprecated', '=', False)])
+ label = fields.Char(string='Journal Item Label')
+ amount_type = fields.Selection([
+ ('fixed', 'Fixed'),
+ ('percentage', 'Percentage of balance'),
+ ('regex', 'From label'),
+ ], required=True, default='percentage')
+ amount_string = fields.Char(string="Amount")
+ force_tax_included = fields.Boolean(string='Tax Included in Price', help='Force the tax to be managed as a price included tax.')
+ tax_ids = fields.Many2many('account.tax.template', string='Taxes', ondelete='restrict')
diff --git a/addons/account/models/company.py b/addons/account/models/company.py
new file mode 100644
index 00000000..f5479a06
--- /dev/null
+++ b/addons/account/models/company.py
@@ -0,0 +1,554 @@
+# -*- coding: utf-8 -*-
+
+from datetime import timedelta, datetime, date
+import calendar
+from dateutil.relativedelta import relativedelta
+
+from odoo import fields, models, api, _
+from odoo.exceptions import ValidationError, UserError, RedirectWarning
+from odoo.tools.misc import format_date
+from odoo.tools.float_utils import float_round, float_is_zero
+from odoo.tests.common import Form
+
+
+MONTH_SELECTION = [
+ ('1', 'January'),
+ ('2', 'February'),
+ ('3', 'March'),
+ ('4', 'April'),
+ ('5', 'May'),
+ ('6', 'June'),
+ ('7', 'July'),
+ ('8', 'August'),
+ ('9', 'September'),
+ ('10', 'October'),
+ ('11', 'November'),
+ ('12', 'December'),
+]
+
+ONBOARDING_STEP_STATES = [
+ ('not_done', "Not done"),
+ ('just_done', "Just done"),
+ ('done', "Done"),
+]
+DASHBOARD_ONBOARDING_STATES = ONBOARDING_STEP_STATES + [('closed', 'Closed')]
+
+
+class ResCompany(models.Model):
+ _inherit = "res.company"
+
+ #TODO check all the options/fields are in the views (settings + company form view)
+ fiscalyear_last_day = fields.Integer(default=31, required=True)
+ fiscalyear_last_month = fields.Selection(MONTH_SELECTION, default='12', required=True)
+ period_lock_date = fields.Date(string="Lock Date for Non-Advisers", help="Only users with the 'Adviser' role can edit accounts prior to and inclusive of this date. Use it for period locking inside an open fiscal year, for example.")
+ fiscalyear_lock_date = fields.Date(string="Lock Date", help="No users, including Advisers, can edit accounts prior to and inclusive of this date. Use it for fiscal year locking for example.")
+ tax_lock_date = fields.Date("Tax Lock Date", help="No users can edit journal entries related to a tax prior and inclusive of this date.")
+ transfer_account_id = fields.Many2one('account.account',
+ domain=lambda self: [('reconcile', '=', True), ('user_type_id.id', '=', self.env.ref('account.data_account_type_current_assets').id), ('deprecated', '=', False)], string="Inter-Banks Transfer Account", help="Intermediary account used when moving money from a liquidity account to another")
+ expects_chart_of_accounts = fields.Boolean(string='Expects a Chart of Accounts', default=True)
+ chart_template_id = fields.Many2one('account.chart.template', help='The chart template for the company (if any)')
+ bank_account_code_prefix = fields.Char(string='Prefix of the bank accounts')
+ cash_account_code_prefix = fields.Char(string='Prefix of the cash accounts')
+ default_cash_difference_income_account_id = fields.Many2one('account.account', string="Cash Difference Income Account")
+ default_cash_difference_expense_account_id = fields.Many2one('account.account', string="Cash Difference Expense Account")
+ account_journal_suspense_account_id = fields.Many2one('account.account', string='Journal Suspense Account')
+ 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_calculation_rounding_method = fields.Selection([
+ ('round_per_line', 'Round per Line'),
+ ('round_globally', 'Round Globally'),
+ ], default='round_per_line', string='Tax Calculation Rounding Method')
+ currency_exchange_journal_id = fields.Many2one('account.journal', string="Exchange Gain or Loss Journal", domain=[('type', '=', 'general')])
+ income_currency_exchange_account_id = fields.Many2one(
+ comodel_name='account.account',
+ string="Gain Exchange Rate Account",
+ domain=lambda self: "[('internal_type', '=', 'other'), ('deprecated', '=', False), ('company_id', '=', id), \
+ ('user_type_id', 'in', %s)]" % [self.env.ref('account.data_account_type_revenue').id,
+ self.env.ref('account.data_account_type_other_income').id])
+ expense_currency_exchange_account_id = fields.Many2one(
+ comodel_name='account.account',
+ string="Loss Exchange Rate Account",
+ domain=lambda self: "[('internal_type', '=', 'other'), ('deprecated', '=', False), ('company_id', '=', id), \
+ ('user_type_id', '=', %s)]" % self.env.ref('account.data_account_type_expenses').id)
+ anglo_saxon_accounting = fields.Boolean(string="Use anglo-saxon accounting")
+ property_stock_account_input_categ_id = fields.Many2one('account.account', string="Input Account for Stock Valuation")
+ property_stock_account_output_categ_id = fields.Many2one('account.account', string="Output Account for Stock Valuation")
+ property_stock_valuation_account_id = fields.Many2one('account.account', string="Account Template for Stock Valuation")
+ bank_journal_ids = fields.One2many('account.journal', 'company_id', domain=[('type', '=', 'bank')], string='Bank Journals')
+ tax_exigibility = fields.Boolean(string='Use Cash Basis')
+ account_tax_fiscal_country_id = fields.Many2one('res.country', string="Fiscal Country", compute='compute_account_tax_fiscal_country', store=True, readonly=False, help="The country to use the tax reports from for this company")
+
+ incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm',
+ help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
+
+ qr_code = fields.Boolean(string='Display QR-code on invoices')
+
+ invoice_is_email = fields.Boolean('Email by default', default=True)
+ invoice_is_print = fields.Boolean('Print by default', default=True)
+
+ #Fields of the setup step for opening move
+ account_opening_move_id = fields.Many2one(string='Opening Journal Entry', comodel_name='account.move', help="The journal entry containing the initial balance of all this company's accounts.")
+ account_opening_journal_id = fields.Many2one(string='Opening Journal', comodel_name='account.journal', related='account_opening_move_id.journal_id', help="Journal where the opening entry of this company's accounting has been posted.", readonly=False)
+ account_opening_date = fields.Date(string='Opening Entry', default=lambda self: fields.Date.context_today(self).replace(month=1, day=1), required=True, help="That is the date of the opening entry.")
+
+ # Fields marking the completion of a setup step
+ account_setup_bank_data_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding bank data step", default='not_done')
+ account_setup_fy_data_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding fiscal year step", default='not_done')
+ account_setup_coa_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding charts of account step", default='not_done')
+ account_onboarding_invoice_layout_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding invoice layout step", default='not_done')
+ account_onboarding_create_invoice_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding create invoice step", default='not_done')
+ account_onboarding_sale_tax_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding sale tax step", default='not_done')
+
+ # account dashboard onboarding
+ account_invoice_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account invoice onboarding panel", default='not_done')
+ account_dashboard_onboarding_state = fields.Selection(DASHBOARD_ONBOARDING_STATES, string="State of the account dashboard onboarding panel", default='not_done')
+ invoice_terms = fields.Text(string='Default Terms and Conditions', translate=True)
+ account_setup_bill_state = fields.Selection(ONBOARDING_STEP_STATES, string="State of the onboarding bill step", default='not_done')
+
+ # Needed in the Point of Sale
+ account_default_pos_receivable_account_id = fields.Many2one('account.account', string="Default PoS Receivable Account")
+
+ # Accrual Accounting
+ expense_accrual_account_id = fields.Many2one('account.account',
+ help="Account used to move the period of an expense",
+ domain="[('internal_group', '=', 'liability'), ('internal_type', 'not in', ('receivable', 'payable')), ('company_id', '=', id)]")
+ revenue_accrual_account_id = fields.Many2one('account.account',
+ help="Account used to move the period of a revenue",
+ domain="[('internal_group', '=', 'asset'), ('internal_type', 'not in', ('receivable', 'payable')), ('company_id', '=', id)]")
+ automatic_entry_default_journal_id = fields.Many2one('account.journal', help="Journal used by default for moving the period of an entry", domain="[('type', '=', 'general')]")
+
+ # 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:
+ # there is no "fiscalyear_last_year" so we do not know his intentions.
+ for rec in self:
+ if rec.fiscalyear_last_day == 29 and rec.fiscalyear_last_month == '2':
+ continue
+
+ if rec.account_opening_date:
+ year = rec.account_opening_date.year
+ else:
+ year = datetime.now().year
+
+ max_day = calendar.monthrange(year, int(rec.fiscalyear_last_month))[1]
+ if rec.fiscalyear_last_day > max_day:
+ raise ValidationError(_("Invalid fiscal year last day"))
+
+ @api.depends('country_id')
+ def compute_account_tax_fiscal_country(self):
+ for record in self:
+ record.account_tax_fiscal_country_id = record.country_id
+
+ def get_and_update_account_invoice_onboarding_state(self):
+ """ This method is called on the controller rendering method and ensures that the animations
+ are displayed only one time. """
+ return self.get_and_update_onbarding_state(
+ 'account_invoice_onboarding_state',
+ self.get_account_invoice_onboarding_steps_states_names()
+ )
+
+ # YTI FIXME: Define only one method that returns {'account': [], 'sale': [], ...}
+ def get_account_invoice_onboarding_steps_states_names(self):
+ """ Necessary to add/edit steps from other modules (payment acquirer in this case). """
+ return [
+ 'base_onboarding_company_state',
+ 'account_onboarding_invoice_layout_state',
+ 'account_onboarding_create_invoice_state',
+ ]
+
+ def get_and_update_account_dashboard_onboarding_state(self):
+ """ This method is called on the controller rendering method and ensures that the animations
+ are displayed only one time. """
+ return self.get_and_update_onbarding_state(
+ 'account_dashboard_onboarding_state',
+ self.get_account_dashboard_onboarding_steps_states_names()
+ )
+
+ def get_account_dashboard_onboarding_steps_states_names(self):
+ """ Necessary to add/edit steps from other modules (account_winbooks_import in this case). """
+ return [
+ 'account_setup_bill_state',
+ 'account_setup_bank_data_state',
+ 'account_setup_fy_data_state',
+ 'account_setup_coa_state',
+ ]
+
+ def get_new_account_code(self, current_code, old_prefix, new_prefix):
+ digits = len(current_code)
+ return new_prefix + current_code.replace(old_prefix, '', 1).lstrip('0').rjust(digits-len(new_prefix), '0')
+
+ def reflect_code_prefix_change(self, old_code, new_code):
+ accounts = self.env['account.account'].search([('code', 'like', old_code), ('internal_type', '=', 'liquidity'),
+ ('company_id', '=', self.id)], order='code asc')
+ for account in accounts:
+ if account.code.startswith(old_code):
+ account.write({'code': self.get_new_account_code(account.code, old_code, new_code)})
+
+ def _validate_fiscalyear_lock(self, values):
+ if values.get('fiscalyear_lock_date'):
+
+ draft_entries = self.env['account.move'].search([
+ ('company_id', 'in', self.ids),
+ ('state', '=', 'draft'),
+ ('date', '<=', values['fiscalyear_lock_date'])])
+ if draft_entries:
+ error_msg = _('There are still unposted entries in the period you want to lock. You should either post or delete them.')
+ action_error = {
+ 'view_mode': 'tree',
+ 'name': 'Unposted Entries',
+ 'res_model': 'account.move',
+ 'type': 'ir.actions.act_window',
+ 'domain': [('id', 'in', draft_entries.ids)],
+ 'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'],
+ 'views': [[self.env.ref('account.view_move_tree').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']],
+ }
+ raise RedirectWarning(error_msg, action_error, _('Show unposted entries'))
+
+ unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
+ ('company_id', 'in', self.ids),
+ ('is_reconciled', '=', False),
+ ('date', '<=', values['fiscalyear_lock_date']),
+ ('move_id.state', 'in', ('draft', 'posted')),
+ ])
+ if unreconciled_statement_lines:
+ error_msg = _("There are still unreconciled bank statement lines in the period you want to lock."
+ "You should either reconcile or delete them.")
+ action_error = {
+ 'type': 'ir.actions.client',
+ 'tag': 'bank_statement_reconciliation_view',
+ 'context': {'statement_line_ids': unreconciled_statement_lines.ids, 'company_ids': self.ids},
+ }
+ raise RedirectWarning(error_msg, action_error, _('Show Unreconciled Bank Statement Line'))
+
+ def _get_user_fiscal_lock_date(self):
+ """Get the fiscal lock date for this company depending on the user"""
+ self.ensure_one()
+ lock_date = max(self.period_lock_date or date.min, self.fiscalyear_lock_date or date.min)
+ if self.user_has_groups('account.group_account_manager'):
+ lock_date = self.fiscalyear_lock_date or date.min
+ return lock_date
+
+ def write(self, values):
+ #restrict the closing of FY if there are still unposted entries
+ self._validate_fiscalyear_lock(values)
+
+ # Reflect the change on accounts
+ for company in self:
+ if values.get('bank_account_code_prefix'):
+ new_bank_code = values.get('bank_account_code_prefix') or company.bank_account_code_prefix
+ company.reflect_code_prefix_change(company.bank_account_code_prefix, new_bank_code)
+
+ if values.get('cash_account_code_prefix'):
+ new_cash_code = values.get('cash_account_code_prefix') or company.cash_account_code_prefix
+ company.reflect_code_prefix_change(company.cash_account_code_prefix, new_cash_code)
+
+ #forbid the change of currency_id if there are already some accounting entries existing
+ if 'currency_id' in values and values['currency_id'] != company.currency_id.id:
+ if self.env['account.move.line'].search([('company_id', '=', company.id)]):
+ raise UserError(_('You cannot change the currency of the company since some journal items already exist'))
+
+ return super(ResCompany, self).write(values)
+
+ @api.model
+ def setting_init_bank_account_action(self):
+ """ Called by the 'Bank Accounts' button of the setup bar."""
+ view_id = self.env.ref('account.setup_bank_account_wizard').id
+ return {'type': 'ir.actions.act_window',
+ 'name': _('Create a Bank Account'),
+ 'res_model': 'account.setup.bank.manual.config',
+ 'target': 'new',
+ 'view_mode': 'form',
+ 'views': [[view_id, 'form']],
+ }
+
+ @api.model
+ def setting_init_fiscal_year_action(self):
+ """ Called by the 'Fiscal Year Opening' button of the setup bar."""
+ company = self.env.company
+ company.create_op_move_if_non_existant()
+ new_wizard = self.env['account.financial.year.op'].create({'company_id': company.id})
+ view_id = self.env.ref('account.setup_financial_year_opening_form').id
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Accounting Periods'),
+ 'view_mode': 'form',
+ 'res_model': 'account.financial.year.op',
+ 'target': 'new',
+ 'res_id': new_wizard.id,
+ 'views': [[view_id, 'form']],
+ }
+
+ @api.model
+ def setting_chart_of_accounts_action(self):
+ """ Called by the 'Chart of Accounts' button of the setup bar."""
+ company = self.env.company
+ company.sudo().set_onboarding_step_done('account_setup_coa_state')
+
+ # If an opening move has already been posted, we open the tree view showing all the accounts
+ if company.opening_move_posted():
+ return 'account.action_account_form'
+
+ # Otherwise, we create the opening move
+ company.create_op_move_if_non_existant()
+
+ # Then, we open will open a custom tree view allowing to edit opening balances of the account
+ view_id = self.env.ref('account.init_accounts_tree').id
+ # Hide the current year earnings account as it is automatically computed
+ domain = [('user_type_id', '!=', self.env.ref('account.data_unaffected_earnings').id), ('company_id','=', company.id)]
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Chart of Accounts'),
+ 'res_model': 'account.account',
+ 'view_mode': 'tree',
+ 'limit': 99999999,
+ 'search_view_id': self.env.ref('account.view_account_search').id,
+ 'views': [[view_id, 'list']],
+ 'domain': domain,
+ }
+
+ @api.model
+ def create_op_move_if_non_existant(self):
+ """ Creates an empty opening move in 'draft' state for the current company
+ if there wasn't already one defined. For this, the function needs at least
+ one journal of type 'general' to exist (required by account.move).
+ """
+ self.ensure_one()
+ if not self.account_opening_move_id:
+ default_journal = self.env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', self.id)], limit=1)
+
+ if not default_journal:
+ raise UserError(_("Please install a chart of accounts or create a miscellaneous journal before proceeding."))
+
+ opening_date = self.account_opening_date - timedelta(days=1)
+
+ self.account_opening_move_id = self.env['account.move'].create({
+ 'ref': _('Opening Journal Entry'),
+ 'company_id': self.id,
+ 'journal_id': default_journal.id,
+ 'date': opening_date,
+ })
+
+ def opening_move_posted(self):
+ """ Returns true if this company has an opening account move and this move is posted."""
+ return bool(self.account_opening_move_id) and self.account_opening_move_id.state == 'posted'
+
+ def get_unaffected_earnings_account(self):
+ """ Returns the unaffected earnings account for this company, creating one
+ if none has yet been defined.
+ """
+ unaffected_earnings_type = self.env.ref("account.data_unaffected_earnings")
+ account = self.env['account.account'].search([('company_id', '=', self.id),
+ ('user_type_id', '=', unaffected_earnings_type.id)])
+ if account:
+ return account[0]
+ # Do not assume '999999' doesn't exist since the user might have created such an account
+ # manually.
+ code = 999999
+ while self.env['account.account'].search([('code', '=', str(code)), ('company_id', '=', self.id)]):
+ code -= 1
+ return self.env['account.account'].create({
+ 'code': str(code),
+ 'name': _('Undistributed Profits/Losses'),
+ 'user_type_id': unaffected_earnings_type.id,
+ 'company_id': self.id,
+ })
+
+ def get_opening_move_differences(self, opening_move_lines):
+ currency = self.currency_id
+ balancing_move_line = opening_move_lines.filtered(lambda x: x.account_id == self.get_unaffected_earnings_account())
+
+ debits_sum = credits_sum = 0.0
+ for line in opening_move_lines:
+ if line != balancing_move_line:
+ #skip the autobalancing move line
+ debits_sum += line.debit
+ credits_sum += line.credit
+
+ difference = abs(debits_sum - credits_sum)
+ debit_diff = (debits_sum > credits_sum) and float_round(difference, precision_rounding=currency.rounding) or 0.0
+ credit_diff = (debits_sum < credits_sum) and float_round(difference, precision_rounding=currency.rounding) or 0.0
+ return debit_diff, credit_diff
+
+ def _auto_balance_opening_move(self):
+ """ Checks the opening_move of this company. If it has not been posted yet
+ and is unbalanced, balances it with a automatic account.move.line in the
+ current year earnings account.
+ """
+ if self.account_opening_move_id and self.account_opening_move_id.state == 'draft':
+ balancing_account = self.get_unaffected_earnings_account()
+ currency = self.currency_id
+
+ balancing_move_line = self.account_opening_move_id.line_ids.filtered(lambda x: x.account_id == balancing_account)
+ # There could be multiple lines if we imported the balance from unaffected earnings account too
+ if len(balancing_move_line) > 1:
+ self.with_context(check_move_validity=False).account_opening_move_id.line_ids -= balancing_move_line[1:]
+ balancing_move_line = balancing_move_line[0]
+
+ debit_diff, credit_diff = self.get_opening_move_differences(self.account_opening_move_id.line_ids)
+
+ if float_is_zero(debit_diff + credit_diff, precision_rounding=currency.rounding):
+ if balancing_move_line:
+ # zero difference and existing line : delete the line
+ self.account_opening_move_id.line_ids -= balancing_move_line
+ else:
+ if balancing_move_line:
+ # Non-zero difference and existing line : edit the line
+ balancing_move_line.write({'debit': credit_diff, 'credit': debit_diff})
+ else:
+ # Non-zero difference and no existing line : create a new line
+ self.env['account.move.line'].create({
+ 'name': _('Automatic Balancing Line'),
+ 'move_id': self.account_opening_move_id.id,
+ 'account_id': balancing_account.id,
+ 'debit': credit_diff,
+ 'credit': debit_diff,
+ })
+
+ @api.model
+ def action_close_account_invoice_onboarding(self):
+ """ Mark the invoice onboarding panel as closed. """
+ self.env.company.account_invoice_onboarding_state = 'closed'
+
+ @api.model
+ def action_close_account_dashboard_onboarding(self):
+ """ Mark the dashboard onboarding panel as closed. """
+ self.env.company.account_dashboard_onboarding_state = 'closed'
+
+ @api.model
+ def action_open_account_onboarding_sale_tax(self):
+ """ Onboarding step for the invoice layout. """
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_open_account_onboarding_sale_tax")
+ action['res_id'] = self.env.company.id
+ return action
+
+ @api.model
+ def action_open_account_onboarding_create_invoice(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_open_account_onboarding_create_invoice")
+ return action
+
+ def action_save_onboarding_invoice_layout(self):
+ """ Set the onboarding step as done """
+ if bool(self.external_report_layout_id):
+ self.set_onboarding_step_done('account_onboarding_invoice_layout_state')
+
+ def action_save_onboarding_sale_tax(self):
+ """ Set the onboarding step as done """
+ self.set_onboarding_step_done('account_onboarding_sale_tax_state')
+
+ def get_chart_of_accounts_or_fail(self):
+ account = self.env['account.account'].search([('company_id', '=', self.id)], limit=1)
+ if len(account) == 0:
+ action = self.env.ref('account.action_account_config')
+ msg = _(
+ "We cannot find a chart of accounts for this company, you should configure it. \n"
+ "Please go to Account Configuration and select or install a fiscal localization.")
+ raise RedirectWarning(msg, action.id, _("Go to the configuration panel"))
+ return account
+
+ @api.model
+ def _action_check_hash_integrity(self):
+ return self.env.ref('account.action_report_account_hash_integrity').report_action(self.id)
+
+ def _check_hash_integrity(self):
+ """Checks that all posted moves have still the same data as when they were posted
+ and raises an error with the result.
+ """
+ def build_move_info(move):
+ return(move.name, move.inalterable_hash, fields.Date.to_string(move.date))
+
+ journals = self.env['account.journal'].search([('company_id', '=', self.id)])
+ results_by_journal = {
+ 'results': [],
+ 'printing_date': format_date(self.env, fields.Date.to_string(fields.Date.context_today(self)))
+ }
+
+ for journal in journals:
+ rslt = {
+ 'journal_name': journal.name,
+ 'journal_code': journal.code,
+ 'restricted_by_hash_table': journal.restrict_mode_hash_table and 'V' or 'X',
+ 'msg_cover': '',
+ 'first_hash': 'None',
+ 'first_move_name': 'None',
+ 'first_move_date': 'None',
+ 'last_hash': 'None',
+ 'last_move_name': 'None',
+ 'last_move_date': 'None',
+ }
+ if not journal.restrict_mode_hash_table:
+ rslt.update({'msg_cover': _('This journal is not in strict mode.')})
+ results_by_journal['results'].append(rslt)
+ continue
+
+ all_moves_count = self.env['account.move'].search_count([('state', '=', 'posted'), ('journal_id', '=', journal.id)])
+ moves = self.env['account.move'].search([('state', '=', 'posted'), ('journal_id', '=', journal.id),
+ ('secure_sequence_number', '!=', 0)], order="secure_sequence_number ASC")
+ if not moves:
+ rslt.update({
+ 'msg_cover': _('There isn\'t any journal entry flagged for data inalterability yet for this journal.'),
+ })
+ results_by_journal['results'].append(rslt)
+ continue
+
+ previous_hash = u''
+ start_move_info = []
+ hash_corrupted = False
+ for move in moves:
+ if move.inalterable_hash != move._compute_hash(previous_hash=previous_hash):
+ rslt.update({'msg_cover': _('Corrupted data on journal entry with id %s.', move.id)})
+ results_by_journal['results'].append(rslt)
+ hash_corrupted = True
+ break
+ if not previous_hash:
+ #save the date and sequence number of the first move hashed
+ start_move_info = build_move_info(move)
+ previous_hash = move.inalterable_hash
+ end_move_info = build_move_info(move)
+
+ if hash_corrupted:
+ continue
+
+ rslt.update({
+ 'first_move_name': start_move_info[0],
+ 'first_hash': start_move_info[1],
+ 'first_move_date': format_date(self.env, start_move_info[2]),
+ 'last_move_name': end_move_info[0],
+ 'last_hash': end_move_info[1],
+ 'last_move_date': format_date(self.env, end_move_info[2]),
+ })
+ if len(moves) == all_moves_count:
+ rslt.update({'msg_cover': _('All entries are hashed.')})
+ else:
+ rslt.update({'msg_cover': _('Entries are hashed from %s (%s)') % (start_move_info[0], format_date(self.env, start_move_info[2]))})
+ results_by_journal['results'].append(rslt)
+
+ return results_by_journal
+
+ def compute_fiscalyear_dates(self, current_date):
+ """
+ The role of this method is to provide a fallback when account_accounting is not installed.
+ As the fiscal year is irrelevant when account_accounting is not installed, this method returns the calendar year.
+ :param current_date: A datetime.date/datetime.datetime object.
+ :return: A dictionary containing:
+ * date_from
+ * date_to
+ """
+
+ return {'date_from': datetime(year=current_date.year, month=1, day=1).date(),
+ 'date_to': datetime(year=current_date.year, month=12, day=31).date()}
diff --git a/addons/account/models/digest.py b/addons/account/models/digest.py
new file mode 100644
index 00000000..4a25f820
--- /dev/null
+++ b/addons/account/models/digest.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, _
+from odoo.exceptions import AccessError
+
+
+class Digest(models.Model):
+ _inherit = 'digest.digest'
+
+ kpi_account_total_revenue = fields.Boolean('Revenue')
+ kpi_account_total_revenue_value = fields.Monetary(compute='_compute_kpi_account_total_revenue_value')
+
+ def _compute_kpi_account_total_revenue_value(self):
+ if not self.env.user.has_group('account.group_account_invoice'):
+ raise AccessError(_("Do not have access, skip this data for user's digest email"))
+ for record in self:
+ start, end, company = record._get_kpi_compute_parameters()
+ self._cr.execute('''
+ SELECT -SUM(line.balance)
+ FROM account_move_line line
+ JOIN account_move move ON move.id = line.move_id
+ JOIN account_account account ON account.id = line.account_id
+ WHERE line.company_id = %s AND line.date >= %s AND line.date < %s
+ AND account.internal_group = 'income'
+ AND move.state = 'posted'
+ ''', [company.id, start, end])
+ query_res = self._cr.fetchone()
+ record.kpi_account_total_revenue_value = query_res and query_res[0] or 0.0
+
+ def _compute_kpis_actions(self, company, user):
+ res = super(Digest, self)._compute_kpis_actions(company, user)
+ res['kpi_account_total_revenue'] = 'account.action_move_out_invoice_type&menu_id=%s' % self.env.ref('account.menu_finance').id
+ return res
diff --git a/addons/account/models/ir_actions_report.py b/addons/account/models/ir_actions_report.py
new file mode 100644
index 00000000..27deaa9f
--- /dev/null
+++ b/addons/account/models/ir_actions_report.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, api, _
+from odoo.exceptions import UserError
+
+class IrActionsReport(models.Model):
+ _inherit = 'ir.actions.report'
+
+ def retrieve_attachment(self, record):
+ # get the original bills through the message_main_attachment_id field of the record
+ if self.report_name == 'account.report_original_vendor_bill' and record.message_main_attachment_id:
+ if record.message_main_attachment_id.mimetype == 'application/pdf' or \
+ record.message_main_attachment_id.mimetype.startswith('image'):
+ return record.message_main_attachment_id
+ return super(IrActionsReport, self).retrieve_attachment(record)
+
+ def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None):
+ # don't include the generated dummy report
+ if self.report_name == 'account.report_original_vendor_bill':
+ pdf_content = None
+ res_ids = None
+ if not save_in_attachment:
+ raise UserError(_("No original vendor bills could be found for any of the selected vendor bills."))
+ return super(IrActionsReport, self)._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=res_ids)
+
+ def _postprocess_pdf_report(self, record, buffer):
+ # don't save the 'account.report_original_vendor_bill' report as it's just a mean to print existing attachments
+ if self.report_name == 'account.report_original_vendor_bill':
+ return None
+ res = super(IrActionsReport, self)._postprocess_pdf_report(record, buffer)
+ if self.model == 'account.move' and record.state == 'posted' and record.is_sale_document(include_receipts=True):
+ attachment = self.retrieve_attachment(record)
+ if attachment:
+ attachment.register_as_main_attachment(force=False)
+ return res
diff --git a/addons/account/models/mail_thread.py b/addons/account/models/mail_thread.py
new file mode 100644
index 00000000..cf396c93
--- /dev/null
+++ b/addons/account/models/mail_thread.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+class MailThread(models.AbstractModel):
+ _inherit = 'mail.thread'
+
+ def _message_post_process_attachments(self, attachments, attachment_ids, message_values):
+ """ This method extension ensures that, when using the "Send & Print" feature, if the user
+ adds an attachment, the latter will be linked to the record. """
+ record = self.env.context.get('attached_to')
+ # link mail.compose.message attachments to attached_to
+ if record and record._name == 'account.move':
+ message_values['model'] = record._name
+ message_values['res_id'] = record.id
+ res = super()._message_post_process_attachments(attachments, attachment_ids, message_values)
+ # link account.invoice.send attachments to attached_to
+ model = message_values['model']
+ res_id = message_values['res_id']
+ att_ids = [att[1] for att in res.get('attachment_ids') or []]
+ if att_ids and model == 'account.move':
+ filtered_attachment_ids = self.env['ir.attachment'].sudo().browse(att_ids).filtered(
+ lambda a: a.res_model in ('account.invoice.send',) and a.create_uid.id == self._uid)
+ if filtered_attachment_ids:
+ filtered_attachment_ids.write({'res_model': model, 'res_id': res_id})
+ return res
diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py
new file mode 100644
index 00000000..7d0c08b2
--- /dev/null
+++ b/addons/account/models/partner.py
@@ -0,0 +1,510 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import time
+import logging
+
+from psycopg2 import sql, DatabaseError
+
+from odoo import api, fields, models, _
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
+from odoo.exceptions import ValidationError
+from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
+
+_logger = logging.getLogger(__name__)
+
+class AccountFiscalPosition(models.Model):
+ _name = 'account.fiscal.position'
+ _description = 'Fiscal Position'
+ _order = 'sequence'
+
+ sequence = fields.Integer()
+ name = fields.Char(string='Fiscal Position', required=True)
+ active = fields.Boolean(default=True,
+ help="By unchecking the active field, you may hide a fiscal position without deleting it.")
+ company_id = fields.Many2one(
+ comodel_name='res.company',
+ string='Company', required=True, readonly=True,
+ default=lambda self: self.env.company)
+ account_ids = fields.One2many('account.fiscal.position.account', 'position_id', string='Account Mapping', copy=True)
+ tax_ids = fields.One2many('account.fiscal.position.tax', 'position_id', string='Tax Mapping', copy=True)
+ note = fields.Text('Notes', translate=True, help="Legal mentions that have to be printed on the invoices.")
+ auto_apply = fields.Boolean(string='Detect Automatically', help="Apply automatically this fiscal position.")
+ vat_required = fields.Boolean(string='VAT required', help="Apply only if partner has a VAT number.")
+ country_id = fields.Many2one('res.country', string='Country',
+ help="Apply only if delivery country matches.")
+ country_group_id = fields.Many2one('res.country.group', string='Country Group',
+ help="Apply only if delivery country matches the group.")
+ state_ids = fields.Many2many('res.country.state', string='Federal States')
+ zip_from = fields.Char(string='Zip Range From')
+ zip_to = fields.Char(string='Zip Range To')
+ # To be used in hiding the 'Federal States' field('attrs' in view side) when selected 'Country' has 0 states.
+ states_count = fields.Integer(compute='_compute_states_count')
+
+ def _compute_states_count(self):
+ for position in self:
+ position.states_count = len(position.country_id.state_ids)
+
+ @api.constrains('zip_from', 'zip_to')
+ def _check_zip(self):
+ for position in self:
+ if position.zip_from and position.zip_to and position.zip_from > position.zip_to:
+ raise ValidationError(_('Invalid "Zip Range", please configure it properly.'))
+
+ def map_tax(self, taxes, product=None, partner=None):
+ if not self:
+ return taxes
+ result = self.env['account.tax']
+ for tax in taxes:
+ taxes_correspondance = self.tax_ids.filtered(lambda t: t.tax_src_id == tax._origin)
+ result |= taxes_correspondance.tax_dest_id if taxes_correspondance else tax
+ return result
+
+ def map_account(self, account):
+ for pos in self.account_ids:
+ if pos.account_src_id == account:
+ return pos.account_dest_id
+ return account
+
+ def map_accounts(self, accounts):
+ """ Receive a dictionary having accounts in values and try to replace those accounts accordingly to the fiscal position.
+ """
+ ref_dict = {}
+ for line in self.account_ids:
+ ref_dict[line.account_src_id] = line.account_dest_id
+ for key, acc in accounts.items():
+ if acc in ref_dict:
+ accounts[key] = ref_dict[acc]
+ return accounts
+
+ @api.onchange('country_id')
+ def _onchange_country_id(self):
+ if self.country_id:
+ self.zip_from = self.zip_to = self.country_group_id = False
+ self.state_ids = [(5,)]
+ self.states_count = len(self.country_id.state_ids)
+
+ @api.onchange('country_group_id')
+ def _onchange_country_group_id(self):
+ if self.country_group_id:
+ self.zip_from = self.zip_to = self.country_id = False
+ self.state_ids = [(5,)]
+
+ @api.model
+ def _convert_zip_values(self, zip_from='', zip_to=''):
+ max_length = max(len(zip_from), len(zip_to))
+ if zip_from.isdigit():
+ zip_from = zip_from.rjust(max_length, '0')
+ if zip_to.isdigit():
+ zip_to = zip_to.rjust(max_length, '0')
+ return zip_from, zip_to
+
+ @api.model
+ def create(self, vals):
+ zip_from = vals.get('zip_from')
+ zip_to = vals.get('zip_to')
+ if zip_from and zip_to:
+ vals['zip_from'], vals['zip_to'] = self._convert_zip_values(zip_from, zip_to)
+ return super(AccountFiscalPosition, self).create(vals)
+
+ def write(self, vals):
+ zip_from = vals.get('zip_from')
+ zip_to = vals.get('zip_to')
+ if zip_from or zip_to:
+ for rec in self:
+ vals['zip_from'], vals['zip_to'] = self._convert_zip_values(zip_from or rec.zip_from, zip_to or rec.zip_to)
+ return super(AccountFiscalPosition, self).write(vals)
+
+ @api.model
+ def _get_fpos_by_region(self, country_id=False, state_id=False, zipcode=False, vat_required=False):
+ if not country_id:
+ return False
+ base_domain = [
+ ('auto_apply', '=', True),
+ ('vat_required', '=', vat_required),
+ ('company_id', 'in', [self.env.company.id, False]),
+ ]
+ null_state_dom = state_domain = [('state_ids', '=', False)]
+ null_zip_dom = zip_domain = [('zip_from', '=', False), ('zip_to', '=', False)]
+ null_country_dom = [('country_id', '=', False), ('country_group_id', '=', False)]
+
+ if zipcode:
+ zip_domain = [('zip_from', '<=', zipcode), ('zip_to', '>=', zipcode)]
+
+ if state_id:
+ state_domain = [('state_ids', '=', state_id)]
+
+ domain_country = base_domain + [('country_id', '=', country_id)]
+ domain_group = base_domain + [('country_group_id.country_ids', '=', country_id)]
+
+ # Build domain to search records with exact matching criteria
+ fpos = self.search(domain_country + state_domain + zip_domain, limit=1)
+ # return records that fit the most the criteria, and fallback on less specific fiscal positions if any can be found
+ if not fpos and state_id:
+ fpos = self.search(domain_country + null_state_dom + zip_domain, limit=1)
+ if not fpos and zipcode:
+ fpos = self.search(domain_country + state_domain + null_zip_dom, limit=1)
+ if not fpos and state_id and zipcode:
+ fpos = self.search(domain_country + null_state_dom + null_zip_dom, limit=1)
+
+ # fallback: country group with no state/zip range
+ if not fpos:
+ fpos = self.search(domain_group + null_state_dom + null_zip_dom, limit=1)
+
+ if not fpos:
+ # Fallback on catchall (no country, no group)
+ fpos = self.search(base_domain + null_country_dom, limit=1)
+ return fpos
+
+ @api.model
+ def get_fiscal_position(self, partner_id, delivery_id=None):
+ """
+ :return: fiscal position found (recordset)
+ :rtype: :class:`account.fiscal.position`
+ """
+ if not partner_id:
+ return self.env['account.fiscal.position']
+
+ # This can be easily overridden to apply more complex fiscal rules
+ PartnerObj = self.env['res.partner']
+ partner = PartnerObj.browse(partner_id)
+ delivery = PartnerObj.browse(delivery_id)
+
+ # If partner and delivery have the same vat prefix, use invoicing
+ if not delivery or (delivery.vat and partner.vat and delivery.vat[:2] == partner.vat[:2]):
+ delivery = partner
+
+ # partner manually set fiscal position always win
+ if delivery.property_account_position_id or partner.property_account_position_id:
+ return delivery.property_account_position_id or partner.property_account_position_id
+
+ # First search only matching VAT positions
+ vat_required = bool(partner.vat)
+ fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, vat_required)
+
+ # Then if VAT required found no match, try positions that do not require it
+ if not fp and vat_required:
+ fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, False)
+
+ return fp or self.env['account.fiscal.position']
+
+
+class AccountFiscalPositionTax(models.Model):
+ _name = 'account.fiscal.position.tax'
+ _description = 'Tax Mapping of Fiscal Position'
+ _rec_name = 'position_id'
+ _check_company_auto = True
+
+ position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position',
+ required=True, ondelete='cascade')
+ company_id = fields.Many2one('res.company', string='Company', related='position_id.company_id', store=True)
+ tax_src_id = fields.Many2one('account.tax', string='Tax on Product', required=True, check_company=True)
+ tax_dest_id = fields.Many2one('account.tax', string='Tax to Apply', check_company=True)
+
+ _sql_constraints = [
+ ('tax_src_dest_uniq',
+ 'unique (position_id,tax_src_id,tax_dest_id)',
+ 'A tax fiscal position could be defined only one time on same taxes.')
+ ]
+
+
+class AccountFiscalPositionAccount(models.Model):
+ _name = 'account.fiscal.position.account'
+ _description = 'Accounts Mapping of Fiscal Position'
+ _rec_name = 'position_id'
+ _check_company_auto = True
+
+ position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position',
+ required=True, ondelete='cascade')
+ company_id = fields.Many2one('res.company', string='Company', related='position_id.company_id', store=True)
+ account_src_id = fields.Many2one('account.account', string='Account on Product',
+ check_company=True, required=True,
+ domain="[('deprecated', '=', False), ('company_id', '=', company_id)]")
+ account_dest_id = fields.Many2one('account.account', string='Account to Use Instead',
+ check_company=True, required=True,
+ domain="[('deprecated', '=', False), ('company_id', '=', company_id)]")
+
+ _sql_constraints = [
+ ('account_src_dest_uniq',
+ 'unique (position_id,account_src_id,account_dest_id)',
+ 'An account fiscal position could be defined only one time on same accounts.')
+ ]
+
+
+class ResPartner(models.Model):
+ _name = 'res.partner'
+ _inherit = 'res.partner'
+
+ @api.depends_context('company')
+ def _credit_debit_get(self):
+ tables, where_clause, where_params = self.env['account.move.line'].with_context(state='posted', company_id=self.env.company.id)._query_get()
+ where_params = [tuple(self.ids)] + where_params
+ if where_clause:
+ where_clause = 'AND ' + where_clause
+ self._cr.execute("""SELECT account_move_line.partner_id, act.type, SUM(account_move_line.amount_residual)
+ FROM """ + tables + """
+ LEFT JOIN account_account a ON (account_move_line.account_id=a.id)
+ LEFT JOIN account_account_type act ON (a.user_type_id=act.id)
+ WHERE act.type IN ('receivable','payable')
+ AND account_move_line.partner_id IN %s
+ AND account_move_line.reconciled IS NOT TRUE
+ """ + where_clause + """
+ GROUP BY account_move_line.partner_id, act.type
+ """, where_params)
+ treated = self.browse()
+ for pid, type, val in self._cr.fetchall():
+ partner = self.browse(pid)
+ if type == 'receivable':
+ partner.credit = val
+ if partner not in treated:
+ partner.debit = False
+ treated |= partner
+ elif type == 'payable':
+ partner.debit = -val
+ if partner not in treated:
+ partner.credit = False
+ treated |= partner
+ remaining = (self - treated)
+ remaining.debit = False
+ remaining.credit = False
+
+ def _asset_difference_search(self, account_type, operator, operand):
+ if operator not in ('<', '=', '>', '>=', '<='):
+ return []
+ if type(operand) not in (float, int):
+ return []
+ sign = 1
+ if account_type == 'payable':
+ sign = -1
+ res = self._cr.execute('''
+ SELECT partner.id
+ FROM res_partner partner
+ LEFT JOIN account_move_line aml ON aml.partner_id = partner.id
+ JOIN account_move move ON move.id = aml.move_id
+ RIGHT JOIN account_account acc ON aml.account_id = acc.id
+ WHERE acc.internal_type = %s
+ AND NOT acc.deprecated AND acc.company_id = %s
+ AND move.state = 'posted'
+ GROUP BY partner.id
+ HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand))
+ res = self._cr.fetchall()
+ if not res:
+ return [('id', '=', '0')]
+ return [('id', 'in', [r[0] for r in res])]
+
+ @api.model
+ def _credit_search(self, operator, operand):
+ return self._asset_difference_search('receivable', operator, operand)
+
+ @api.model
+ def _debit_search(self, operator, operand):
+ return self._asset_difference_search('payable', operator, operand)
+
+ def _invoice_total(self):
+ self.total_invoiced = 0
+ if not self.ids:
+ return True
+
+ all_partners_and_children = {}
+ all_partner_ids = []
+ for partner in self.filtered('id'):
+ # price_total is in the company currency
+ all_partners_and_children[partner] = self.with_context(active_test=False).search([('id', 'child_of', partner.id)]).ids
+ all_partner_ids += all_partners_and_children[partner]
+
+ domain = [
+ ('partner_id', 'in', all_partner_ids),
+ ('state', 'not in', ['draft', 'cancel']),
+ ('move_type', 'in', ('out_invoice', 'out_refund')),
+ ]
+ price_totals = self.env['account.invoice.report'].read_group(domain, ['price_subtotal'], ['partner_id'])
+ for partner, child_ids in all_partners_and_children.items():
+ partner.total_invoiced = sum(price['price_subtotal'] for price in price_totals if price['partner_id'][0] in child_ids)
+
+ def _compute_journal_item_count(self):
+ AccountMoveLine = self.env['account.move.line']
+ for partner in self:
+ partner.journal_item_count = AccountMoveLine.search_count([('partner_id', '=', partner.id)])
+
+ def _compute_has_unreconciled_entries(self):
+ for partner in self:
+ # Avoid useless work if has_unreconciled_entries is not relevant for this partner
+ if not partner.active or not partner.is_company and partner.parent_id:
+ partner.has_unreconciled_entries = False
+ continue
+ self.env.cr.execute(
+ """ SELECT 1 FROM(
+ SELECT
+ p.last_time_entries_checked AS last_time_entries_checked,
+ MAX(l.write_date) AS max_date
+ FROM
+ account_move_line l
+ RIGHT JOIN account_account a ON (a.id = l.account_id)
+ RIGHT JOIN res_partner p ON (l.partner_id = p.id)
+ WHERE
+ p.id = %s
+ AND EXISTS (
+ SELECT 1
+ FROM account_move_line l
+ WHERE l.account_id = a.id
+ AND l.partner_id = p.id
+ AND l.amount_residual > 0
+ )
+ AND EXISTS (
+ SELECT 1
+ FROM account_move_line l
+ WHERE l.account_id = a.id
+ AND l.partner_id = p.id
+ AND l.amount_residual < 0
+ )
+ GROUP BY p.last_time_entries_checked
+ ) as s
+ WHERE (last_time_entries_checked IS NULL OR max_date > last_time_entries_checked)
+ """, (partner.id,))
+ partner.has_unreconciled_entries = self.env.cr.rowcount == 1
+
+ def mark_as_reconciled(self):
+ self.env['account.partial.reconcile'].check_access_rights('write')
+ return self.sudo().write({'last_time_entries_checked': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)})
+
+ def _get_company_currency(self):
+ for partner in self:
+ if partner.company_id:
+ partner.currency_id = partner.sudo().company_id.currency_id
+ else:
+ partner.currency_id = self.env.company.currency_id
+
+ credit = fields.Monetary(compute='_credit_debit_get', search=_credit_search,
+ string='Total Receivable', help="Total amount this customer owes you.")
+ debit = fields.Monetary(compute='_credit_debit_get', search=_debit_search, string='Total Payable',
+ help="Total amount you have to pay to this vendor.")
+ debit_limit = fields.Monetary('Payable Limit')
+ total_invoiced = fields.Monetary(compute='_invoice_total', string="Total Invoiced",
+ groups='account.group_account_invoice,account.group_account_readonly')
+ currency_id = fields.Many2one('res.currency', compute='_get_company_currency', readonly=True,
+ string="Currency", help='Utility field to express amount currency')
+ journal_item_count = fields.Integer(compute='_compute_journal_item_count', string="Journal Items")
+ property_account_payable_id = fields.Many2one('account.account', company_dependent=True,
+ string="Account Payable",
+ domain="[('internal_type', '=', 'payable'), ('deprecated', '=', False), ('company_id', '=', current_company_id)]",
+ help="This account will be used instead of the default one as the payable account for the current partner",
+ required=True)
+ property_account_receivable_id = fields.Many2one('account.account', company_dependent=True,
+ string="Account Receivable",
+ domain="[('internal_type', '=', 'receivable'), ('deprecated', '=', False), ('company_id', '=', current_company_id)]",
+ help="This account will be used instead of the default one as the receivable account for the current partner",
+ required=True)
+ property_account_position_id = fields.Many2one('account.fiscal.position', company_dependent=True,
+ string="Fiscal Position",
+ domain="[('company_id', '=', current_company_id)]",
+ help="The fiscal position determines the taxes/accounts used for this contact.")
+ property_payment_term_id = fields.Many2one('account.payment.term', company_dependent=True,
+ string='Customer Payment Terms',
+ domain="[('company_id', 'in', [current_company_id, False])]",
+ help="This payment term will be used instead of the default one for sales orders and customer invoices")
+ property_supplier_payment_term_id = fields.Many2one('account.payment.term', company_dependent=True,
+ string='Vendor Payment Terms',
+ domain="[('company_id', 'in', [current_company_id, False])]",
+ help="This payment term will be used instead of the default one for purchase orders and vendor bills")
+ ref_company_ids = fields.One2many('res.company', 'partner_id',
+ string='Companies that refers to partner')
+ has_unreconciled_entries = fields.Boolean(compute='_compute_has_unreconciled_entries',
+ help="The partner has at least one unreconciled debit and credit since last time the invoices & payments matching was performed.")
+ last_time_entries_checked = fields.Datetime(
+ string='Latest Invoices & Payments Matching Date', readonly=True, copy=False,
+ help='Last time the invoices & payments matching was performed for this partner. '
+ 'It is set either if there\'s not at least an unreconciled debit and an unreconciled credit '
+ 'or if you click the "Done" button.')
+ invoice_ids = fields.One2many('account.move', 'partner_id', string='Invoices', readonly=True, copy=False)
+ contract_ids = fields.One2many('account.analytic.account', 'partner_id', string='Partner Contracts', readonly=True)
+ bank_account_count = fields.Integer(compute='_compute_bank_count', string="Bank")
+ trust = fields.Selection([('good', 'Good Debtor'), ('normal', 'Normal Debtor'), ('bad', 'Bad Debtor')], string='Degree of trust you have in this debtor', default='normal', company_dependent=True)
+ invoice_warn = fields.Selection(WARNING_MESSAGE, 'Invoice', help=WARNING_HELP, default="no-message")
+ invoice_warn_msg = fields.Text('Message for Invoice')
+ # Computed fields to order the partners as suppliers/customers according to the
+ # amount of their generated incoming/outgoing account moves
+ supplier_rank = fields.Integer(default=0)
+ customer_rank = fields.Integer(default=0)
+
+ def _get_name_search_order_by_fields(self):
+ res = super()._get_name_search_order_by_fields()
+ partner_search_mode = self.env.context.get('res_partner_search_mode')
+ if not partner_search_mode in ('customer', 'supplier'):
+ return res
+ order_by_field = 'COALESCE(res_partner.%s, 0) DESC,'
+ if partner_search_mode == 'customer':
+ field = 'customer_rank'
+ else:
+ field = 'supplier_rank'
+
+ order_by_field = order_by_field % field
+ return '%s, %s' % (res, order_by_field % field) if res else order_by_field
+
+ def _compute_bank_count(self):
+ bank_data = self.env['res.partner.bank'].read_group([('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id'])
+ mapped_data = dict([(bank['partner_id'][0], bank['partner_id_count']) for bank in bank_data])
+ for partner in self:
+ partner.bank_account_count = mapped_data.get(partner.id, 0)
+
+ def _find_accounting_partner(self, partner):
+ ''' Find the partner for which the accounting entries will be created '''
+ return partner.commercial_partner_id
+
+ @api.model
+ def _commercial_fields(self):
+ return super(ResPartner, self)._commercial_fields() + \
+ ['debit_limit', 'property_account_payable_id', 'property_account_receivable_id', 'property_account_position_id',
+ 'property_payment_term_id', 'property_supplier_payment_term_id', 'last_time_entries_checked']
+
+ def action_view_partner_invoices(self):
+ self.ensure_one()
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type")
+ action['domain'] = [
+ ('move_type', 'in', ('out_invoice', 'out_refund')),
+ ('partner_id', 'child_of', self.id),
+ ]
+ action['context'] = {'default_move_type':'out_invoice', 'move_type':'out_invoice', 'journal_type': 'sale', 'search_default_unpaid': 1}
+ return action
+
+ def can_edit_vat(self):
+ ''' Can't edit `vat` if there is (non draft) issued invoices. '''
+ can_edit_vat = super(ResPartner, self).can_edit_vat()
+ if not can_edit_vat:
+ return can_edit_vat
+ has_invoice = self.env['account.move'].search([
+ ('move_type', 'in', ['out_invoice', 'out_refund']),
+ ('partner_id', 'child_of', self.commercial_partner_id.id),
+ ('state', '=', 'posted')
+ ], limit=1)
+ return can_edit_vat and not (bool(has_invoice))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ search_partner_mode = self.env.context.get('res_partner_search_mode')
+ is_customer = search_partner_mode == 'customer'
+ is_supplier = search_partner_mode == 'supplier'
+ if search_partner_mode:
+ for vals in vals_list:
+ if is_customer and 'customer_rank' not in vals:
+ vals['customer_rank'] = 1
+ elif is_supplier and 'supplier_rank' not in vals:
+ vals['supplier_rank'] = 1
+ return super().create(vals_list)
+
+ def _increase_rank(self, field, n=1):
+ if self.ids and field in ['customer_rank', 'supplier_rank']:
+ try:
+ with self.env.cr.savepoint(flush=False):
+ query = sql.SQL("""
+ SELECT {field} FROM res_partner WHERE ID IN %(partner_ids)s FOR UPDATE NOWAIT;
+ UPDATE res_partner SET {field} = {field} + %(n)s
+ WHERE id IN %(partner_ids)s
+ """).format(field=sql.Identifier(field))
+ self.env.cr.execute(query, {'partner_ids': tuple(self.ids), 'n': n})
+ for partner in self:
+ self.env.cache.remove(partner, partner._fields[field])
+ except DatabaseError as e:
+ if e.pgcode == '55P03':
+ _logger.debug('Another transaction already locked partner rows. Cannot update partner ranks.')
+ else:
+ raise e
diff --git a/addons/account/models/product.py b/addons/account/models/product.py
new file mode 100644
index 00000000..14db12e4
--- /dev/null
+++ b/addons/account/models/product.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+
+ACCOUNT_DOMAIN = "['&', '&', '&', ('deprecated', '=', False), ('internal_type','=','other'), ('company_id', '=', current_company_id), ('is_off_balance', '=', False)]"
+
+class ProductCategory(models.Model):
+ _inherit = "product.category"
+
+ property_account_income_categ_id = fields.Many2one('account.account', company_dependent=True,
+ string="Income Account",
+ domain=ACCOUNT_DOMAIN,
+ help="This account will be used when validating a customer invoice.")
+ property_account_expense_categ_id = fields.Many2one('account.account', company_dependent=True,
+ string="Expense Account",
+ domain=ACCOUNT_DOMAIN,
+ help="The expense is accounted for when a vendor bill is validated, except in anglo-saxon accounting with perpetual inventory valuation in which case the expense (Cost of Goods Sold account) is recognized at the customer invoice validation.")
+
+#----------------------------------------------------------
+# Products
+#----------------------------------------------------------
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ taxes_id = fields.Many2many('account.tax', 'product_taxes_rel', 'prod_id', 'tax_id', help="Default taxes used when selling the product.", string='Customer Taxes',
+ domain=[('type_tax_use', '=', 'sale')], default=lambda self: self.env.company.account_sale_tax_id)
+ supplier_taxes_id = fields.Many2many('account.tax', 'product_supplier_taxes_rel', 'prod_id', 'tax_id', string='Vendor Taxes', help='Default taxes used when buying the product.',
+ domain=[('type_tax_use', '=', 'purchase')], default=lambda self: self.env.company.account_purchase_tax_id)
+ property_account_income_id = fields.Many2one('account.account', company_dependent=True,
+ string="Income Account",
+ domain=ACCOUNT_DOMAIN,
+ help="Keep this field empty to use the default value from the product category.")
+ property_account_expense_id = fields.Many2one('account.account', company_dependent=True,
+ string="Expense Account",
+ domain=ACCOUNT_DOMAIN,
+ help="Keep this field empty to use the default value from the product category. If anglo-saxon accounting with automated valuation method is configured, the expense account on the product category will be used.")
+
+ def _get_product_accounts(self):
+ return {
+ 'income': self.property_account_income_id or self.categ_id.property_account_income_categ_id,
+ 'expense': self.property_account_expense_id or self.categ_id.property_account_expense_categ_id
+ }
+
+ def _get_asset_accounts(self):
+ res = {}
+ res['stock_input'] = False
+ res['stock_output'] = False
+ return res
+
+ def get_product_accounts(self, fiscal_pos=None):
+ accounts = self._get_product_accounts()
+ if not fiscal_pos:
+ fiscal_pos = self.env['account.fiscal.position']
+ return fiscal_pos.map_accounts(accounts)
+
+
+class ProductProduct(models.Model):
+ _inherit = "product.product"
+
+ def _get_product_accounts(self):
+ return self.product_tmpl_id._get_product_accounts()
diff --git a/addons/account/models/res_bank.py b/addons/account/models/res_bank.py
new file mode 100644
index 00000000..226e1c88
--- /dev/null
+++ b/addons/account/models/res_bank.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, models, fields, _
+from odoo.exceptions import UserError
+
+class ResPartnerBank(models.Model):
+ _inherit = 'res.partner.bank'
+
+ def build_qr_code_url(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
+ """ Returns the QR-code report URL to pay to this account with the given parameters,
+ or None if no QR-code could be generated.
+
+ :param amount: The amount to be paid
+ :param free_communication: Free communication to add to the payment when generating one with the QR-code
+ :param structured_communication: Structured communication to add to the payment when generating one with the QR-code
+ :param currency: The currency in which amount is expressed
+ :param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
+ :param qr_method: The QR generation method to be used to make the QR-code. If None, the first one giving a result will be used.
+ :param silent_errors: If true, forbids errors to be raised if some tested QR-code format can't be generated because of incorrect data.
+ """
+ if not self:
+ return None
+
+ self.ensure_one()
+
+ if not currency:
+ raise UserError(_("Currency must always be provided in order to generate a QR-code"))
+
+ available_qr_methods = self.get_available_qr_methods_in_sequence()
+ candidate_methods = qr_method and [(qr_method, dict(available_qr_methods)[qr_method])] or available_qr_methods
+ for candidate_method, candidate_name in candidate_methods:
+ if self._eligible_for_qr_code(candidate_method, debtor_partner, currency):
+ error_message = self._check_for_qr_code_errors(candidate_method, amount, currency, debtor_partner, free_communication, structured_communication)
+
+ if not error_message:
+ return self._get_qr_code_url(candidate_method, amount, currency, debtor_partner, free_communication, structured_communication)
+
+ elif not silent_errors:
+ error_header = _("The following error prevented '%s' QR-code to be generated though it was detected as eligible: ", candidate_name)
+ raise UserError( error_header + error_message)
+
+ return None
+
+ def _get_qr_code_url(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
+ """ Hook for extension, to support the different QR generation methods.
+ This function uses the provided qr_method to try generation a QR-code for
+ the given data. It it succeeds, it returns the report URL to make this
+ QR-code; else None.
+
+ :param qr_method: The QR generation method to be used to make the QR-code.
+ :param amount: The amount to be paid
+ :param currency: The currency in which amount is expressed
+ :param debtor_partner: The partner to which this QR-code is aimed (so the one who will have to pay)
+ :param free_communication: Free communication to add to the payment when generating one with the QR-code
+ :param structured_communication: Structured communication to add to the payment when generating one with the QR-code
+ """
+ return None
+
+ @api.model
+ def _get_available_qr_methods(self):
+ """ Returns the QR-code generation methods that are available on this db,
+ in the form of a list of (code, name, sequence) elements, where
+ 'code' is a unique string identifier, 'name' the name to display
+ to the user to designate the method, and 'sequence' is a positive integer
+ indicating the order in which those mehtods need to be checked, to avoid
+ shadowing between them (lower sequence means more prioritary).
+ """
+ return []
+
+ @api.model
+ def get_available_qr_methods_in_sequence(self):
+ """ Same as _get_available_qr_methods but without returning the sequence,
+ and using it directly to order the returned list.
+ """
+ all_available = self._get_available_qr_methods()
+ all_available.sort(key=lambda x: x[2])
+ return [(code, name) for (code, name, sequence) in all_available]
+
+
+ def _eligible_for_qr_code(self, qr_method, debtor_partner, currency):
+ """ Tells whether or not the criteria to apply QR-generation
+ method qr_method are met for a payment on this account, in the
+ given currency, by debtor_partner. This does not impeach generation errors,
+ it only checks that this type of QR-code *should be* possible to generate.
+ Consistency of the required field needs then to be checked by _check_for_qr_code_errors().
+ """
+ return False
+
+ def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
+ """ Checks the data before generating a QR-code for the specified qr_method
+ (this method must have been checked for eligbility by _eligible_for_qr_code() first).
+
+ Returns None if no error was found, or a string describing the first error encountered
+ so that it can be reported to the user.
+ """
+ return None \ No newline at end of file
diff --git a/addons/account/models/res_config_settings.py b/addons/account/models/res_config_settings.py
new file mode 100644
index 00000000..9572d6e4
--- /dev/null
+++ b/addons/account/models/res_config_settings.py
@@ -0,0 +1,164 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ has_accounting_entries = fields.Boolean(compute='_compute_has_chart_of_accounts')
+ currency_id = fields.Many2one('res.currency', related="company_id.currency_id", required=True, readonly=False,
+ string='Currency', help="Main currency of the company.")
+ currency_exchange_journal_id = fields.Many2one(
+ comodel_name='account.journal',
+ related='company_id.currency_exchange_journal_id', readonly=False,
+ string="Currency Exchange Journal",
+ domain="[('company_id', '=', company_id), ('type', '=', 'general')]",
+ help='The accounting journal where automatic exchange differences will be registered')
+ income_currency_exchange_account_id = fields.Many2one(
+ comodel_name="account.account",
+ related="company_id.income_currency_exchange_account_id",
+ string="Gain Account",
+ readonly=False,
+ domain=lambda self: "[('internal_type', '=', 'other'), ('deprecated', '=', False), ('company_id', '=', company_id),\
+ ('user_type_id', 'in', %s)]" % [self.env.ref('account.data_account_type_revenue').id,
+ self.env.ref('account.data_account_type_other_income').id])
+ expense_currency_exchange_account_id = fields.Many2one(
+ comodel_name="account.account",
+ related="company_id.expense_currency_exchange_account_id",
+ string="Loss Account",
+ readonly=False,
+ domain=lambda self: "[('internal_type', '=', 'other'), ('deprecated', '=', False), ('company_id', '=', company_id),\
+ ('user_type_id', '=', %s)]" % self.env.ref('account.data_account_type_expenses').id)
+ has_chart_of_accounts = fields.Boolean(compute='_compute_has_chart_of_accounts', string='Company has a chart of accounts')
+ chart_template_id = fields.Many2one('account.chart.template', string='Template', default=lambda self: self.env.company.chart_template_id,
+ domain="[('visible','=', True)]")
+ sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", related='company_id.account_sale_tax_id', readonly=False)
+ purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax", related='company_id.account_purchase_tax_id', readonly=False)
+ tax_calculation_rounding_method = fields.Selection(
+ related='company_id.tax_calculation_rounding_method', string='Tax calculation rounding method', readonly=False)
+ module_account_accountant = fields.Boolean(string='Accounting')
+ group_analytic_accounting = fields.Boolean(string='Analytic Accounting',
+ implied_group='analytic.group_analytic_accounting')
+ group_analytic_tags = fields.Boolean(string='Analytic Tags', implied_group='analytic.group_analytic_tags')
+ group_warning_account = fields.Boolean(string="Warnings in Invoices", implied_group='account.group_warning_account')
+ group_cash_rounding = fields.Boolean(string="Cash Rounding", implied_group='account.group_cash_rounding')
+ # group_show_line_subtotals_tax_excluded and group_show_line_subtotals_tax_included are opposite,
+ # so we can assume exactly one of them will be set, and not the other.
+ # We need both of them to coexist so we can take advantage of automatic group assignation.
+ group_show_line_subtotals_tax_excluded = fields.Boolean(
+ "Show line subtotals without taxes (B2B)",
+ implied_group='account.group_show_line_subtotals_tax_excluded',
+ group='base.group_portal,base.group_user,base.group_public')
+ group_show_line_subtotals_tax_included = fields.Boolean(
+ "Show line subtotals with taxes (B2C)",
+ implied_group='account.group_show_line_subtotals_tax_included',
+ group='base.group_portal,base.group_user,base.group_public')
+ group_show_sale_receipts = fields.Boolean(string='Sale Receipt',
+ implied_group='account.group_sale_receipts')
+ group_show_purchase_receipts = fields.Boolean(string='Purchase Receipt',
+ implied_group='account.group_purchase_receipts')
+ show_line_subtotals_tax_selection = fields.Selection([
+ ('tax_excluded', 'Tax-Excluded'),
+ ('tax_included', 'Tax-Included')], string="Line Subtotals Tax Display",
+ required=True, default='tax_excluded',
+ config_parameter='account.show_line_subtotals_tax_selection')
+ module_account_budget = fields.Boolean(string='Budget Management')
+ module_account_payment = fields.Boolean(string='Invoice Online Payment')
+ module_account_reports = fields.Boolean("Dynamic Reports")
+ module_account_check_printing = fields.Boolean("Allow check printing and deposits")
+ module_account_batch_payment = fields.Boolean(string='Use batch payments',
+ help='This allows you grouping payments into a single batch and eases the reconciliation process.\n'
+ '-This installs the account_batch_payment module.')
+ module_account_sepa = fields.Boolean(string='SEPA Credit Transfer (SCT)')
+ module_account_sepa_direct_debit = fields.Boolean(string='Use SEPA Direct Debit')
+ module_account_plaid = fields.Boolean(string="Plaid Connector")
+ module_account_yodlee = fields.Boolean("Bank Interface - Sync your bank feeds automatically")
+ module_account_bank_statement_import_qif = fields.Boolean("Import .qif files")
+ module_account_bank_statement_import_ofx = fields.Boolean("Import in .ofx format")
+ module_account_bank_statement_import_csv = fields.Boolean("Import in .csv format")
+ module_account_bank_statement_import_camt = fields.Boolean("Import in CAMT.053 format")
+ module_currency_rate_live = fields.Boolean(string="Automatic Currency Rates")
+ module_account_intrastat = fields.Boolean(string='Intrastat')
+ module_product_margin = fields.Boolean(string="Allow Product Margin")
+ module_l10n_eu_service = fields.Boolean(string="EU Intra-community Distance Selling")
+ module_account_taxcloud = fields.Boolean(string="Account TaxCloud")
+ module_account_invoice_extract = fields.Boolean(string="Bill Digitalization")
+ 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)
+ invoice_is_email = fields.Boolean(string='Send Email', related='company_id.invoice_is_email', readonly=False)
+ incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm', related='company_id.incoterm_id', help='International Commercial Terms are a series of predefined commercial terms used in international transactions.', readonly=False)
+ invoice_terms = fields.Text(related='company_id.invoice_terms', string="Terms & Conditions", readonly=False)
+ use_invoice_terms = fields.Boolean(
+ string='Default Terms & Conditions',
+ config_parameter='account.use_invoice_terms')
+
+ # Technical field to hide country specific fields from accounting configuration
+ country_code = fields.Char(related='company_id.country_id.code', readonly=True)
+
+ def set_values(self):
+ super(ResConfigSettings, self).set_values()
+ if self.group_multi_currency:
+ self.env.ref('base.group_user').write({'implied_ids': [(4, self.env.ref('product.group_sale_pricelist').id)]})
+ # install a chart of accounts for the given company (if required)
+ if self.env.company == self.company_id and self.chart_template_id and self.chart_template_id != self.company_id.chart_template_id:
+ self.chart_template_id._load(15.0, 15.0, self.env.company)
+
+ @api.depends('company_id')
+ def _compute_has_chart_of_accounts(self):
+ self.has_chart_of_accounts = bool(self.company_id.chart_template_id)
+ self.has_accounting_entries = self.env['account.chart.template'].existing_accounting(self.company_id)
+
+ @api.onchange('show_line_subtotals_tax_selection')
+ def _onchange_sale_tax(self):
+ if self.show_line_subtotals_tax_selection == "tax_excluded":
+ self.update({
+ 'group_show_line_subtotals_tax_included': False,
+ 'group_show_line_subtotals_tax_excluded': True,
+ })
+ else:
+ self.update({
+ 'group_show_line_subtotals_tax_included': True,
+ 'group_show_line_subtotals_tax_excluded': False,
+ })
+
+ @api.onchange('group_analytic_accounting')
+ def onchange_analytic_accounting(self):
+ if self.group_analytic_accounting:
+ self.module_account_accountant = True
+
+ @api.onchange('module_account_budget')
+ def onchange_module_account_budget(self):
+ if self.module_account_budget:
+ self.group_analytic_accounting = True
+
+ @api.onchange('module_account_yodlee')
+ def onchange_account_yodlee(self):
+ if self.module_account_yodlee:
+ self.module_account_plaid = True
+
+ @api.onchange('tax_exigibility')
+ def _onchange_tax_exigibility(self):
+ res = {}
+ tax = self.env['account.tax'].search([
+ ('company_id', '=', self.env.company.id), ('tax_exigibility', '=', 'on_payment')
+ ], limit=1)
+ if not self.tax_exigibility and tax:
+ self.tax_exigibility = True
+ res['warning'] = {
+ 'title': _('Error!'),
+ 'message': _('You cannot disable this setting because some of your taxes are cash basis. '
+ 'Modify your taxes first before disabling this setting.')
+ }
+ return res
diff --git a/addons/account/models/res_currency.py b/addons/account/models/res_currency.py
new file mode 100644
index 00000000..964a60e9
--- /dev/null
+++ b/addons/account/models/res_currency.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, fields, _
+from odoo.exceptions import UserError
+
+
+class ResCurrency(models.Model):
+ _inherit = 'res.currency'
+
+ display_rounding_warning = fields.Boolean(string="Display Rounding Warning", compute='_compute_display_rounding_warning',
+ help="Technical field. Used to tell whether or not to display the rounding warning. The warning informs a rounding factor change might be dangerous on res.currency's form view.")
+
+
+ @api.depends('rounding')
+ def _compute_display_rounding_warning(self):
+ for record in self:
+ record.display_rounding_warning = record.id \
+ and record._origin.rounding != record.rounding \
+ and record._origin._has_accounting_entries()
+
+ def write(self, vals):
+ if 'rounding' in vals:
+ rounding_val = vals['rounding']
+ for record in self:
+ if (rounding_val > record.rounding or rounding_val == 0) and record._has_accounting_entries():
+ raise UserError(_("You cannot reduce the number of decimal places of a currency which has already been used to make accounting entries."))
+
+ return super(ResCurrency, self).write(vals)
+
+ def _has_accounting_entries(self):
+ """ Returns True iff this currency has been used to generate (hence, round)
+ some move lines (either as their foreign currency, or as the main currency
+ of their company).
+ """
+ self.ensure_one()
+ return bool(self.env['account.move.line'].search_count(['|', ('currency_id', '=', self.id), ('company_currency_id', '=', self.id)]))
+
+ @api.model
+ def _get_query_currency_table(self, options):
+ ''' Construct the currency table as a mapping company -> rate to convert the amount to the user's company
+ currency in a multi-company/multi-currency environment.
+ The currency_table is a small postgresql table construct with VALUES.
+ :param options: The report options.
+ :return: The query representing the currency table.
+ '''
+
+ user_company = self.env.company
+ user_currency = user_company.currency_id
+ if options.get('multi_company', False):
+ companies = self.env.companies
+ conversion_date = options['date']['date_to']
+ currency_rates = companies.mapped('currency_id')._get_rates(user_company, conversion_date)
+ else:
+ companies = user_company
+ currency_rates = {user_currency.id: 1.0}
+
+ conversion_rates = []
+ for company in companies:
+ conversion_rates.extend((
+ company.id,
+ currency_rates[user_company.currency_id.id] / currency_rates[company.currency_id.id],
+ user_currency.decimal_places,
+ ))
+ query = '(VALUES %s) AS currency_table(company_id, rate, precision)' % ','.join('(%s, %s, %s)' for i in companies)
+ return self.env.cr.mogrify(query, conversion_rates).decode(self.env.cr.connection.encoding)
diff --git a/addons/account/models/res_partner_bank.py b/addons/account/models/res_partner_bank.py
new file mode 100644
index 00000000..2bfae1ae
--- /dev/null
+++ b/addons/account/models/res_partner_bank.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+from odoo.exceptions import ValidationError
+from odoo import api, fields, models, _
+
+
+class ResPartnerBank(models.Model):
+ _inherit = "res.partner.bank"
+
+ journal_id = fields.One2many('account.journal', 'bank_account_id', domain=[('type', '=', 'bank')], string='Account Journal', readonly=True,
+ help="The accounting journal corresponding to this bank account.")
+
+ @api.constrains('journal_id')
+ def _check_journal_id(self):
+ for bank in self:
+ if len(bank.journal_id) > 1:
+ raise ValidationError(_('A bank account can belong to only one journal.'))
diff --git a/addons/account/models/res_users.py b/addons/account/models/res_users.py
new file mode 100644
index 00000000..0cf9d85c
--- /dev/null
+++ b/addons/account/models/res_users.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, _
+from odoo.exceptions import ValidationError
+
+
+class Users(models.Model):
+ _inherit = "res.users"
+
+ @api.constrains('groups_id')
+ def _check_one_user_type(self):
+ super(Users, self)._check_one_user_type()
+
+ g1 = self.env.ref('account.group_show_line_subtotals_tax_included', False)
+ g2 = self.env.ref('account.group_show_line_subtotals_tax_excluded', False)
+
+ if not g1 or not g2:
+ # A user cannot be in a non-existant group
+ return
+
+ for user in self:
+ if user._has_multiple_groups([g1.id, g2.id]):
+ raise ValidationError(_("A user cannot have both Tax B2B and Tax B2C.\n"
+ "You should go in General Settings, and choose to display Product Prices\n"
+ "either in 'Tax-Included' or in 'Tax-Excluded' mode\n"
+ "(or switch twice the mode if you are already in the desired one)."))
diff --git a/addons/account/models/sequence_mixin.py b/addons/account/models/sequence_mixin.py
new file mode 100644
index 00000000..2fd1bae9
--- /dev/null
+++ b/addons/account/models/sequence_mixin.py
@@ -0,0 +1,241 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.exceptions import ValidationError
+from odoo.tools.misc import format_date
+
+import re
+from psycopg2 import sql
+
+
+class SequenceMixin(models.AbstractModel):
+ """Mechanism used to have an editable sequence number.
+
+ Be careful of how you use this regarding the prefixes. More info in the
+ docstring of _get_last_sequence.
+ """
+
+ _name = 'sequence.mixin'
+ _description = "Automatic sequence"
+
+ _sequence_field = "name"
+ _sequence_date_field = "date"
+ _sequence_index = False
+ _sequence_monthly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix2>\D*?)(?P<month>(0[1-9]|1[0-2]))(?P<prefix3>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
+ _sequence_yearly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((20|21)?\d{2}))(?P<prefix2>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
+ _sequence_fixed_regex = r'^(?P<prefix1>.*?)(?P<seq>\d{0,9})(?P<suffix>\D*?)$'
+
+ sequence_prefix = fields.Char(compute='_compute_split_sequence', store=True)
+ sequence_number = fields.Integer(compute='_compute_split_sequence', store=True)
+
+ def init(self):
+ # Add an index to optimise the query searching for the highest sequence number
+ if not self._abstract and self._sequence_index:
+ index_name = self._table + '_sequence_index'
+ self.env.cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', (index_name,))
+ if not self.env.cr.fetchone():
+ self.env.cr.execute(sql.SQL("""
+ CREATE INDEX {index_name} ON {table} ({sequence_index}, sequence_prefix desc, sequence_number desc, {field});
+ CREATE INDEX {index2_name} ON {table} ({sequence_index}, id desc, sequence_prefix);
+ """).format(
+ sequence_index=sql.Identifier(self._sequence_index),
+ index_name=sql.Identifier(index_name),
+ index2_name=sql.Identifier(index_name + "2"),
+ table=sql.Identifier(self._table),
+ field=sql.Identifier(self._sequence_field),
+ ))
+
+ def __init__(self, pool, cr):
+ api.constrains(self._sequence_field, self._sequence_date_field)(type(self)._constrains_date_sequence)
+ return super().__init__(pool, cr)
+
+ def _constrains_date_sequence(self):
+ # Make it possible to bypass the constraint to allow edition of already messed up documents.
+ # /!\ Do not use this to completely disable the constraint as it will make this mixin unreliable.
+ constraint_date = fields.Date.to_date(self.env['ir.config_parameter'].sudo().get_param(
+ 'sequence.mixin.constraint_start_date',
+ '1970-01-01'
+ ))
+ for record in self:
+ date = fields.Date.to_date(record[record._sequence_date_field])
+ sequence = record[record._sequence_field]
+ if sequence and date and date > constraint_date:
+ format_values = record._get_sequence_format_param(sequence)[1]
+ if (
+ format_values['year'] and format_values['year'] != date.year % 10**len(str(format_values['year']))
+ or format_values['month'] and format_values['month'] != date.month
+ ):
+ raise ValidationError(_(
+ "The %(date_field)s (%(date)s) doesn't match the %(sequence_field)s (%(sequence)s).\n"
+ "You might want to clear the field %(sequence_field)s before proceeding with the change of the date.",
+ date=format_date(self.env, date),
+ sequence=sequence,
+ date_field=record._fields[record._sequence_date_field]._description_string(self.env),
+ sequence_field=record._fields[record._sequence_field]._description_string(self.env),
+ ))
+
+ @api.depends(lambda self: [self._sequence_field])
+ def _compute_split_sequence(self):
+ for record in self:
+ sequence = record[record._sequence_field] or ''
+ regex = re.sub(r"\?P<\w+>", "?:", record._sequence_fixed_regex.replace(r"?P<seq>", "")) # make the seq the only matching group
+ matching = re.match(regex, sequence)
+ record.sequence_prefix = sequence[:matching.start(1)]
+ record.sequence_number = int(matching.group(1) or 0)
+
+ @api.model
+ def _deduce_sequence_number_reset(self, name):
+ """Detect if the used sequence resets yearly, montly or never.
+
+ :param name: the sequence that is used as a reference to detect the resetting
+ periodicity. Typically, it is the last before the one you want to give a
+ sequence.
+ """
+ for regex, ret_val, requirements in [
+ (self._sequence_monthly_regex, 'month', ['seq', 'month', 'year']),
+ (self._sequence_yearly_regex, 'year', ['seq', 'year']),
+ (self._sequence_fixed_regex, 'never', ['seq']),
+ ]:
+ match = re.match(regex, name or '')
+ if match:
+ groupdict = match.groupdict()
+ if all(req in groupdict for req in requirements):
+ return ret_val
+ raise ValidationError(_(
+ 'The sequence regex should at least contain the seq grouping keys. For instance:\n'
+ '^(?P<prefix1>.*?)(?P<seq>\d*)(?P<suffix>\D*?)$'
+ ))
+
+ def _get_last_sequence_domain(self, relaxed=False):
+ """Get the sql domain to retreive the previous sequence number.
+
+ This function should be overriden by models heriting from this mixin.
+
+ :param relaxed: see _get_last_sequence.
+
+ :returns: tuple(where_string, where_params): with
+ where_string: the entire SQL WHERE clause as a string.
+ where_params: a dictionary containing the parameters to substitute
+ at the execution of the query.
+ """
+ self.ensure_one()
+ return "", {}
+
+ def _get_starting_sequence(self):
+ """Get a default sequence number.
+
+ This function should be overriden by models heriting from this mixin
+ This number will be incremented so you probably want to start the sequence at 0.
+
+ :return: string to use as the default sequence to increment
+ """
+ self.ensure_one()
+ return "00000000"
+
+ def _get_last_sequence(self, relaxed=False):
+ """Retrieve the previous sequence.
+
+ This is done by taking the number with the greatest alphabetical value within
+ the domain of _get_last_sequence_domain. This means that the prefix has a
+ huge importance.
+ For instance, if you have INV/2019/0001 and INV/2019/0002, when you rename the
+ last one to FACT/2019/0001, one might expect the next number to be
+ FACT/2019/0002 but it will be INV/2019/0002 (again) because INV > FACT.
+ Therefore, changing the prefix might not be convenient during a period, and
+ would only work when the numbering makes a new start (domain returns by
+ _get_last_sequence_domain is [], i.e: a new year).
+
+ :param field_name: the field that contains the sequence.
+ :param relaxed: this should be set to True when a previous request didn't find
+ something without. This allows to find a pattern from a previous period, and
+ try to adapt it for the new period.
+
+ :return: the string of the previous sequence or None if there wasn't any.
+ """
+ self.ensure_one()
+ if self._sequence_field not in self._fields or not self._fields[self._sequence_field].store:
+ raise ValidationError(_('%s is not a stored field', self._sequence_field))
+ where_string, param = self._get_last_sequence_domain(relaxed)
+ if self.id or self.id.origin:
+ where_string += " AND id != %(id)s "
+ param['id'] = self.id or self.id.origin
+
+ query = """
+ UPDATE {table} SET write_date = write_date WHERE id = (
+ SELECT id FROM {table}
+ {where_string}
+ AND sequence_prefix = (SELECT sequence_prefix FROM {table} {where_string} ORDER BY id DESC LIMIT 1)
+ ORDER BY sequence_number DESC
+ LIMIT 1
+ )
+ RETURNING {field};
+ """.format(
+ table=self._table,
+ where_string=where_string,
+ field=self._sequence_field,
+ )
+
+ self.flush([self._sequence_field, 'sequence_number', 'sequence_prefix'])
+ self.env.cr.execute(query, param)
+ return (self.env.cr.fetchone() or [None])[0]
+
+ def _get_sequence_format_param(self, previous):
+ """Get the python format and format values for the sequence.
+
+ :param previous: the sequence we want to extract the format from
+ :return tuple(format, format_values):
+ format is the format string on which we should call .format()
+ format_values is the dict of values to format the `format` string
+ ``format.format(**format_values)`` should be equal to ``previous``
+ """
+ sequence_number_reset = self._deduce_sequence_number_reset(previous)
+ regex = self._sequence_fixed_regex
+ if sequence_number_reset == 'year':
+ regex = self._sequence_yearly_regex
+ elif sequence_number_reset == 'month':
+ regex = self._sequence_monthly_regex
+
+ format_values = re.match(regex, previous).groupdict()
+ format_values['seq_length'] = len(format_values['seq'])
+ format_values['year_length'] = len(format_values.get('year', ''))
+ if not format_values.get('seq') and 'prefix1' in format_values and 'suffix' in format_values:
+ # if we don't have a seq, consider we only have a prefix and not a suffix
+ format_values['prefix1'] = format_values['suffix']
+ format_values['suffix'] = ''
+ for field in ('seq', 'year', 'month'):
+ format_values[field] = int(format_values.get(field) or 0)
+
+ placeholders = re.findall(r'(prefix\d|seq|suffix\d?|year|month)', regex)
+ format = ''.join(
+ "{seq:0{seq_length}d}" if s == 'seq' else
+ "{month:02d}" if s == 'month' else
+ "{year:0{year_length}d}" if s == 'year' else
+ "{%s}" % s
+ for s in placeholders
+ )
+ return format, format_values
+
+ def _set_next_sequence(self):
+ """Set the next sequence.
+
+ This method ensures that the field is set both in the ORM and in the database.
+ This is necessary because we use a database query to get the previous sequence,
+ and we need that query to always be executed on the latest data.
+
+ :param field_name: the field that contains the sequence.
+ """
+ self.ensure_one()
+ last_sequence = self._get_last_sequence()
+ new = not last_sequence
+ if new:
+ last_sequence = self._get_last_sequence(relaxed=True) or self._get_starting_sequence()
+
+ format, format_values = self._get_sequence_format_param(last_sequence)
+ if new:
+ format_values['seq'] = 0
+ format_values['year'] = self[self._sequence_date_field].year % (10 ** format_values['year_length'])
+ format_values['month'] = self[self._sequence_date_field].month
+ format_values['seq'] = format_values['seq'] + 1
+
+ self[self._sequence_field] = format.format(**format_values)
+ self._compute_split_sequence()