diff options
| author | Mahendra barad <mba@openerp.com> | 2015-06-01 16:28:18 +0530 |
|---|---|---|
| committer | Josse Colpaert <jco@odoo.com> | 2015-06-23 14:00:43 +0200 |
| commit | 38dc782eaa2f86dc6aabe321880c7dd82ba3e59d (patch) | |
| tree | e37d5920d1e21a0e7987b2ce0c2597f2671f925b /addons/account/models | |
| parent | 75d7bbb46e100b7ffda091a11183d4ed14d3bbed (diff) | |
[RESTRUCTURE]Account : Files Restructure according to new api guideliens
Diffstat (limited to 'addons/account/models')
| -rw-r--r-- | addons/account/models/__init__.py | 15 | ||||
| -rw-r--r-- | addons/account/models/account.py | 648 | ||||
| -rw-r--r-- | addons/account/models/account_analytic_line.py | 159 | ||||
| -rw-r--r-- | addons/account/models/account_bank.py | 44 | ||||
| -rw-r--r-- | addons/account/models/account_bank_statement.py | 846 | ||||
| -rw-r--r-- | addons/account/models/account_invoice.py | 1278 | ||||
| -rw-r--r-- | addons/account/models/account_journal_dashboard.py | 351 | ||||
| -rw-r--r-- | addons/account/models/account_move.py | 1195 | ||||
| -rw-r--r-- | addons/account/models/account_payment.py | 501 | ||||
| -rw-r--r-- | addons/account/models/chart_template.py | 802 | ||||
| -rw-r--r-- | addons/account/models/company.py | 74 | ||||
| -rw-r--r-- | addons/account/models/partner.py | 377 | ||||
| -rw-r--r-- | addons/account/models/product.py | 66 | ||||
| -rw-r--r-- | addons/account/models/res_config.py | 237 |
14 files changed, 6593 insertions, 0 deletions
diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py new file mode 100644 index 00000000000..a31840bb76e --- /dev/null +++ b/addons/account/models/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +import partner +import account +import account_payment +import account_invoice +import account_bank_statement +import account_bank +import account_move +import chart_template +import account_analytic_line +import account_journal_dashboard +import product +import company +import res_config diff --git a/addons/account/models/account.py b/addons/account/models/account.py new file mode 100644 index 00000000000..333f4fe0f86 --- /dev/null +++ b/addons/account/models/account.py @@ -0,0 +1,648 @@ +# -*- coding: utf-8 -*- + +import time +import math + +from openerp.osv import expression +from openerp.tools.float_utils import float_round as round +from openerp.tools.safe_eval import safe_eval as eval +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT +from openerp.exceptions import UserError +from openerp import api, fields, models, _ + + +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 supplier/customer accounts.") + note = fields.Text(string='Description') + + +class AccountAccountTag(models.Model): + _name = 'account.account.tag' + _description = 'Account Tag' + + name = fields.Char(translate=True, required=True) + applicability = fields.Selection([('accounts', 'Accounts'), ('taxes', 'Taxes')], required=True, default='accounts') + +#---------------------------------------------------------- +# Accounts +#---------------------------------------------------------- + + +class AccountAccount(models.Model): + _name = "account.account" + _description = "Account" + _order = "code" + + #@api.multi + #def _compute_has_unreconciled_entries(self): + # print "ici dedans" + # account_ids = self.ids + # for account in self: + # # Avoid useless work if has_unreconciled_entries is not relevant for this account + # if account.deprecated or not account.reconcile: + # account.has_unreconciled_entries = False + # account_ids = account_ids - account + # if account_ids: + # res = dict.fromkeys([x.id for x in account_ids], False) + # self.env.cr.execute( + # """ SELECT s.account_id FROM( + # SELECT + # a.id as account_id, a.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) + # WHERE + # a.id in %s + # AND EXISTS ( + # SELECT 1 + # FROM account_move_line l + # WHERE l.account_id = a.id + # AND l.amount_residual > 0 + # ) + # AND EXISTS ( + # SELECT 1 + # FROM account_move_line l + # WHERE l.account_id = a.id + # AND l.amount_residual < 0 + # ) + # GROUP BY a.id, a.last_time_entries_checked + # ) as s + # WHERE (last_time_entries_checked IS NULL OR max_date > last_time_entries_checked) + # """ % (account_ids,)) + # res.update(self.env.cr.dictfetchall()) + # for account in self.browse(res.keys()): + # if res[account.id]: + # account.has_unreconciled_entries = True + + @api.multi + @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 ValueError(_('You cannot have a receivable/payable account that is not reconciliable. (account code: %s)') % account.code) + + name = fields.Char(required=True, index=True) + currency_id = fields.Many2one('res.currency', string='Secondary Currency', + help="Forces all moves for this account to have this secondary currency.") + code = fields.Char(size=64, required=True, index=True) + deprecated = fields.Boolean(index=True, default=False) + user_type_id = fields.Many2one('account.account.type', string='Type', required=True, oldname="user_type", + 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', store=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.") + 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 on this account. It is set either if there\'s not at least '\ + 'an unreconciled debit and an unreconciled credit Or if you click the "Done" button.') + reconcile = fields.Boolean(string='Allow Invoices & Payments Matching', 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') + note = fields.Text('Internal Notes') + company_id = fields.Many2one('res.company', string='Company', required=True, + default=lambda self: self.env['res.company']._company_default_get('account.account')) + tag_ids = fields.Many2many('account.account.tag', 'account_account_account_tag', string='Account tag', help="Optional tags you may want to assign for custom reporting") + + _sql_constraints = [ + ('code_company_uniq', 'unique (code,company_id)', 'The code of the account must be unique per company !') + ] + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + args = args or [] + domain = [] + if name: + domain = ['|', ('code', '=ilike', name + '%'), ('name', operator, name)] + if operator in expression.NEGATIVE_TERM_OPERATORS: + domain = ['&'] + domain + accounts = self.search(domain + args, limit=limit) + return accounts.name_get() + + @api.onchange('internal_type') + def onchange_internal_type(self): + if self.internal_type in ('receivable', 'payable'): + self.reconcile = True + + @api.multi + @api.depends('name', 'code') + def name_get(self): + result = [] + for account in self: + name = account.code + ' ' + account.name + result.append((account.id, name)) + return result + + @api.one + def copy(self, default=None): + default = dict(default or {}) + default.update(code=_("%s (copy)") % (self.code or '')) + return super(AccountAccount, self).copy(default) + + @api.multi + def write(self, vals): + # Dont 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.')) + return super(AccountAccount, self).write(vals) + + @api.multi + def unlink(self): + if self.env['account.move.line'].search([('account_id', 'in', self.ids)], limit=1): + raise UserError(_('You cannot do that 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'].search([('value_reference', 'in', values)], limit=1) + if partner_prop_acc: + raise UserError(_('You cannot remove/deactivate an account which is set on a customer or supplier.')) + return super(AccountAccount, self).unlink() + + @api.multi + def mark_as_reconciled(self): + return self.write({'last_time_entries_checked': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}) + + +class AccountJournal(models.Model): + _name = "account.journal" + _description = "Journal" + _order = 'sequence, type, code' + + def _default_inbound_payment_methods(self): + return [(4, self.env.ref('account.account_payment_method_manual_in').id)] + + def _default_outbound_payment_methods(self): + return [(4, self.env.ref('account.account_payment_method_manual_out').id)] + + name = fields.Char(string='Journal Name', required=True) + code = fields.Char(string='Short Name', size=5, required=True, help="The code will be displayed on reports.") + type = fields.Selection([ + ('sale', 'Sale'), + ('purchase', 'Purchase'), + ('cash', 'Cash'), + ('bank', 'Bank'), + ('general', 'Miscellaneous'), + ], required=True, + help="Select 'Sale' for customer invoices journals."\ + " Select 'Purchase' for supplier bills journals."\ + " Select 'Cash' or 'Bank' for journals that are used in customer or supplier payments."\ + " Select 'General' for miscellaneous operations journals."\ + " Select 'Opening/Closing Situation' for entries generated for new fiscal years.") + type_control_ids = fields.Many2many('account.account.type', 'account_journal_type_rel', 'journal_id', 'type_id', string='Account Types Allowed (empty for no control)') + account_control_ids = fields.Many2many('account.account', 'account_account_type_rel', 'journal_id', 'account_id', string='Accounts Allowed (empty for no control)', + domain=[('deprecated', '=', False)]) + default_credit_account_id = fields.Many2one('account.account', string='Default Credit Account', + domain=[('deprecated', '=', False)], help="It acts as a default account for credit amount") + default_debit_account_id = fields.Many2one('account.account', string='Default Debit Account', + domain=[('deprecated', '=', False)], help="It acts as a default account for debit amount") + update_posted = fields.Boolean(string='Allow Cancelling Entries', + help="Check this box if you want to allow the cancellation the entries related to this journal or of the invoice related to this journal") + group_invoice_lines = fields.Boolean(string='Group Invoice Lines', + help="If this box is checked, the system will try to group the accounting lines when generating them from invoices.") + sequence_id = fields.Many2one('ir.sequence', string='Entry Sequence', + help="This field contains the information related to the numbering of the journal entries of this journal.", required=True, copy=False) + refund_sequence_id = fields.Many2one('ir.sequence', string='Refund Entry Sequence', + help="This field contains the information related to the numbering of the refund entries of this journal.", copy=False) + sequence = fields.Integer(help='Used to order Journals in the dashboard view') + + #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", oldname='currency') + company_id = fields.Many2one('res.company', string='Company', required=True, index=1, default=lambda self: self.env.user.company_id, + help="Company related to this journal") + + analytic_journal_id = fields.Many2one('account.analytic.journal', string='Analytic Journal', help="Journal for analytic entries") + refund_sequence = fields.Boolean(string='Dedicated Refund Sequence', help="Check this box if you don't want to share the same sequence for invoices and refunds made from this journal") + + inbound_payment_method_ids = fields.Many2many('account.payment.method', 'account_journal_inbound_payment_method_rel', 'journal_id', 'inbound_payment_method', + domain=[('payment_type', '=', 'inbound')], string='Inbound Payment Methods', default=lambda self: self._default_inbound_payment_methods(), + help="Means of payment for collecting money. Odoo modules offer various payments handling facilities, " + "but you can always use the 'Manual' payment method in order to manage payments outside of the software.") + outbound_payment_method_ids = fields.Many2many('account.payment.method', 'account_journal_outbound_payment_method_rel', 'journal_id', 'outbound_payment_method', + domain=[('payment_type', '=', 'outbound')], string='Outbound Payment Methods', default=lambda self: self._default_outbound_payment_methods(), + help="Means of payment for sending money. Odoo modules offer various payments handling facilities, " + "but you can always use the 'Manual' payment method in order to manage payments outside of the software.") + 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('account.account', string='Profit Account', domain=[('deprecated', '=', False)], help="Used to register a profit when the ending balance of a cash register differs from what the system computes") + loss_account_id = fields.Many2one('account.account', string='Loss Account', domain=[('deprecated', '=', False)], help="Used to register a loss when the ending balance of a cash register differs from what the system computes") + + _sql_constraints = [ + ('code_company_uniq', 'unique (code, name, company_id)', 'The code and name of the journal must be unique per company !'), + ] + + @api.one + @api.constrains('currency_id', 'default_credit_account_id', 'default_debit_account_id') + def _check_currency(self): + if self.currency_id: + if self.default_credit_account_id and not self.default_credit_account_id.currency_id.id == self.currency_id.id: + raise UserError(_('Configuration error!\nThe currency of the journal should be the same than the default credit account.')) + if self.default_debit_account_id and not self.default_debit_account_id.currency_id.id == self.currency_id.id: + raise UserError(_('Configuration error!\nThe currency of the journal should be the same than the default debit account.')) + + @api.onchange('default_debit_account_id') + def onchange_debit_account_id(self): + if not self.default_credit_account_id: + self.default_credit_account_id = self.default_debit_account_id + + @api.onchange('default_credit_account_id') + def onchange_credit_account_id(self): + if not self.default_debit_account_id: + self.default_debit_account_id = self.default_credit_account_id + + @api.one + 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) + + @api.multi + def write(self, vals): + for journal in self: + if ('company_id' in vals and journal.company_id.id != vals['company_id']): + if self.env['account.move'].search([('journal_id', 'in', self.ids)], limit=1): + raise UserError(_('This journal already contains items, therefore you cannot modify its company.')) + if ('code' in vals and journal.code != vals['code']): + if self.env['account.move'].search([('journal_id', 'in', self.ids)], limit=1): + raise UserError(_('This journal already contains items, therefore you cannot modify its short name.')) + new_prefix = self._get_sequence_prefix(vals['code'], refund=False) + journal.sequence_id.write({'prefix': new_prefix}) + if journal.refund_sequence_id: + new_prefix = self._get_sequence_prefix(vals['code'], refund=True) + journal.refund_sequence_id.write({'prefix': new_prefix}) + return super(AccountJournal, self).write(vals) + + @api.model + def _get_sequence_prefix(self, code, refund=False): + prefix = code.upper() + if refund: + prefix = 'R' + prefix + return prefix + '/%(year)s/' + + @api.model + def _create_sequence(self, vals, refund=False): + """ Create new no_gap entry sequence for every new Journal""" + prefix = self._get_sequence_prefix(vals['code'], refund) + if refund: + prefix = 'R' + prefix + seq = { + 'name': vals['name'], + 'implementation': 'no_gap', + 'prefix': prefix, + 'padding': 4, + 'number_increment': 1, + 'use_date_range': True, + } + if 'company_id' in vals: + seq['company_id'] = vals['company_id'] + return self.env['ir.sequence'].create(seq) + + @api.model + def _prepare_bank_account(self, name, company, currency_id): + ''' + This function prepares the value to use for the creation of the default debit and credit accounts of a + bank journal (created through the wizard of generating COA from templates for example). + + :param name: name of the bank account + :param company: company for which the wizard is running + :param currency_id: ID of the currency in wich is the bank account + :return: mapping of field names and values + :rtype: dict + ''' + + # Seek the next available number for the account code + code_digits = company.accounts_code_digits or 0 + bank_account_code_char = company.bank_account_code_char or '' + for num in xrange(1, 100): + new_code = str(bank_account_code_char.ljust(code_digits - 1, '0')) + str(num) + rec = self.env['account.account'].search([('code', '=', new_code), ('company_id', '=', company.id)], limit=1) + if not rec: + break + else: + raise UserError(_('Cannot generate an unused account code.')) + + liquidity_type = self.env.ref('account.data_account_type_liquidity') + return { + 'name': name, + 'currency_id': currency_id or False, + 'code': new_code, + 'user_type_id': liquidity_type and liquidity_type.id or False, + 'company_id': company.id, + } + + @api.model + def _prepare_bank_journal(self, company, line): + ''' + This function prepares the value to use for the creation of a bank journal created through the wizard of + generating COA from templates. + + :param company: company for which the wizard is running + :param line: dictionary containing the values encoded by the user related to his bank account with keys + - acc_name (char): name of the bank account + - account_type (char): kind of liquidity journal to create. Either 'bank' or 'cash' + - currency_id (int): id of the currency related to this account if its different than the company currency (False otherwise) + :return: mapping of field names and values + :rtype: dict + ''' + # we need to loop to find next number for journal code + for num in xrange(1, 100): + # journal_code has a maximal size of 5, hence we can enforce the boundary num < 100 + journal_code = line.get('account_type', 'bank') == 'cash' and 'CSH' or 'BNK' + journal_code += str(num) + journal = self.env['account.journal'].search([('code', '=', journal_code), ('company_id', '=', company.id)], limit=1) + if not journal: + break + else: + raise UserError(_('Cannot generate an unused journal code.')) + + return { + 'name': line['acc_name'], + 'code': journal_code, + 'type': line.get('account_type', 'bank'), + 'company_id': company.id, + 'analytic_journal_id': False, + 'currency_id': line.get('currency_id', False), + 'show_on_dashboard': True, + } + + @api.model + def create(self, vals): + if vals.get('type') in ('bank', 'cash'): + default_account = vals.get('default_debit_account_id') or vals.get('default_credit_account_id') + if not default_account: + company = self.env['res.company'].browse(vals['company_id']) + account_vals = self._prepare_bank_account(vals.get('name'), company, vals.get('currency_id')) + default_account = self.env['account.account'].create(account_vals) + vals['default_debit_account_id'] = default_account.id + vals['default_credit_account_id'] = default_account.id + # We just need to create the relevant sequences according to the chosen options + if not vals.get('sequence_id'): + vals.update({'sequence_id': self.sudo()._create_sequence(vals).id}) + if vals.get('refund_sequence') and not vals.get('refund_sequence_id'): + vals.update({'refund_sequence_id': self.sudo()._create_sequence(vals, refund=True).id}) + return super(AccountJournal, self).create(vals) + + @api.multi + @api.depends('name', 'currency_id', 'company_id', 'company_id.currency_id') + def name_get(self): + res = [] + for journal in self: + currency = journal.currency_id or journal.company_id.currency_id + name = "%s (%s)" % (journal.name, currency.name) + res += [(journal.id, name)] + return res + + @api.multi + @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(self.inbound_payment_method_ids)) + journal.at_least_one_outbound = bool(len(self.outbound_payment_method_ids)) + + +#---------------------------------------------------------- +# Tax +#---------------------------------------------------------- + + +class AccountTax(models.Model): + _name = 'account.tax' + _description = 'Tax' + _order = 'sequence' + + name = fields.Char(string='Tax Name', required=True, translate=True) + type_tax_use = fields.Selection([('sale', 'Sales'), ('purchase', 'Purchases'), ('none', 'None')], string='Tax Scope', required=True, default="sale", + help="Determines where the tax is selectable. Note : 'None' means a tax can't be used by itself, however it can still be used in a group.") + 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.") + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) + children_tax_ids = fields.Many2many('account.tax', 'account_tax_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, 3)) + account_id = fields.Many2one('account.account', domain=[('deprecated', '=', False)], string='Tax Account', ondelete='restrict', + help="Account that will be set on invoice tax lines for invoices. Leave empty to use the expense account.", oldname='account_collected_id') + refund_account_id = fields.Many2one('account.account', domain=[('deprecated', '=', False)], string='Tax Account on Refunds', ondelete='restrict', + help="Account that will be set on invoice tax lines for refunds. Leave empty to use the expense account.", oldname='account_paid_id') + description = fields.Char(string='Text 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)") + tag_ids = fields.Many2many('account.account.tag', 'account_tax_account_tag', string='Account tag', help="Optional tags you may want to assign for custom reporting") + + _sql_constraints = [ + ('name_company_uniq', 'unique(name, company_id)', 'Tax names must be unique !'), + ] + + @api.one + @api.constrains('children_tax_ids', 'type_tax_use') + def _check_children_scope(self): + if not all(child.type_tax_use in ('none', self.type_tax_use) for child in self.children_tax_ids): + raise UserError(_('The application scope of taxes in a group must be either the same as the group or "None".')) + + @api.one + def copy(self, default=None): + default = dict(default or {}, name=_("%s (Copy)") % self.name) + return super(AccountTax, self).copy(default=default) + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=80): + """ Returns a list of tupples containing id, name, as internally it is called {def name_get} + result format: {[(id, name), (id, name), ...]} + """ + args = args or [] + if operator in expression.NEGATIVE_TERM_OPERATORS: + domain = [('description', operator, name), ('name', operator, name)] + else: + domain = ['|', ('description', operator, name), ('name', operator, name)] + taxes = self.search(expression.AND([domain, args]), limit=limit) + return taxes.name_get() + + @api.model + def search(self, args, offset=0, limit=None, order=None, count=False): + context = self._context or {} + + if context.get('type'): + if context.get('type') in ('out_invoice', 'out_refund'): + args += [('type_tax_use', '=', 'sale')] + elif context.get('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) + + @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('account_id') + def onchange_account_id(self): + self.refund_account_id = self.account_id + + @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': + return math.copysign(self.amount, base_amount) * quantity + if (self.amount_type == 'percent' and not self.price_include) or (self.amount_type == 'division' and self.price_include): + return base_amount * self.amount / 100 + if self.amount_type == 'percent' and self.price_include: + return base_amount - (base_amount / (1 + self.amount / 100)) + if self.amount_type == 'division' and not self.price_include: + return base_amount / (1 - self.amount / 100) - base_amount + + @api.v8 + def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None): + """ Returns all information required to apply taxes (in self + their children in case of a tax goup). + 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] + + RETURN: { + 'total_excluded': 0.0, # Total without taxes + 'total_included': 0.0, # Total with taxes + '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 currency: + currency = self[0].company_id.currency_id + if len(self) == 0: + company_id = self.env.user.company_id + else: + company_id = self[0].company_id + taxes = [] + # 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.decimal_places + if company_id.tax_calculation_rounding_method == 'round_globally': + prec += 5 + total_excluded = total_included = base = round(price_unit * quantity, prec) + + for tax in self: + if tax.amount_type == 'group': + ret = tax.children_tax_ids.compute_all(price_unit, currency, quantity, product, partner) + total_excluded = ret['total_excluded'] + base = ret['total_excluded'] + total_included = ret['total_included'] + tax_amount = total_included - total_excluded + taxes += ret['taxes'] + continue + + tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + tax_amount = currency.round(tax_amount) + + if tax_amount: + if tax.price_include: + total_excluded -= tax_amount + base -= tax_amount + else: + total_included += tax_amount + + if tax.include_base_amount: + base += tax_amount + + taxes.append({ + 'id': tax.id, + 'name': tax.name, + 'amount': tax_amount, + 'sequence': tax.sequence, + 'account_id': tax.account_id.id, + 'refund_account_id': tax.refund_account_id.id, + 'analytic': tax.analytic, + }) + + return { + 'taxes': taxes, + 'total_excluded': currency.round(total_excluded), + 'total_included': currency.round(total_included), + } + + @api.v7 + def compute_all(self, cr, uid, ids, price_unit, currency_id=None, quantity=1.0, product_id=None, partner_id=None, context=None): + currency = currency_id and self.pool.get('res.currency').browse(cr, uid, currency_id, context=context) or None + product = product_id and self.pool.get('product.product').browse(cr, uid, product_id, context=context) or None + partner = partner_id and self.pool.get('res.partner').browse(cr, uid, partner_id, context=context) or None + ids = isinstance(ids, (int, long)) and [ids] or ids + recs = self.browse(cr, uid, ids, context=context) + return recs.compute_all(price_unit, currency, quantity, product, partner) + + +class AccountOperationTemplate(models.Model): + _name = "account.operation.template" + _description = "Preset to create journal entries during a invoices and payments matching" + + name = fields.Char(string='Button Label', required=True) + sequence = fields.Integer(required=True, default=10) + has_second_line = fields.Boolean(string='Second line', default=False) + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) + + account_id = fields.Many2one('account.account', string='Account', ondelete='cascade', domain=[('deprecated', '=', False)]) + journal_id = fields.Many2one('account.journal', string='Journal', ondelete='cascade', help="This field is ignored in a bank statement reconciliation.") + label = fields.Char(string='Journal Item Label') + amount_type = fields.Selection([ + ('fixed', 'Fixed'), + ('percentage', 'Percentage of amount') + ], required=True, default='percentage') + amount = fields.Float(digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.") + tax_id = fields.Many2one('account.tax', string='Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')]) + analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', ondelete='set null', domain=[('state', 'not in', ('close', 'cancelled'))]) + + second_account_id = fields.Many2one('account.account', string='Account', ondelete='cascade', domain=[('deprecated', '=', False)]) + second_journal_id = fields.Many2one('account.journal', string='Journal', ondelete='cascade', help="This field is ignored in a bank statement reconciliation.") + second_label = fields.Char(string='Journal Item Label') + second_amount_type = fields.Selection([ + ('fixed', 'Fixed'), + ('percentage', 'Percentage of amount') + ], string='Amount type', required=True, default='percentage') + second_amount = fields.Float(string='Amount', digits=0, required=True, default=100.0, help="Fixed amount will count as a debit if it is negative, as a credit if it is positive.") + second_tax_id = fields.Many2one('account.tax', string='Tax', ondelete='restrict', domain=[('type_tax_use', '=', 'purchase')]) + second_analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', ondelete='set null', domain=[('state', 'not in', ('close', 'cancelled'))]) + + @api.onchange('name') + def onchange_name(self): + self.label = self.name diff --git a/addons/account/models/account_analytic_line.py b/addons/account/models/account_analytic_line.py new file mode 100644 index 00000000000..5b8faf5a428 --- /dev/null +++ b/addons/account/models/account_analytic_line.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- + +from openerp import api, fields, models, _ +from openerp.exceptions import UserError + + +class AccountAnalyticLine(models.Model): + _inherit = 'account.analytic.line' + _description = 'Analytic Line' + _order = 'date desc' + + product_uom_id = fields.Many2one('product.uom', string='Unit of Measure') + product_id = fields.Many2one('product.product', string='Product') + general_account_id = fields.Many2one('account.account', string='Financial Account', required=True, ondelete='restrict', domain=[('deprecated', '=', False)]) + move_id = fields.Many2one('account.move.line', string='Move Line', ondelete='cascade', index=True) + journal_id = fields.Many2one('account.analytic.journal', string='Analytic Journal', required=True, ondelete='restrict', index=True) + code = fields.Char(size=8) + ref = fields.Char(string='Ref.') + currency_id = fields.Many2one('res.currency', related='move_id.currency_id', string='Account Currency', store=True, help="The related account currency if not equal to the company one.", readonly=True) + amount_currency = fields.Monetary(related='move_id.amount_currency', store=True, help="The amount expressed in the related account currency if not equal to the company one.", readonly=True) + partner_id = fields.Many2one('res.partner', related='account_id.partner_id', string='Partner', store=True) + + # Compute the cost based on the price type define into company + # property_valuation_price_type property + @api.v7 + def on_change_unit_amount(self, cr, uid, id, prod_id, quantity, company_id, + unit=False, journal_id=False, context=None): + if context is None: + context = {} + if not journal_id: + j_ids = self.pool.get('account.analytic.journal').search(cr, uid, [('type', '=', 'purchase')]) + journal_id = j_ids and j_ids[0] or False + if not journal_id or not prod_id: + return {} + product_obj = self.pool.get('product.product') + analytic_journal_obj = self.pool.get('account.analytic.journal') + product_price_type_obj = self.pool.get('product.price.type') + product_uom_obj = self.pool.get('product.uom') + j_id = analytic_journal_obj.browse(cr, uid, journal_id, context=context) + prod = product_obj.browse(cr, uid, prod_id, context=context) + result = 0.0 + if prod_id: + unit_obj = False + if unit: + unit_obj = product_uom_obj.browse(cr, uid, unit, context=context) + if not unit_obj or prod.uom_id.category_id.id != unit_obj.category_id.id: + unit = prod.uom_id.id + if j_id.type == 'purchase': + if not unit_obj or prod.uom_po_id.category_id.id != unit_obj.category_id.id: + unit = prod.uom_po_id.id + if j_id.type <> 'sale': + a = prod.property_account_expense_id.id + if not a: + a = prod.categ_id.property_account_expense_categ_id.id + if not a: + raise UserError(_('There is no expense account defined ' \ + 'for this product: "%s" (id:%d).') % \ + (prod.name, prod.id,)) + else: + a = prod.property_account_income_id.id + if not a: + a = prod.categ_id.property_account_income_categ_id.id + if not a: + raise UserError(_('There is no income account defined ' \ + 'for this product: "%s" (id:%d).') % \ + (prod.name, prod_id,)) + + flag = False + # Compute based on pricetype + product_price_type_ids = product_price_type_obj.search(cr, uid, [('field', '=', 'standard_price')], context=context) + pricetype = product_price_type_obj.browse(cr, uid, product_price_type_ids, context=context)[0] + if journal_id: + journal = analytic_journal_obj.browse(cr, uid, journal_id, context=context) + if journal.type == 'sale': + product_price_type_ids = product_price_type_obj.search(cr, uid, [('field', '=', 'list_price')], context=context) + if product_price_type_ids: + pricetype = product_price_type_obj.browse(cr, uid, product_price_type_ids, context=context)[0] + # Take the company currency as the reference one + if pricetype.field == 'list_price': + flag = True + ctx = context.copy() + if unit: + # price_get() will respect a 'uom' in its context, in order + # to return a default price for those units + ctx['uom'] = unit + amount_unit = prod.price_get(pricetype.field, context=ctx)[prod.id] + amount = amount_unit * quantity or 0.0 + cur_record = self.browse(cr, uid, id, context=context) + currency = cur_record.exists() and cur_record.currency_id or prod.company_id.currency_id + result = round(amount, currency.decimal_places) + if not flag: + result *= -1 + return {'value': { + 'amount': result, + 'general_account_id': a, + 'product_uom_id': unit + } + } + + @api.v8 + @api.onchange('product_id', 'product_uom_id') + def on_change_unit_amount(self): + product_price_type_obj = self.env['product.price.type'] + + journal_id = self.journal_id + if not journal_id: + journal_id = self.env['account.analytic.journal'].search([('type', '=', 'purchase')], limit=1) + if not journal_id or not self.product_id: + return {} + + result = 0.0 + if self.product_id: + unit = self.product_uom_id.id + if not self.product_uom_id or self.product_id.uom_id.category_id.id != self.product_uom_id.category_id.id: + unit = self.product_id.uom_id.id + if journal_id.type == 'purchase': + if not self.product_uom_id or self.product_id.uom_po_id.category_id.id != self.product_uom_id.category_id.id: + unit = self.product_id.uom_po_id.id + if journal_id.type != 'sale': + account = self.product_id.property_account_expense_id.id or self.product_id.categ_id.property_account_expense_categ_id.id + if not account: + raise UserError(_('There is no expense account defined ' \ + 'for this product: "%s" (id:%d).') % \ + (self.product_id.name, self.product_id.id,)) + else: + account = self.product_id.property_account_income_id.id or self.product_id.categ_id.property_account_income_categ_id.id + if not account: + raise UserError(_('There is no income account defined ' \ + 'for this product: "%s" (id:%d).') % \ + (self.product_id.name, self.product_id.id,)) + + # Compute based on pricetype + if journal_id.type == 'sale': + pricetype = product_price_type_obj.search([('field', '=', 'list_price')], limit=1) + else: + pricetype = product_price_type_obj.search([('field', '=', 'standard_price')], limit=1) + + ctx = dict(self._context or {}) + if unit: + # price_get() will respect a 'uom' in its context, in order + # to return a default price for those units + ctx['uom'] = unit + amount_unit = self.product_id.with_context(ctx).price_get(pricetype.field)[self.product_id.id] + amount = amount_unit * self.unit_amount or 0.0 + result = round(amount, self.currency_id.decimal_places) + if pricetype.field != 'list_price': + result *= -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): + context = (self._context or {}) + header = False + if context.get('account_id', False): + analytic_account = self.env['account.analytic.account'].search([('id', '=', context['account_id'])], limit=1) + header = _('Entries: ') + (analytic_account.name or '') + return header diff --git a/addons/account/models/account_bank.py b/addons/account/models/account_bank.py new file mode 100644 index 00000000000..96b69d81bab --- /dev/null +++ b/addons/account/models/account_bank.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +from openerp import api, fields, models + + +class Bank(models.Model): + _inherit = "res.partner.bank" + + journal_id = fields.Many2one('account.journal', string='Account Journal', + help="This journal will be created automatically for this bank account when you save the record") + + @api.model + def create(self, data): + result = super(Bank, self).create(data) + result.post_write() + return result + + @api.multi + def write(self, data): + result = super(Bank, self).write(data) + self.post_write() + return result + + @api.model + def _prepare_name(self, bank): + "Return the name to use when creating a bank journal" + name = bank.bank_name + ' ' if bank.bank_name else '' + name += bank.acc_number + return name + + @api.multi + def post_write(self): + JournalObj = self.env['account.journal'] + for bank in self: + # Create a journal for the bank account if it belongs to the company. + if bank.company_id and not bank.journal_id: + journal_vals = JournalObj._prepare_bank_journal(bank.company_id, {'acc_name': self._prepare_name(bank), 'currency_id': bank.currency_id.id, 'account_type': 'bank'}) + journal = JournalObj.create(journal_vals) + missing_vals = {'journal_id': journal.id} + if not bank.partner_id: + missing_vals['partner_id'] = bank.company_id.partner_id.id + if not bank.owner_name: + missing_vals['owner_name'] = bank.company_id.partner_id.name + bank.write(missing_vals) diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py new file mode 100644 index 00000000000..3ca812c7590 --- /dev/null +++ b/addons/account/models/account_bank_statement.py @@ -0,0 +1,846 @@ +# -*- coding: utf-8 -*- + +from openerp import api, fields, models, _ +from openerp.osv import expression +from openerp.tools import float_is_zero +from openerp.tools import float_compare, float_round +from openerp.tools.misc import formatLang +from openerp.exceptions import UserError, ValidationError + +import time +import math + +class AccountCashboxLine(models.Model): + """ Cash Box Details """ + _name = 'account.cashbox.line' + _description = 'CashBox Line' + _rec_name = 'coin_value' + _order = 'coin_value' + + @api.one + @api.depends('coin_value', 'number') + def _sub_total(self): + """ Calculates Sub total""" + self.subtotal = self.coin_value * self.number + + coin_value = fields.Float(string='Coin/Bill Value', required=True, digits=0) + number = fields.Integer(string='Number of 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') + + +class AccountBankStmtCashWizard(models.Model): + """ + Account Bank Statement popup that allows entering cash details. + """ + _name = 'account.bank.statement.cashbox' + _description = 'Account Bank Statement Cashbox Details' + + cashbox_lines_ids = fields.One2many('account.cashbox.line', 'cashbox_id', string='Cashbox Lines') + + @api.multi + def validate(self): + bnk_stmt_id = self.env.context.get('active_id', False) + bnk_stmt = self.env['account.bank.statement'].browse(bnk_stmt_id) + total = 0.0 + for lines in self.cashbox_lines_ids: + total += lines.subtotal + if self.env.context.get('balance', False) == 'start': + #starting balance + bnk_stmt.write({'balance_start': total, 'cashbox_start_id': self.id}) + else: + #closing balance + bnk_stmt.write({'balance_end_real': total, 'cashbox_end_id': self.id}) + return {'type': 'ir.actions.act_window_close'} + + +class AccountBankStmtCloseCheck(models.TransientModel): + """ + Account Bank Statement wizard that check that closing balance is correct. + """ + _name = 'account.bank.statement.closebalance' + _description = 'Account Bank Statement closing balance' + + @api.multi + 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_confirm_bank() + return {'type': 'ir.actions.act_window_close'} + + +class AccountBankStatement(models.Model): + + @api.one + @api.depends('line_ids', 'balance_start', 'line_ids.amount', 'balance_end_real') + def _end_balance(self): + self.total_entry_encoding = sum([line.amount for line in self.line_ids]) + self.balance_end = self.balance_start + self.total_entry_encoding + self.difference = self.balance_end_real - self.balance_end + + @api.one + @api.depends('journal_id') + def _compute_currency(self): + self.currency_id = self.journal_id.currency_id or self.env.user.company_id.currency_id + + @api.one + @api.depends('line_ids.journal_entry_ids') + def _check_lines_reconciled(self): + self.all_lines_reconciled = all([line.journal_entry_ids.ids or line.account_id.id for line in self.line_ids]) + + @api.model + def _default_journal(self): + journal_type = self.env.context.get('journal_type', False) + company_id = self.env['res.company']._company_default_get('account.bank.statement') + if journal_type: + journals = self.env['account.journal'].search([('type', '=', journal_type), ('company_id', '=', company_id)]) + if journals: + return journals[0] + return False + + _name = "account.bank.statement" + _description = "Bank Statement" + _order = "date desc, id desc" + _inherit = ['mail.thread'] + + name = fields.Char(string='Reference', states={'open': [('readonly', False)]}, copy=False, readonly=True) + date = fields.Date(required=True, states={'confirm': [('readonly', True)]}, select=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)]}) + balance_end_real = fields.Monetary('Ending Balance', states={'confirm': [('readonly', True)]}) + state = fields.Selection([('open', 'New'), ('confirm', 'Validated')], string='Status', required=True, readonly=True, copy=False, default='open') + currency_id = fields.Many2one('res.currency', compute='_compute_currency', oldname='currency') + journal_id = fields.Many2one('account.journal', string='Journal', required=True, states={'confirm': [('readonly', True)]}, default=_default_journal) + 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['res.company']._company_default_get('account.bank.statement')) + + 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)]}) + all_lines_reconciled = fields.Boolean(compute='_check_lines_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') + cashbox_end_id = fields.Many2one('account.bank.statement.cashbox') + + + + @api.multi + def _balance_check(self): + for stmt in self: + if not stmt.currency_id.is_zero(stmt.difference): + if stmt.journal_type == 'cash': + if stmt.difference < 0.0: + account = stmt.journal_id.loss_account_id + name = _('Loss') + else: + # statement.difference > 0.0 + account = stmt.journal_id.profit_account_id + name = _('Profit') + if not account: + raise UserError(_('There is no account defined on the journal %s for %s involved in a cash difference.') % (stmt.journal_id.name, name)) + + values = { + 'statement_id': stmt.id, + 'account_id': account.id, + 'amount': stmt.difference, + 'name': _("Cash difference observed during the counting (%s)") % name, + } + self.env['account.bank.statement.line'].create(values) + 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 (%s) is different from the computed one. (%s)') + % (balance_end_real, balance_end)) + return True + + @api.model + def create(self, vals): + if not vals.get('name'): + journal_id = vals.get('journal_id', self._context.get('default_journal_id', False)) + journal = self.env['account.journal'].browse(journal_id) + vals['name'] = journal.sequence_id.with_context(ir_sequence_date=vals.get('date')).next_by_id() + return super(AccountBankStatement, self).create(vals) + + @api.multi + 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() + return super(AccountBankStatement, self).unlink() + + @api.multi + def open_cashbox_id(self): + context = dict(self.env.context or {}) + if context.get('cashbox_id'): + context['active_id'] = self.id + return { + 'name': _('Cash Control'), + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.bank.statement.cashbox', + 'view_id': self.env.ref('account.view_account_bnk_stmt_cashbox').id, + 'type': 'ir.actions.act_window', + 'res_id': self.env.context.get('cashbox_id'), + 'context': context, + 'target': 'new' + } + + @api.multi + def button_cancel(self): + for statement in self: + if any(line.journal_entry_ids.ids for line in statement.line_ids): + raise UserError(_('A statement cannot be canceled when its lines are reconciled.')) + self.state = 'open' + + @api.multi + def check_confirm_bank(self): + if self.journal_type == 'cash' and not self.currency_id.is_zero(self.difference): + action_rec = self.env['ir.model.data'].xmlid_to_object('account.action_view_account_bnk_stmt_check') + if action_rec: + action = action_rec.read([])[0] + return action + return self.button_confirm_bank() + + @api.multi + def button_confirm_bank(self): + self._balance_check() + statements = self.filtered(lambda r: r.state == 'open') + for statement in statements: + moves = self.env['account.move'] + for st_line in statement.line_ids: + if st_line.account_id and not st_line.journal_entry_ids.ids: + st_line.fast_counterpart_creation() + elif not st_line.journal_entry_ids.ids: + raise UserError(_('All the account entries lines must be processed in order to close the statement.')) + moves = (moves | st_line.journal_entry_ids) + if moves: + moves.post() + statement.message_post(body=_('Statement %s confirmed, journal items were created.') % (statement.name,)) + statements.link_bank_to_partner() + statements.write({'state': 'confirm', 'date_done': time.strftime("%Y-%m-%d %H:%M:%S")}) + + @api.multi + def button_journal_entries(self): + context = dict(self._context or {}) + context['journal_id'] = self.journal_id.id + return { + 'name': _('Journal Items'), + 'view_type': 'form', + 'view_mode': 'tree', + 'res_model': 'account.move.line', + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('statement_id', 'in', self.ids)], + 'context': context, + } + + @api.multi + def button_open(self): + """ Changes statement state to Running.""" + for statement in self: + if not statement.name: + context = {'ir_sequence_date', statement.date} + if statement.journal_id.sequence_id: + st_number = statement.journal_id.sequence_id.with_context(context).next_by_id() + else: + SequenceObj = self.env['ir.sequence'] + st_number = SequenceObj.with_context(context).next_by_code('account.bank.statement') + statement.name = st_number + statement.state = 'open' + + @api.multi + def reconciliation_widget_preprocess(self): + """ Get statement lines of the specified statements or all unreconciled statement lines and try to automatically reconcile them / find them a partner. + Return ids of statement lines left to reconcile and other data for the reconciliation widget. + """ + statements = self + bsl_obj = self.env['account.bank.statement.line'] + + # NB : The field account_id can be used at the statement line creation/import to avoid the reconciliation process on it later on, + # this is why we filter out statements lines where account_id is set + st_lines_filter = [('journal_entry_ids', '=', False), ('account_id', '=', False)] + if statements: + st_lines_filter += [('statement_id', 'in', statements.ids)] + + # Try to automatically reconcile statement lines + automatic_reconciliation_entries = [] + st_lines_left = self.env['account.bank.statement.line'] + for st_line in bsl_obj.search(st_lines_filter): + res = st_line.auto_reconcile() + if not res: + st_lines_left = (st_lines_left | st_line) + else: + automatic_reconciliation_entries.append(res.ids) + + # Try to set statement line's partner + for st_line in st_lines_left: + if st_line.name and not st_line.partner_id: + additional_domain = [('ref', '=', st_line.name)] + match_recs = st_line.get_move_lines_for_reconciliation(limit=1, additional_domain=additional_domain, overlook_partner=True) + if match_recs and match_recs[0].partner_id: + st_line.write({'partner_id': match_recs[0].partner_id.name}) + + # Collect various informations for the reconciliation widget + notifications = [] + num_auto_reconciled = len(automatic_reconciliation_entries) + if num_auto_reconciled > 0: + auto_reconciled_message = num_auto_reconciled > 1 \ + and _("%d transactions were automatically reconciled.") % num_auto_reconciled \ + or _("1 transaction was automatically reconciled.") + notifications += [{ + 'type': 'info', + 'message': auto_reconciled_message, + 'details': { + 'name': _("Automatically reconciled items"), + 'model': 'account.move', + 'ids': automatic_reconciliation_entries + } + }] + + lines = [] + for el in statements: + lines.extend(el.line_ids.ids) + lines = list(set(lines)) + + return { + 'st_lines_ids': st_lines_left.ids, + 'notifications': notifications, + 'statement_name': len(statements) == 1 and statements[0].name or False, + 'num_already_reconciled_lines': statements and bsl_obj.search_count([('journal_entry_ids', '!=', False), ('id', 'in', lines)]) or 0, + } + + @api.multi + def link_bank_to_partner(self): + for statement in self: + for st_line in statement.line_ids: + if st_line.bank_account_id and st_line.partner_id and st_line.bank_account_id.partner_id.id != st_line.partner_id.id: + bank_vals = st_line.bank_account_id.onchange_partner_id(st_line.partner_id.id)['value'] + bank_vals.update({'partner_id': st_line.partner_id.id}) + st_line.bank_account_id.write(bank_vals) + + +class AccountBankStatementLine(models.Model): + _name = "account.bank.statement.line" + _description = "Bank Statement Line" + _order = "statement_id desc, sequence" + _inherit = ['ir.needaction_mixin'] + + name = fields.Char(string='Communication', required=True) + date = fields.Date(required=True, default=lambda self: self._context.get('date', fields.Date.context_today(self))) + amount = fields.Monetary(digits=0, currency_field='journal_currency_id') + journal_currency_id = fields.Many2one('res.currency', related='journal_id.currency_id', + help='Utility field to express amount currency', readonly=True) + partner_id = fields.Many2one('res.partner', string='Partner') + bank_account_id = fields.Many2one('res.partner.bank', string='Bank Account') + account_id = fields.Many2one('account.account', string='Counterpart Account', domain=[('deprecated', '=', False)], + help="This technical field can be used at the statement line creation/import time in order to avoid the reconciliation" + " process on it later on. The statement line will simply create a counterpart on this account") + statement_id = fields.Many2one('account.bank.statement', string='Statement', index=True, required=True, ondelete='cascade') + journal_id = fields.Many2one('account.journal', related='statement_id.journal_id', string='Journal', store=True, readonly=True) + 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).") + ref = fields.Char(string='Reference') + note = fields.Text(string='Notes') + sequence = fields.Integer(index=True, help="Gives the sequence order when displaying a list of bank statement lines.", default=1) + company_id = fields.Many2one('res.company', related='statement_id.company_id', string='Company', store=True, readonly=True) + journal_entry_ids = fields.One2many('account.move', 'statement_line_id', 'Journal Entries', copy=False, readonly=True) + amount_currency = fields.Monetary(help="The amount expressed in an optional other currency if it is a multi-currency entry.") + currency_id = fields.Many2one('res.currency', string='Currency', help="The optional other currency if it is a multi-currency entry.") + + @api.one + @api.constrains('amount') + def _check_amount(self): + # This constraint could possibly underline flaws in bank statement import (eg. inability to + # support hacks such as using dummy transactions to give additional informations) + if self.amount == 0: + raise ValidationError(_('A transaction can\'t have a 0 amount.')) + + @api.one + @api.constrains('amount', 'amount_currency') + def _check_amount_currency(self): + if self.amount_currency != 0 and self.amount == 0: + raise ValidationError(_('If "Amount Currency" is specified, then "Amount" must be as well.')) + + @api.multi + def unlink(self): + for line in self: + if line.journal_entry_ids.ids: + raise UserError(_('In order to delete a bank statement line, you must first cancel it to delete related journal items.')) + return super(AccountBankStatementLine, self).unlink() + + @api.model + def _needaction_domain_get(self): + return [('journal_entry_ids', '=', False), ('account_id', '=', False)] + + @api.multi + def button_cancel_reconciliation(self): + # TOCKECK : might not behave as expected in case of reconciliations (match statement line with already + # registered payment) or partial reconciliations : it will completely remove the existing payment. + move_recs = self.env['account.move'] + for st_line in self: + move_recs = (move_recs | st_line.journal_entry_ids) + if move_recs: + for move in move_recs: + move.line_ids.remove_move_reconcile() + move_recs.write({'statement_line_id': False}) + move_recs.button_cancel() + move_recs.unlink() + + #################################################### + # Reconciliation interface methods + #################################################### + + @api.multi + def get_data_for_reconciliation_widget(self, excluded_ids=None): + """ Returns the data required to display a reconciliation widget, for each statement line in self """ + excluded_ids = excluded_ids or [] + ret = [] + + for st_line in self: + aml_recs = st_line.get_reconciliation_proposition(excluded_ids=excluded_ids) + target_currency = st_line.currency_id or st_line.journal_id.currency_id or st_line.journal_id.company_id.currency_id + rp = aml_recs.prepare_move_lines_for_reconciliation_widget(target_currency=target_currency, target_date=st_line.date) + excluded_ids += [move_line['id'] for move_line in rp] + ret.append({ + 'st_line': st_line.get_statement_line_for_reconciliation_widget(), + 'reconciliation_proposition': rp + }) + + return ret + + def get_statement_line_for_reconciliation_widget(self): + """ Returns the data required by the bank statement reconciliation widget to display a statement line """ + statement_currency = self.journal_id.currency_id or self.journal_id.company_id.currency_id + if self.amount_currency and self.currency_id: + amount = self.amount_currency + amount_currency = self.amount + amount_currency_str = amount_currency > 0 and amount_currency or -amount_currency + amount_currency_str = formatLang(self.env, amount_currency_str, currency_obj=statement_currency) + else: + amount = self.amount + amount_currency_str = "" + amount_str = formatLang(self.env, abs(amount), currency_obj=self.currency_id or statement_currency) + + data = { + 'id': self.id, + 'ref': self.ref, + 'note': self.note or "", + 'name': self.name, + 'date': self.date, + 'amount': amount, + 'amount_str': amount_str, # Amount in the statement line currency + 'currency_id': self.currency_id.id or statement_currency.id, + 'partner_id': self.partner_id.id, + 'journal_id': self.journal_id.id, + 'statement_id': self.statement_id.id, + 'account_code': self.journal_id.default_debit_account_id.code, + 'account_name': self.journal_id.default_debit_account_id.name, + 'partner_name': self.partner_id.name, + 'communication_partner_name': self.partner_name, + 'amount_currency_str': amount_currency_str, # Amount in the statement currency + 'has_no_partner': not self.partner_id.id, + } + if self.partner_id: + if amount > 0: + data['open_balance_account_id'] = self.partner_id.property_account_receivable_id.id + else: + data['open_balance_account_id'] = self.partner_id.property_account_payable_id.id + + return data + + @api.multi + def get_move_lines_for_reconciliation_widget(self, excluded_ids=None, str=False, offset=0, limit=None): + """ Returns move lines for the bank statement reconciliation widget, formatted as a list of dicts + """ + aml_recs = self.get_move_lines_for_reconciliation(excluded_ids=excluded_ids, str=str, offset=offset, limit=limit) + target_currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id + return aml_recs.prepare_move_lines_for_reconciliation_widget(target_currency=target_currency, target_date=self.date) + + #################################################### + # Reconciliation methods + #################################################### + + def get_move_lines_for_reconciliation(self, excluded_ids=None, str=False, offset=0, limit=None, additional_domain=None, overlook_partner=False): + """ Return account.move.line records which can be used for bank statement reconciliation. + + :param excluded_ids: + :param str: + :param offset: + :param limit: + :param additional_domain: + :param overlook_partner: + """ + # Domain to fetch registered payments (use case where you encode the payment before you get the bank statement) + reconciliation_aml_accounts = [self.journal_id.default_credit_account_id.id, self.journal_id.default_debit_account_id.id] + domain_reconciliation = ['&', ('statement_id', '=', False), ('account_id', 'in', reconciliation_aml_accounts)] + + # Domain to fetch unreconciled payables/receivables (use case where you close invoices/refunds by reconciling your bank statements) + domain_matching = [('reconciled', '=', False)] + if self.partner_id.id or overlook_partner: + domain_matching = expression.AND([domain_matching, [('account_id.internal_type', 'in', ['payable', 'receivable'])]]) + else: + # TODO : find out what use case this permits (match a check payment, registered on a journal whose account type is other instead of liquidity) + domain_matching = expression.AND([domain_matching, [('account_id.reconcile', '=', True)]]) + + # Let's add what applies to both + domain = expression.OR([domain_reconciliation, domain_matching]) + if self.partner_id.id and not overlook_partner: + domain = expression.AND([domain, [('partner_id', '=', self.partner_id.id)]]) + + # Domain factorized for all reconciliation use cases + ctx = dict(self._context or {}) + ctx['bank_statement_line'] = self + generic_domain = self.env['account.move.line'].with_context(ctx).domain_move_lines_for_reconciliation(excluded_ids=excluded_ids, str=str) + domain = expression.AND([domain, generic_domain]) + + # Domain from caller + if additional_domain is None: + additional_domain = [] + else: + additional_domain = expression.normalize_domain(additional_domain) + domain = expression.AND([domain, additional_domain]) + + return self.env['account.move.line'].search(domain, offset=offset, limit=limit, order="date_maturity asc, id asc") + + def _get_domain_maker_move_line_amount(self): + """ Returns a function that can create the appropriate domain to search on move.line amount based on statement.line currency/amount """ + company_currency = self.journal_id.company_id.currency_id + st_line_currency = self.currency_id or self.journal_id.currency_id + currency = (st_line_currency and st_line_currency != company_currency) and st_line_currency.id or False + field = currency and 'amount_residual_currency' or 'amount_residual' + precision = st_line_currency and st_line_currency.decimal_places or company_currency.decimal_places + + def ret(comparator, amount, p=precision, f=field, c=currency): + if comparator == '<': + if amount < 0: + domain = [(f, '<', 0), (f, '>', amount)] + else: + domain = [(f, '>', 0), (f, '<', amount)] + elif comparator == '=': + domain = [(f, '=', float_round(amount, precision_digits=p))] + else: + raise UserError(_("Programmation error : domain_maker_move_line_amount requires comparator '=' or '<'")) + domain += [('currency_id', '=', c)] + return domain + + return ret + + def get_reconciliation_proposition(self, excluded_ids=None): + """ Returns move lines that constitute the best guess to reconcile a statement line + Note: it only looks for move lines in the same currency as the statement line. + """ + # Look for structured communication match + if self.name: + overlook_partner = not self.partner_id # If the transaction has no partner, look for match in payable and receivable account anyway + domain = [('ref', '=', self.name)] + match_recs = self.get_move_lines_for_reconciliation(excluded_ids=excluded_ids, limit=2, additional_domain=domain, overlook_partner=overlook_partner) + if match_recs and len(match_recs) == 1: + return match_recs + + # How to compare statement line amount and move lines amount + amount_domain_maker = self._get_domain_maker_move_line_amount() + amount = self.amount_currency or self.amount + + # Look for a single move line with the same amount + match_recs = self.get_move_lines_for_reconciliation(excluded_ids=excluded_ids, limit=1, additional_domain=amount_domain_maker('=', amount)) + if match_recs: + return match_recs + + if not self.partner_id: + return self.env['account.move.line'] + + # Select move lines until their total amount is greater than the statement line amount + domain = [('reconciled', '=', False)] + domain += [('account_id.user_type_id.type', '=', amount > 0 and 'receivable' or 'payable')] # Make sure we can't mix receivable and payable + domain += amount_domain_maker('<', amount) # Will also enforce > 0 + mv_lines = self.get_move_lines_for_reconciliation(excluded_ids=excluded_ids, limit=5, additional_domain=domain) + st_line_currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id + ret = self.env['account.move.line'] + total = 0 + for line in mv_lines: + total += line.currency_id and line.amount_residual_currency or line.amount_residual + if float_compare(total, abs(amount), precision_digits=st_line_currency.rounding) != -1: + break + ret = (ret | line) + return ret + + def _get_move_lines_for_auto_reconcile(self): + """ Returns the move lines that the method auto_reconcile can use to try to reconcile the statement line """ + pass + + @api.multi + def auto_reconcile(self): + """ Try to automatically reconcile the statement.line ; return the counterpart journal entry/ies if the automatic reconciliation succeeded, False otherwise. + TODO : this method could be greatly improved and made extensible + """ + self.ensure_one() + match_recs = self.env['account.move.line'] + + # How to compare statement line amount and move lines amount + amount_domain_maker = self._get_domain_maker_move_line_amount() + equal_amount_domain = amount_domain_maker('=', self.amount_currency or self.amount) + + # Look for structured communication match + if self.name: + overlook_partner = not self.partner_id # If the transaction has no partner, look for match in payable and receivable account anyway + domain = equal_amount_domain + [('ref', '=', self.name)] + match_recs = self.get_move_lines_for_reconciliation(limit=2, additional_domain=domain, overlook_partner=overlook_partner) + if match_recs and len(match_recs) != 1: + return False + + # Look for a single move line with the same partner, the same amount + if not match_recs: + if self.partner_id: + match_recs = self.get_move_lines_for_reconciliation(limit=2, additional_domain=equal_amount_domain) + if match_recs and len(match_recs) != 1: + return False + + if not match_recs: + return False + + # Now reconcile + counterpart_aml_dicts = [] + payment_aml_rec = self.env['account.move.line'] + for aml in match_recs: + if aml.account_id.internal_type == 'liquidity': + payment_aml_rec = (payment_aml_rec | aml) + else: + amount = aml.currency_id and aml.amount_residual_currency or aml.amount_residual + counterpart_aml_dicts.append({ + 'name': aml.name if aml.name != '/' else aml.move_id.name, + 'debit': amount < 0 and -amount or 0, + 'credit': amount > 0 and amount or 0, + 'move_line': aml + }) + + try: + with self._cr.savepoint(): + counterpart = self.process_reconciliation(counterpart_aml_dicts=counterpart_aml_dicts, payment_aml_rec=payment_aml_rec) + return counterpart + except UserError: + # A configuration / business logic error that makes it impossible to auto-reconcile should not be raised + # since automatic reconciliation is just an amenity and the user will get the same exception when manually + # reconciling. Other types of exception are (hopefully) programmation errors and should cause a stacktrace. + self.invalidate_cache() + self.env['account.move'].invalidate_cache() + self.env['account.move.line'].invalidate_cache() + return False + + def _prepare_reconciliation_move(self, move_name): + """ Prepare the dict of values to create the move from a statement line. This method may be overridden to adapt domain logic + through model inheritance (make sure to call super() to establish a clean extension chain). + + :param char st_line_number: will be used as the name of the generated account move + :return: dict of value to create() the account.move + """ + return { + 'statement_line_id': self.id, + 'journal_id': self.statement_id.journal_id.id, + 'date': self.date, + 'name': move_name, + 'ref': self.ref, + } + + def _prepare_reconciliation_move_line(self, move, amount): + """ Prepare the dict of values to create the move line from a statement line. + + :param recordset move: the account.move to link the move line + :param float amount: the amount of transaction that wasn't already reconciled + """ + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + st_line_currency = self.currency_id or statement_currency + + amount_currency = False + if statement_currency != company_currency or st_line_currency != company_currency: + # First get the ratio total mount / amount not already reconciled + if statement_currency == company_currency: + total_amount = self.amount + elif st_line_currency == company_currency: + total_amount = self.amount_currency + else: + total_amount = statement_currency.with_context({'date': self.date}).compute(self.amount, company_currency) + ratio = total_amount / amount + # Then use it to adjust the statement.line field that correspond to the move.line amount_currency + if statement_currency != company_currency: + amount_currency = self.amount * ratio + elif st_line_currency != company_currency: + amount_currency = self.amount_currency * ratio + return { + 'name': self.name, + 'date': self.date, + 'ref': self.ref, + 'move_id': move.id, + 'partner_id': self.partner_id and self.partner_id.id or False, + 'account_id': amount >= 0 \ + and self.statement_id.journal_id.default_credit_account_id.id \ + or self.statement_id.journal_id.default_debit_account_id.id, + 'credit': amount < 0 and -amount or 0.0, + 'debit': amount > 0 and amount or 0.0, + 'statement_id': self.statement_id.id, + 'journal_id': self.statement_id.journal_id.id, + 'currency_id': statement_currency != company_currency and statement_currency.id or (st_line_currency != company_currency and st_line_currency.id or False), + 'amount_currency': amount_currency, + } + + @api.v7 + def process_reconciliations(self, cr, uid, ids, data, context=None): + """ Handles data sent from the bank statement reconciliation widget (and can otherwise serve as an old-API bridge) + + :param list of dicts data: must contains the keys 'counterpart_aml_dicts', 'payment_aml_ids' and 'new_aml_dicts', + whose value is the same as described in process_reconciliation except that ids are used instead of recordsets. + """ + aml_obj = self.pool['account.move.line'] + for id, datum in zip(ids, data): + st_line = self.browse(cr, uid, id, context) + payment_aml_rec = aml_obj.browse(cr, uid, datum.get('payment_aml_ids', []), context) + for aml_dict in datum.get('counterpart_aml_dicts', []): + aml_dict['move_line'] = aml_obj.browse(cr, uid, aml_dict['counterpart_aml_id'], context) + del aml_dict['counterpart_aml_id'] + st_line.process_reconciliation(datum.get('counterpart_aml_dicts', []), payment_aml_rec, datum.get('new_aml_dicts', [])) + + def fast_counterpart_creation(self): + for st_line in self: + # Technical functionality to automatically reconcile by creating a new move line + vals = { + 'name': st_line.name, + 'debit': st_line.amount < 0 and -st_line.amount or 0.0, + 'credit': st_line.amount > 0 and st_line.amount or 0.0, + 'account_id': st_line.account_id.id, + } + st_line.process_reconciliation(new_aml_dicts=[vals]) + + def process_reconciliation(self, counterpart_aml_dicts=None, payment_aml_rec=None, new_aml_dicts=None): + """ Match statement lines with existing payments (eg. checks) and/or payables/receivables (eg. invoices and refunds) and/or new move lines (eg. write-offs). + If any new journal item needs to be created (via new_aml_dicts or counterpart_aml_dicts), a new journal entry will be created and will contain those + items, as well as a journal item for the bank statement line. + Finally, mark the statement line as reconciled by putting the matched moves ids in the column journal_entry_ids. + + :param (list of dicts) counterpart_aml_dicts: move lines to create to reconcile with existing payables/receivables. + The expected keys are : + - 'name' + - 'debit' + - 'credit' + - 'move_line' + # The move line to reconcile (partially if specified debit/credit is lower than move line's credit/debit) + + :param (list of recordsets) payment_aml_rec: recordset move lines representing existing payments (which are already fully reconciled) + + :param (list of dicts) new_aml_dicts: move lines to create. The expected keys are : + - 'name' + - 'debit' + - 'credit' + - 'account_id' + - (optional) 'tax_ids' + - (optional) Other account.move.line fields like analytic_account_id or analytics_id + + :returns: The journal entries with which the transaction was matched. If there was at least an entry in counterpart_aml_dicts or new_aml_dicts, this list contains + the move created by the reconciliation, containing entries for the statement.line (1), the counterpart move lines (0..*) and the new move lines (0..*). + """ + counterpart_aml_dicts = counterpart_aml_dicts or [] + payment_aml_rec = payment_aml_rec or self.env['account.move.line'] + new_aml_dicts = new_aml_dicts or [] + + aml_obj = self.env['account.move.line'] + + company_currency = self.journal_id.company_id.currency_id + statement_currency = self.journal_id.currency_id or company_currency + st_line_currency = self.currency_id or statement_currency + + counterpart_moves = self.env['account.move'] + + # Check and prepare received data + if self.journal_entry_ids.ids: + raise UserError(_('The bank statement line was already reconciled.')) + if any(rec.statement_id for rec in payment_aml_rec): + raise UserError(_('A selected move line was already reconciled.')) + for aml_dict in counterpart_aml_dicts: + if aml_dict['move_line'].reconciled: + raise UserError(_('A selected move line was already reconciled.')) + if isinstance(aml_dict['move_line'], (int, long)): + aml_dict['move_line'] = aml_obj.browse(aml_dict['move_line']) + for aml_dict in (counterpart_aml_dicts + new_aml_dicts): + if aml_dict.get('tax_ids') and aml_dict['tax_ids'] and isinstance(aml_dict['tax_ids'][0], (int, long)): + # Transform the value in the format required for One2many and Many2many fields + aml_dict['tax_ids'] = map(lambda id: (4, id, None), aml_dict['tax_ids']) + + # Fully reconciled moves are just linked to the bank statement + for aml_rec in payment_aml_rec: + aml_rec.write({'statement_id': self.statement_id.id}) + aml_rec.move_id.write({'statement_line_id': self.id}) + counterpart_moves = (counterpart_moves | aml_rec.move_id) + + # Create move line(s). Either matching an existing journal entry (eg. invoice), in which + # case we reconcile the existing and the new move lines together, or being a write-off. + if counterpart_aml_dicts or new_aml_dicts: + st_line_currency = self.currency_id or statement_currency + st_line_currency_rate = self.currency_id and (self.amount_currency / self.amount) or False + + # Create the move + move_name = (self.statement_id.name or self.name) + "/" + str(self.sequence) + move_vals = self._prepare_reconciliation_move(move_name) + move = self.env['account.move'].create(move_vals) + move.post() + counterpart_moves = (counterpart_moves | move) + + # Complete dicts to create both counterpart move lines and write-offs + to_create = (counterpart_aml_dicts + new_aml_dicts) + ctx = dict(self._context, date=self.date) + for aml_dict in to_create: + aml_dict['move_id'] = move.id + aml_dict['date'] = self.statement_id.date + aml_dict['partner_id'] = self.partner_id.id + aml_dict['journal_id'] = self.journal_id.id + aml_dict['company_id'] = self.company_id.id + aml_dict['statement_id'] = self.statement_id.id + if st_line_currency.id != company_currency.id: + aml_dict['amount_currency'] = aml_dict['debit'] - aml_dict['credit'] + aml_dict['currency_id'] = st_line_currency.id + if self.currency_id and statement_currency.id == company_currency.id and st_line_currency_rate: + # Statement is in company currency but the transaction is in foreign currency + aml_dict['debit'] = company_currency.round(aml_dict['debit'] / st_line_currency_rate) + aml_dict['credit'] = company_currency.round(aml_dict['credit'] / st_line_currency_rate) + elif self.currency_id and st_line_currency_rate: + # Statement is in foreign currency and the transaction is in another one + aml_dict['debit'] = statement_currency.with_context(ctx).compute(aml_dict['debit'] / st_line_currency_rate, company_currency) + aml_dict['credit'] = statement_currency.with_context(ctx).compute(aml_dict['credit'] / st_line_currency_rate, company_currency) + else: + # Statement is in foreign currency and no extra currency is given for the transaction + aml_dict['debit'] = st_line_currency.with_context(ctx).compute(aml_dict['debit'], company_currency) + aml_dict['credit'] = st_line_currency.with_context(ctx).compute(aml_dict['credit'], company_currency) + elif statement_currency.id != company_currency.id: + # Statement is in foreign currency but the transaction is in company currency + prorata_factor = (aml_dict['debit'] - aml_dict['credit']) / self.amount_currency + aml_dict['amount_currency'] = prorata_factor * self.amount + aml_dict['currency_id'] = statement_currency.id + + # Create the move line for the statement line using the total credit/debit of the counterpart + # This leaves out the amount already reconciled and avoids rounding errors from currency conversion + st_line_amount = sum(aml_dict['credit'] - aml_dict['debit'] for aml_dict in to_create) + aml_obj.with_context(check_move_validity=False).create(self._prepare_reconciliation_move_line(move, st_line_amount)) + + # Create write-offs + for aml_dict in new_aml_dicts: + aml_obj.with_context(check_move_validity=False).create(aml_dict) + + # Create counterpart move lines and reconcile them + for aml_dict in counterpart_aml_dicts: + if aml_dict['move_line'].partner_id.id: + aml_dict['partner_id'] = aml_dict['move_line'].partner_id.id + aml_dict['account_id'] = aml_dict['move_line'].account_id.id + + counterpart_move_line = aml_dict.pop('move_line') + if counterpart_move_line.currency_id and counterpart_move_line.currency_id != company_currency and not aml_dict.get('currency_id'): + aml_dict['currency_id'] = counterpart_move_line.currency_id.id + aml_dict['amount_currency'] = company_currency.with_context(ctx).compute(aml_dict['debit'] - aml_dict['credit'], counterpart_move_line.currency_id) + new_aml = aml_obj.with_context(check_move_validity=False).create(aml_dict) + (new_aml | counterpart_move_line).reconcile() + + counterpart_moves.assert_balanced() + return counterpart_moves diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py new file mode 100644 index 00000000000..06471732d88 --- /dev/null +++ b/addons/account/models/account_invoice.py @@ -0,0 +1,1278 @@ +# -*- coding: utf-8 -*- + +import json +from lxml import etree +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from openerp import api, fields, models, _ +from openerp.tools import float_is_zero + +from openerp.exceptions import UserError, RedirectWarning + +import openerp.addons.decimal_precision as dp + +# mapping invoice type to journal type +TYPE2JOURNAL = { + 'out_invoice': 'sale', + 'in_invoice': 'purchase', + 'out_refund': 'sale', + 'in_refund': 'purchase', +} + +# mapping invoice type to refund type +TYPE2REFUND = { + 'out_invoice': 'out_refund', # Customer Invoice + 'in_invoice': 'in_refund', # Supplier Bill + 'out_refund': 'out_invoice', # Customer Refund + 'in_refund': 'in_invoice', # Supplier Refund +} + +MAGIC_COLUMNS = ('id', 'create_uid', 'create_date', 'write_uid', 'write_date') + + +class AccountInvoice(models.Model): + _name = "account.invoice" + _inherit = ['mail.thread'] + _description = "Invoice" + _order = "date_invoice desc, number desc, id desc" + + @api.one + @api.depends('invoice_line_ids.price_subtotal', 'tax_line_ids.amount', 'currency_id', 'company_id') + def _compute_amount(self): + self.amount_untaxed = sum(line.price_subtotal for line in self.invoice_line_ids) + self.amount_tax = sum(line.amount for line in self.tax_line_ids) + self.amount_total = self.amount_untaxed + self.amount_tax + amount_total_signed = self.amount_total + amount_untaxed_signed = self.amount_untaxed + if self.currency_id and self.currency_id != self.company_id.currency_id: + amount_total_signed = self.currency_id.compute(self.amount_total, self.company_id.currency_id) + amount_untaxed_signed = self.currency_id.compute(self.amount_untaxed, self.company_id.currency_id) + sign = self.type in ['in_refund', 'out_refund'] and -1 or 1 + self.amount_total_signed = amount_total_signed * sign + self.amount_untaxed_signed = amount_untaxed_signed * sign + + @api.model + def _default_journal(self): + inv_type = self._context.get('type', 'out_invoice') + inv_types = inv_type if isinstance(inv_type, list) else [inv_type] + company_id = self._context.get('company_id', self.env.user.company_id.id) + domain = [ + ('type', 'in', filter(None, map(TYPE2JOURNAL.get, inv_types))), + ('company_id', '=', company_id), + ] + return self.env['account.journal'].search(domain, limit=1) + + @api.model + def _default_currency(self): + journal = self._default_journal() + return journal.currency_id or journal.company_id.currency_id + + @api.model + @api.returns('account.analytic.journal', lambda r: r.id) + def _get_journal_analytic(self, inv_type): + """ Return the analytic journal corresponding to the given invoice type. """ + journal_type = TYPE2JOURNAL.get(inv_type, 'sale') + journal = self.env['account.analytic.journal'].search([('type', '=', journal_type)], limit=1) + if not journal: + raise UserError(_("You must define an analytic journal of type '%s'!") % (journal_type,)) + return journal + + @api.model + def _get_reference_type(self): + return [('none', _('Free Reference'))] + + @api.one + @api.depends( + 'state', 'currency_id', 'invoice_line_ids.price_subtotal', + 'move_id.line_ids.amount_residual', + 'move_id.line_ids.currency_id') + def _compute_residual(self): + residual = 0.0 + residual_signed = 0.0 + sign = self.type in ['in_refund', 'in_invoice'] and -1 or 1 + for line in self.sudo().move_id.line_ids: + if line.account_id.internal_type in ('receivable', 'payable'): + residual_signed += line.amount_residual * sign + if line.currency_id == self.currency_id: + residual += line.amount_residual_currency if line.currency_id else line.amount_residual + else: + from_currency = (line.currency_id and line.currency_id.with_context(date=line.date)) or line.company_id.currency_id.with_context(date=line.date) + residual += from_currency.compute(line.amount_residual, self.currency_id) + self.residual_signed = residual_signed + self.residual = abs(residual) + digits_rounding_precision = self.currency_id.rounding + if float_is_zero(self.residual, digits_rounding_precision): + self.reconciled = True + + @api.one + def _get_outstanding_info_JSON(self): + self.outstanding_credits_debits_widget = json.dumps(False) + if self.state == 'open': + domain = [('journal_id.type', 'in', ('bank', 'cash')), ('account_id', '=', self.account_id.id), ('partner_id', '=', self.partner_id.id), ('reconciled', '=', False), ('amount_residual', '!=', 0.0)] + if self.type in ('out_invoice', 'in_refund'): + domain.extend([('credit', '>', 0), ('debit', '=', 0)]) + type_payment = _('Outstanding credit from %s') + else: + domain.extend([('credit', '=', 0), ('debit', '>', 0)]) + type_payment = _('Outstanding debit from %s') + info = {'title': '', 'outstanding': True, 'content': [], 'invoice_id': self.id} + lines = self.env['account.move.line'].search(domain) + if len(lines) != 0: + for line in lines: + # get the outstanding residual value in invoice currency + if line.currency_id: + amount_to_show = line.currency_id.compute(abs(line.amount_residual_currency), self.currency_id) + else: + amount_to_show = line.company_id.currency_id.compute(abs(line.amount_residual), self.currency_id) + info['content'].append({ + 'ref': line.ref or line.move_id.name, + 'amount': amount_to_show, + 'currency': self.currency_id.symbol, + 'id': line.id, + 'position': self.currency_id.position, + 'digits': [69, self.currency_id.decimal_places], + }) + info['title'] = type_payment % line.partner_id.name + self.outstanding_credits_debits_widget = json.dumps(info) + self.has_outstanding = True + + @api.one + @api.depends('payment_move_line_ids.amount_residual') + def _get_payment_info_JSON(self): + self.payments_widget = json.dumps(False) + if self.payment_move_line_ids: + info = {'title': _('Less Payment'), 'outstanding': False, 'content': []} + for payment in self.payment_move_line_ids: + if self.type in ('out_invoice', 'in_refund'): + amount = sum([p.amount for p in payment.matched_debit_ids if p.debit_move_id in self.move_id.line_ids]) + amount_currency = sum([p.amount_currency for p in payment.matched_debit_ids if p.debit_move_id in self.move_id.line_ids]) + elif self.type in ('in_invoice', 'out_refund'): + amount = sum([p.amount for p in payment.matched_credit_ids if p.credit_move_id in self.move_id.line_ids]) + amount_currency = sum([p.amount_currency for p in payment.matched_credit_ids if p.credit_move_id in self.move_id.line_ids]) + #we don't take into account the movement created due to a change difference + if payment.currency_id and payment.move_id.rate_diff_partial_rec_id: + continue + # get the payment value in invoice currency + if payment.currency_id and amount_currency != 0: + amount_to_show = amount_currency + else: + amount_to_show = payment.company_id.currency_id.compute(-amount, self.currency_id) + info['content'].append({ + 'name': payment.name, + 'ref': payment.journal_id.name, + 'amount': amount_to_show, + 'currency': self.currency_id.symbol, + 'digits': [69, self.currency_id.decimal_places], + 'position': self.currency_id.position, + 'date': payment.date, + }) + self.payments_widget = json.dumps(info) + + @api.one + @api.depends('move_id.line_ids.amount_residual') + def _compute_payments(self): + payment_lines = [] + for line in self.move_id.line_ids: + payment_lines.extend([rp.credit_move_id.id for rp in line.matched_credit_ids]) + payment_lines.extend([rp.debit_move_id.id for rp in line.matched_debit_ids]) + self.payment_move_line_ids = self.env['account.move.line'].browse(list(set(payment_lines))) + + name = fields.Char(string='Reference/Description', index=True, + readonly=True, states={'draft': [('readonly', False)]}, copy=False, help='The name that will be used on account move lines') + origin = fields.Char(string='Source Document', + help="Reference of the document that produced this invoice.", + readonly=True, states={'draft': [('readonly', False)]}) + type = fields.Selection([ + ('out_invoice','Customer Invoice'), + ('in_invoice','Supplier Bill'), + ('out_refund','Customer Refund'), + ('in_refund','Supplier Refund'), + ], readonly=True, index=True, change_default=True, + default=lambda self: self._context.get('type', 'out_invoice'), + track_visibility='always') + + number = fields.Char(related='move_id.name', store=True, readonly=True, copy=False) + move_name = fields.Char(string='Journal Entry', readonly=True, + default=False, copy=False, + help="Technical field holding the number given to the invoice, automatically set when the invoice is validated then stored to set the same number again if the invoice is cancelled, set to draft and re-validated.") + reference = fields.Char(string='Supplier Reference', + help="The partner reference of this invoice.", readonly=True, states={'draft': [('readonly', False)]}) + reference_type = fields.Selection('_get_reference_type', string='Payment Reference', + required=True, readonly=True, states={'draft': [('readonly', False)]}, + default='none') + comment = fields.Text('Additional Information', readonly=True, states={'draft': [('readonly', False)]}) + + state = fields.Selection([ + ('draft','Draft'), + ('proforma', 'Pro-forma'), + ('proforma2', 'Pro-forma'), + ('open', 'Open'), + ('paid', 'Paid'), + ('cancel', 'Cancelled'), + ], string='Status', index=True, readonly=True, default='draft', + track_visibility='onchange', copy=False, + help=" * The 'Draft' status is used when a user is encoding a new and unconfirmed Invoice.\n" + " * The 'Pro-forma' when invoice is in Pro-forma status, invoice does not have an invoice number.\n" + " * The 'Open' status is used when user create invoice, an invoice number is generated. Its in open status till user does not pay invoice.\n" + " * The 'Paid' status is set automatically when the invoice is paid. Its related journal entries may or may not be reconciled.\n" + " * The 'Cancelled' status is used when user cancel invoice.") + sent = fields.Boolean(readonly=True, default=False, copy=False, + help="It indicates that the invoice has been sent.") + date_invoice = fields.Date(string='Invoice Date', + readonly=True, states={'draft': [('readonly', False)]}, index=True, + help="Keep empty to use the current date", copy=False) + date_due = fields.Date(string='Due Date', + readonly=True, states={'draft': [('readonly', False)]}, index=True, copy=False, + help="If you use payment terms, the due date will be computed automatically at the generation " + "of accounting entries. The payment term may compute several due dates, for example 50% " + "now and 50% in one month, but if you want to force a due date, make sure that the payment " + "term is not set on the invoice. If you keep the payment term and the due date empty, it " + "means direct payment.") + partner_id = fields.Many2one('res.partner', string='Partner', change_default=True, + required=True, readonly=True, states={'draft': [('readonly', False)]}, + track_visibility='always') + payment_term_id = fields.Many2one('account.payment.term', string='Payment Term', oldname='payment_term', + readonly=True, states={'draft': [('readonly', False)]}, + help="If you use payment terms, the due date will be computed automatically at the generation " + "of accounting entries. If you keep the payment term and the due date empty, it means direct payment. " + "The payment term may compute several due dates, for example 50% now, 50% in one month.") + date = fields.Date(string='Accounting Date', + copy=False, + help="Keep empty to use the invoice date.", + readonly=True, states={'draft': [('readonly', False)]}) + + account_id = fields.Many2one('account.account', string='Account', + required=True, readonly=True, states={'draft': [('readonly', False)]}, + domain=[('deprecated', '=', False)], help="The partner account used for this invoice.") + invoice_line_ids = fields.One2many('account.invoice.line', 'invoice_id', string='Invoice Lines', oldname='invoice_line', + readonly=True, states={'draft': [('readonly', False)]}, copy=True) + tax_line_ids = fields.One2many('account.invoice.tax', 'invoice_id', string='Tax Lines', oldname='tax_line', + readonly=True, states={'draft': [('readonly', False)]}, copy=True) + move_id = fields.Many2one('account.move', string='Journal Entry', + readonly=True, index=True, ondelete='restrict', copy=False, + help="Link to the automatically generated Journal Items.") + + amount_untaxed = fields.Monetary(string='Untaxed Amount', + store=True, readonly=True, compute='_compute_amount', track_visibility='always') + amount_untaxed_signed = fields.Monetary(string='Untaxed Amount', currency_field='company_currency_id', + store=True, readonly=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') + amount_total_signed = fields.Monetary(string='Total', currency_field='company_currency_id', + store=True, readonly=True, compute='_compute_amount', + help="Total amount in the currency of the company, negative for credit notes.") + currency_id = fields.Many2one('res.currency', string='Currency', + required=True, readonly=True, states={'draft': [('readonly', False)]}, + default=_default_currency, track_visibility='always') + company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True) + journal_id = fields.Many2one('account.journal', string='Journal', + required=True, readonly=True, states={'draft': [('readonly', False)]}, + default=_default_journal, + domain="[('type', 'in', {'out_invoice': ['sale'], 'out_refund': ['sale'], 'in_refund': ['purchase'], 'in_invoice': ['purchase']}.get(type, [])), ('company_id', '=', company_id)]") + company_id = fields.Many2one('res.company', string='Company', change_default=True, + required=True, readonly=True, states={'draft': [('readonly', False)]}, + default=lambda self: self.env['res.company']._company_default_get('account.invoice')) + check_total = fields.Monetary(string='Verification Total', + readonly=True, states={'draft': [('readonly', False)]}, default=0.0) + + reconciled = fields.Boolean(string='Paid/Reconciled', store=True, readonly=True, compute='_compute_residual', + help="It indicates that the invoice has been paid and the journal entry of the invoice has been reconciled with one or several journal entries of payment.") + partner_bank_id = fields.Many2one('res.partner.bank', string='Bank Account', + help='Bank Account Number to which the invoice will be paid. A Company bank account if this is a Customer Invoice or Supplier Refund, otherwise a Partner bank account number.', + readonly=True, states={'draft': [('readonly', False)]}) + + residual = fields.Monetary(string='Amount Due', + compute='_compute_residual', store=True, help="Remaining amount due.") + residual_signed = fields.Monetary(string='Amount Due', currency_field='company_currency_id', + compute='_compute_residual', store=True, help="Remaining amount due in the currency of the company.") + payment_ids = fields.Many2many('account.payment', 'account_invoice_payment_rel', 'invoice_id', 'payment_id', string="Payments", copy=False, readonly=True) + payment_move_line_ids = fields.Many2many('account.move.line', string='Payments', compute='_compute_payments', store=True) + user_id = fields.Many2one('res.users', string='Salesperson', track_visibility='onchange', + readonly=True, states={'draft': [('readonly', False)]}, + default=lambda self: self.env.user) + fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', oldname='fiscal_position', + readonly=True, states={'draft': [('readonly', False)]}) + commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', + related='partner_id.commercial_partner_id', store=True, readonly=True, + help="The commercial entity that will be used on Journal Entries for this invoice") + + outstanding_credits_debits_widget = fields.Text(compute='_get_outstanding_info_JSON') + payments_widget = fields.Text(compute='_get_payment_info_JSON') + has_outstanding = fields.Boolean(compute='_get_outstanding_info_JSON') + + _sql_constraints = [ + ('number_uniq', 'unique(number, company_id, journal_id, type)', 'Invoice Number must be unique per Company!'), + ] + + @api.model + def create(self, vals): + if not vals.get('account_id',False): + raise UserError(_('Configuration error!\nCould not find any account to create the invoice, are you sure you have a chart of account installed?')) + return super(AccountInvoice, self.with_context(mail_create_nolog=True)).create(vals) + + @api.model + def fields_view_get(self, view_id=None, view_type=False, toolbar=False, submenu=False): + def get_view_id(xid, name): + try: + return self.env['ir.model.data'].xmlid_to_res_id('account.' + xid, raise_if_not_found=True) + except ValueError: + view = self.env['ir.ui.view'].search([('name', '=', name)], limit=1) + if not view: + return False + return view.id + + context = self._context + if context.get('active_model') == 'res.partner' and context.get('active_ids'): + partner = self.env['res.partner'].browse(context['active_ids'])[0] + if not view_type: + view_id = get_view_id('invoice_tree', 'account.invoice.tree') + view_type = 'tree' + elif view_type == 'form': + if partner.supplier and not partner.customer: + view_id = get_view_id('invoice_supplier_form', 'account.invoice.supplier.form') + elif partner.customer and not partner.supplier: + view_id = get_view_id('invoice_form', 'account.invoice.form') + return super(AccountInvoice, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + + @api.multi + def invoice_print(self): + """ Print the invoice and mark it as sent, so that we can see more + easily the next step of the workflow + """ + self.ensure_one() + self.sent = True + return self.env['report'].get_action(self, 'account.report_invoice') + + @api.multi + 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', False) + compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) + ctx = dict( + default_model='account.invoice', + default_res_id=self.id, + default_use_template=bool(template), + default_template_id=template.id, + default_composition_mode='comment', + mark_invoice_as_sent=True, + ) + return { + 'name': _('Compose Email'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'mail.compose.message', + 'views': [(compose_form.id, 'form')], + 'view_id': compose_form.id, + 'target': 'new', + 'context': ctx, + } + + @api.multi + def compute_taxes(self): + """Function used in other module to compute the taxes on a fresh invoice created (onchanges did not applied)""" + account_invoice_tax = self.env['account.invoice.tax'] + ctx = dict(self._context) + for invoice in self: + # Delete non-manual tax lines + self._cr.execute("DELETE FROM account_invoice_tax WHERE invoice_id=%s AND manual is False", (invoice.id,)) + self.invalidate_cache() + + # Generate one tax line per tax, however many invoice lines it's applied to + tax_grouped = invoice.get_taxes_values() + + # Create new tax lines + for tax in tax_grouped.values(): + account_invoice_tax.create(tax) + + # dummy write on self to trigger recomputations + return self.with_context(ctx).write({'invoice_line_ids': []}) + + @api.multi + def confirm_paid(self): + return self.write({'state': 'paid'}) + + @api.multi + def unlink(self): + for invoice in self: + if invoice.state not in ('draft', 'cancel'): + raise UserError(_('You cannot delete an invoice which is not draft or cancelled. You should refund it instead.')) + elif invoice.move_name: + raise UserError(_('You cannot delete an invoice after it has been validated (and received a number). You can set it back to "Draft" state and modify its content, then re-confirm it.')) + return super(AccountInvoice, self).unlink() + + @api.onchange('invoice_line_ids') + def _onchange_invoice_line_ids(self): + taxes_grouped = self.get_taxes_values() + tax_lines = self.tax_line_ids.browse([]) + for tax in taxes_grouped.values(): + tax_lines += tax_lines.new(tax) + self.tax_line_ids = tax_lines + return + + @api.onchange('partner_id', 'company_id') + def _onchange_partner_id(self): + account_id = False + payment_term_id = False + fiscal_position = False + bank_id = False + p = self.partner_id + company_id = self.company_id.id + type = self.type + if p: + partner_id = p.id + rec_account = p.property_account_receivable_id + pay_account = p.property_account_payable_id + if company_id: + if p.property_account_receivable_id.company_id and \ + p.property_account_receivable_id.company_id.id != company_id and \ + p.property_account_payable_id.company_id and \ + p.property_account_payable_id.company_id.id != company_id: + prop = self.env['ir.property'] + rec_dom = [('name', '=', 'property_account_receivable_id'), ('company_id', '=', company_id)] + pay_dom = [('name', '=', 'property_account_payable_id'), ('company_id', '=', company_id)] + res_dom = [('res_id', '=', 'res.partner,%s' % partner_id)] + rec_prop = prop.search(rec_dom + res_dom) or prop.search(rec_dom) + pay_prop = prop.search(pay_dom + res_dom) or prop.search(pay_dom) + rec_account = rec_prop.get_by_record(rec_prop) + pay_account = pay_prop.get_by_record(pay_prop) + 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')) + + if type in ('out_invoice', 'out_refund'): + account_id = rec_account.id + payment_term_id = p.property_payment_term_id.id + else: + account_id = pay_account.id + payment_term_id = p.property_supplier_payment_term_id.id + fiscal_position = p.property_account_position_id.id + bank_id = p.bank_ids and p.bank_ids.ids[0] or False + self.account_id = account_id + self.payment_term_id = payment_term_id + self.fiscal_position_id = fiscal_position + + if type in ('in_invoice', 'in_refund'): + self.partner_bank_id = bank_id + + @api.onchange('journal_id') + def _onchange_journal_id(self): + if self.journal_id: + self.currency_id = self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id + + @api.onchange('payment_term_id', 'date_invoice') + def _onchange_payment_term_date_invoice(self): + date_invoice = self.date_invoice + if not date_invoice: + date_invoice = fields.Date.context_today(self) + if not self.payment_term_id: + # When no payment term defined + self.date_due = self.date_due or self.date_invoice + else: + pterm = self.payment_term_id + pterm_list = pterm.compute(value=1, date_ref=date_invoice)[0] + self.date_due = max(line[0] for line in pterm_list) + + @api.multi + def action_cancel_draft(self): + # go from canceled state to draft state + self.write({'state': 'draft'}) + self.delete_workflow() + self.create_workflow() + return True + + @api.multi + def get_taxes_values(self): + tax_grouped = {} + for line in self.invoice_line_ids: + price_unit = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = line.invoice_line_tax_ids.compute_all(price_unit, self.currency_id, line.quantity, line.product_id, self.partner_id)['taxes'] + for tax in taxes: + val = { + 'invoice_id': self.id, + 'name': tax['name'], + 'tax_id': tax['id'], + 'amount': tax['amount'], + 'manual': False, + 'sequence': tax['sequence'], + 'account_analytic_id': tax['analytic'] and line.account_analytic_id.id or False, + 'account_id': self.type in ('out_invoice', 'in_invoice') and (tax['account_id'] or line.account_id.id) or (tax['refund_account_id'] or line.account_id.id), + } + + # If the taxes generate moves on the same financial account as the invoice line, + # propagate the analytic account from the invoice line to the tax line. + # This is necessary in situations were (part of) the taxes cannot be reclaimed, + # to ensure the tax move is allocated to the proper analytic account. + if not val.get('account_analytic_id') and line.account_analytic_id and val['account_id'] == line.account_id.id: + val['account_analytic_id'] = line.account_analytic_id.id + + key = tax['id'] + if key not in tax_grouped: + tax_grouped[key] = val + else: + tax_grouped[key]['amount'] += val['amount'] + return tax_grouped + + @api.multi + def register_payment(self, payment_line, writeoff_acc_id=False, writeoff_journal_id=False): + """ Reconcile payable/receivable lines from the invoice with payment_line """ + line_to_reconcile = self.env['account.move.line'] + for inv in self: + line_to_reconcile += inv.move_id.line_ids.filtered(lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable')) + return (line_to_reconcile + payment_line).reconcile(writeoff_acc_id, writeoff_journal_id) + + @api.v7 + def assign_outstanding_credit(self, cr, uid, id, payment_id, context=None): + return self.browse(cr, uid, id, context).register_payment(self.pool.get('account.move.line').browse(cr, uid, payment_id, context)) + + @api.multi + def action_date_assign(self): + for inv in self: + # Here the onchange will automatically write to the database + inv._onchange_payment_term_date_invoice() + return True + + @api.multi + def finalize_invoice_move_lines(self, move_lines): + """ finalize_invoice_move_lines(move_lines) -> move_lines + + Hook method to be overridden in additional modules to verify and + possibly alter the move lines to be created by an invoice, for + special cases. + :param move_lines: list of dictionaries with the account.move.lines (as for create()) + :return: the (possibly updated) final move_lines to create for this invoice + """ + return move_lines + + @api.multi + def compute_invoice_totals(self, company_currency, invoice_move_lines): + total = 0 + total_currency = 0 + for line in invoice_move_lines: + if self.currency_id != company_currency: + currency = self.currency_id.with_context(date=self.date_invoice or fields.Date.context_today(self)) + line['currency_id'] = currency.id + line['amount_currency'] = line['price'] + line['price'] = currency.compute(line['price'], company_currency) + else: + line['currency_id'] = False + line['amount_currency'] = False + if self.type in ('out_invoice', 'in_refund'): + total += line['price'] + total_currency += line['amount_currency'] or line['price'] + line['price'] = - line['price'] + else: + total -= line['price'] + total_currency -= line['amount_currency'] or line['price'] + return total, total_currency, invoice_move_lines + + @api.model + def invoice_line_move_line_get(self): + res = [] + for line in self.invoice_line_ids: + tax_ids = [] + for tax in line.invoice_line_tax_ids: + tax_ids.append((4, tax.id, None)) + for child in tax.children_tax_ids: + if child.type_tax_use != 'none': + tax_ids.append((4, child.id, None)) + + move_line_dict = { + 'invl_id': line.id, + 'type': 'src', + 'name': line.name.split('\n')[0][:64], + 'price_unit': line.price_unit, + 'quantity': line.quantity, + 'price': line.price_subtotal, + 'account_id': line.account_id.id, + 'product_id': line.product_id.id, + 'uos_id': line.uos_id.id, + 'account_analytic_id': line.account_analytic_id.id, + 'tax_ids': tax_ids, + 'invoice_id': self.id, + } + if line['account_analytic_id']: + move_line_dict['analytic_line_ids'] = [(0, 0, line._get_analytic_line())] + res.append(move_line_dict) + return res + + @api.model + def tax_line_move_line_get(self): + res = [] + for tax_line in self.tax_line_ids: + res.append({ + 'tax_line_id': tax_line.tax_id.id, + 'type': 'tax', + 'name': tax_line.name, + 'price_unit': tax_line.amount, + 'quantity': 1, + 'price': tax_line.amount, + 'account_id': tax_line.account_id.id, + 'account_analytic_id': tax_line.account_analytic_id.id, + }) + return res + + def inv_line_characteristic_hashcode(self, invoice_line): + """Overridable hashcode generation for invoice lines. Lines having the same hashcode + will be grouped together if the journal has the 'group line' option. Of course a module + can add fields to invoice lines that would need to be tested too before merging lines + or not.""" + return "%s-%s-%s-%s-%s" % ( + invoice_line['account_id'], + invoice_line.get('tax_line_id', 'False'), + invoice_line.get('product_id', 'False'), + invoice_line.get('analytic_account_id', 'False'), + invoice_line.get('date_maturity', 'False'), + ) + + def group_lines(self, iml, line): + """Merge account move lines (and hence analytic lines) if invoice line hashcodes are equals""" + if self.journal_id.group_invoice_lines: + line2 = {} + for x, y, l in line: + tmp = self.inv_line_characteristic_hashcode(l) + if tmp in line2: + am = line2[tmp]['debit'] - line2[tmp]['credit'] + (l['debit'] - l['credit']) + line2[tmp]['debit'] = (am > 0) and am or 0.0 + line2[tmp]['credit'] = (am < 0) and -am or 0.0 + line2[tmp]['analytic_line_ids'] += l['analytic_line_ids'] + else: + line2[tmp] = l + line = [] + for key, val in line2.items(): + line.append((0, 0, val)) + return line + + @api.multi + def action_move_create(self): + """ Creates invoice related analytics and financial move lines """ + account_move = self.env['account.move'] + + for inv in self: + if not inv.journal_id.sequence_id: + raise UserError(_('Please define sequence on the journal related to this invoice.')) + if not inv.invoice_line_ids: + raise UserError(_('Please create some invoice lines.')) + if inv.move_id: + continue + + ctx = dict(self._context, lang=inv.partner_id.lang) + + if not inv.date_invoice: + inv.with_context(ctx).write({'date_invoice': fields.Date.context_today(self)}) + date_invoice = inv.date_invoice + company_currency = inv.company_id.currency_id + + # create move lines (one per invoice line + eventual taxes and analytic lines) + iml = inv.invoice_line_move_line_get() + iml += inv.tax_line_move_line_get() + + # I disabled the check_total feature + if self.env['res.users'].has_group('account.group_supplier_inv_check_total'): + if inv.type in ('in_invoice', 'in_refund') and abs(inv.check_total - inv.amount_total) >= (inv.currency_id.rounding / 2.0): + raise UserError(_('Please verify the price of the invoice!\nThe encoded total does not match the computed total.')) + + diff_currency = inv.currency_id != company_currency + # create one move line for the total and possibly adjust the other lines amount + total, total_currency, iml = inv.with_context(ctx).compute_invoice_totals(company_currency, iml) + + name = inv.name or '/' + if inv.payment_term_id: + totlines = inv.with_context(ctx).payment_term_id.compute(total, date_invoice)[0] + res_amount_currency = total_currency + ctx['date'] = date_invoice + for i, t in enumerate(totlines): + if inv.currency_id != company_currency: + amount_currency = company_currency.with_context(ctx).compute(t[1], inv.currency_id) + else: + amount_currency = False + + # last line: add the diff + res_amount_currency -= amount_currency or 0 + if i + 1 == len(totlines): + amount_currency += res_amount_currency + + iml.append({ + 'type': 'dest', + 'name': name, + 'price': t[1], + 'account_id': inv.account_id.id, + 'date_maturity': t[0], + 'amount_currency': diff_currency and amount_currency, + 'currency_id': diff_currency and inv.currency_id.id, + 'invoice_id': inv.id + }) + else: + iml.append({ + 'type': 'dest', + 'name': name, + 'price': total, + 'account_id': inv.account_id.id, + 'date_maturity': inv.date_due, + 'amount_currency': diff_currency and total_currency, + 'currency_id': diff_currency and inv.currency_id.id, + 'invoice_id': inv.id + }) + part = self.env['res.partner']._find_accounting_partner(inv.partner_id) + line = [(0, 0, self.line_get_convert(l, part.id)) for l in iml] + line = inv.group_lines(iml, line) + + journal = inv.journal_id.with_context(ctx) + line = inv.finalize_invoice_move_lines(line) + + date = inv.date or date_invoice + move_vals = { + 'ref': inv.reference, + 'line_ids': line, + 'journal_id': journal.id, + 'date': date, + 'narration': inv.comment, + } + ctx['company_id'] = inv.company_id.id + ctx['dont_create_taxes'] = True + ctx['invoice_id'] = inv.move_name and inv or False + move = account_move.with_context(ctx).create(move_vals) + # make the invoice point to that move + vals = { + 'move_id': move.id, + 'date': date, + 'move_name': move.name, + } + inv.with_context(ctx).write(vals) + # Pass invoice in context in method post: used if you want to get the same + # account move reference when creating the same invoice after a cancelled one: + move.post() + return True + + @api.multi + def invoice_validate(self): + return self.write({'state': 'open'}) + + @api.model + def line_get_convert(self, line, part): + return { + 'date_maturity': line.get('date_maturity', False), + 'partner_id': part, + 'name': line['name'][:64], + 'debit': line['price'] > 0 and line['price'], + 'credit': line['price'] < 0 and -line['price'], + 'account_id': line['account_id'], + 'analytic_line_ids': line.get('analytic_line_ids', []), + 'amount_currency': line['price'] > 0 and abs(line.get('amount_currency', False)) or -abs(line.get('amount_currency', False)), + 'currency_id': line.get('currency_id', False), + 'quantity': line.get('quantity', 1.00), + 'product_id': line.get('product_id', False), + 'product_uom_id': line.get('uos_id', False), + 'analytic_account_id': line.get('account_analytic_id', False), + 'invoice_id': line.get('invoice_id', False), + 'tax_ids': line.get('tax_ids', False), + 'tax_line_id': line.get('tax_line_id', False), + } + + @api.multi + def action_cancel(self): + moves = self.env['account.move'] + for inv in self: + if inv.move_id: + moves += inv.move_id + if inv.payment_move_line_ids: + raise UserError(_('You cannot cancel an invoice which is partially paid. You need to unreconcile related payment entries first.')) + + # First, set the invoices as cancelled and detach the move ids + self.write({'state': 'cancel', 'move_id': False}) + if moves: + # second, invalidate the move(s) + moves.button_cancel() + # delete the move this invoice was pointing to + # Note that the corresponding move_lines and move_reconciles + # will be automatically deleted too + moves.unlink() + return True + + ################### + + @api.multi + def name_get(self): + TYPES = { + 'out_invoice': _('Invoice'), + 'in_invoice': _('Supplier Bill'), + 'out_refund': _('Refund'), + 'in_refund': _('Supplier Refund'), + } + result = [] + for inv in self: + result.append((inv.id, "%s %s" % (inv.number or TYPES[inv.type], inv.name or ''))) + return result + + @api.model + def name_search(self, name, args=None, operator='ilike', limit=100): + args = args or [] + recs = self.browse() + if name: + recs = self.search([('number', '=', name)] + args, limit=limit) + if not recs: + recs = self.search([('name', operator, name)] + args, limit=limit) + return recs.name_get() + + @api.model + def _refund_cleanup_lines(self, lines): + """ Convert records to dict of values suitable for one2many line creation + + :param recordset lines: records to convert + :return: list of command tuple for one2many line creation [(0, 0, dict of valueis), ...] + """ + result = [] + for line in lines: + values = {} + for name, field in line._fields.iteritems(): + if name in MAGIC_COLUMNS: + continue + elif field.type == 'many2one': + values[name] = line[name].id + elif field.type not in ['many2many', 'one2many']: + values[name] = line[name] + elif name == 'invoice_line_tax_ids': + values[name] = [(6, 0, line[name].ids)] + result.append((0, 0, values)) + return result + + @api.model + def _prepare_refund(self, invoice, date_invoice=None, date=None, description=None, journal_id=None): + """ Prepare the dict of values to create the new refund from the invoice. + This method may be overridden to implement custom + refund generation (making sure to call super() to establish + a clean extension chain). + + :param record invoice: invoice to refund + :param string date_invoice: refund creation date from the wizard + :param integer date: force date from the wizard + :param string description: description of the refund from the wizard + :param integer journal_id: account.journal from the wizard + :return: dict of value to create() the refund + """ + values = {} + for field in ['name', 'reference', 'comment', 'date_due', 'partner_id', 'company_id', + 'account_id', 'currency_id', 'payment_term_id', 'user_id', 'fiscal_position_id']: + if invoice._fields[field].type == 'many2one': + values[field] = invoice[field].id + else: + values[field] = invoice[field] or False + + values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids) + + tax_lines = filter(lambda l: l.manual, invoice.tax_line_ids) + values['tax_line_ids'] = self._refund_cleanup_lines(tax_lines) + + if journal_id: + journal = self.env['account.journal'].browse(journal_id) + elif invoice['type'] == 'in_invoice': + journal = self.env['account.journal'].search([('type', '=', 'purchase')], limit=1) + else: + journal = self.env['account.journal'].search([('type', '=', 'sale')], limit=1) + values['journal_id'] = journal.id + + values['type'] = TYPE2REFUND[invoice['type']] + values['date_invoice'] = date_invoice or fields.Date.context_today(invoice) + values['state'] = 'draft' + values['number'] = False + values['origin'] = invoice.number + + if date: + values['date'] = date + if description: + values['name'] = description + return values + + @api.multi + @api.returns('self') + def refund(self, date_invoice=None, date=None, description=None, journal_id=None): + new_invoices = self.browse() + for invoice in self: + # create the new invoice + values = self._prepare_refund(invoice, date_invoice=date_invoice, date=date, + description=description, journal_id=journal_id) + new_invoices += self.create(values) + return new_invoices + + @api.v8 + def pay_and_reconcile(self, pay_journal, pay_amount=None, date=None, writeoff_acc=None): + """ Create and post an account.payment for the invoice self, which creates a journal entry that reconciles the invoice. + + :param pay_journal: journal in which the payment entry will be created + :param pay_amount: amount of the payment to register, defaults to the residual of the invoice + :param date: payment date, defaults to fields.Date.context_today(self) + :param writeoff_acc: account in which to create a writeoff if pay_amount < self.residual, so that the invoice is fully paid + """ + assert len(self) == 1, "Can only pay one invoice at a time." + payment_type = self.type in ('out_invoice', 'in_refund') and 'inbound' or 'outbound' + if payment_type == 'inbound': + payment_method = self.env.ref('account.account_payment_method_manual_in') + journal_payment_methods = pay_journal.inbound_payment_method_ids + else: + payment_method = self.env.ref('account.account_payment_method_manual_out') + journal_payment_methods = pay_journal.outbound_payment_method_ids + if payment_method not in journal_payment_methods: + raise UserError(_('No appropriate payment method enabled on journal %s') % pay_journal.name) + + payment = self.env['account.payment'].create({ + 'invoice_ids': [(6, 0, self.ids)], + 'amount': pay_amount or self.residual, + 'payment_date': date or fields.Date.context_today(self), + 'communication': self.type in ('in_invoice', 'in_refund') and self.reference or self.number, + 'partner_id': self.partner_id.id, + 'partner_type': self.type in ('out_invoice', 'out_refund') and 'customer' or 'supplier', + 'journal_id': pay_journal.id, + 'payment_type': payment_type, + 'payment_method_id': payment_method.id, + 'payment_difference_handling': writeoff_acc and 'reconcile' or 'open', + 'writeoff_account_id': writeoff_acc and writeoff_acc.id or False, + }) + payment.post() + + @api.v7 + def pay_and_reconcile(self, cr, uid, ids, pay_journal_id, pay_amount=None, date=None, writeoff_acc_id=None, context=None): + recs = self.browse(cr, uid, ids, context) + pay_journal = self.pool.get('account.journal').browse(cr, uid, pay_journal_id, context=context) + writeoff_acc = self.pool.get('account.account').browse(cr, uid, writeoff_acc_id, context=context) + return recs.pay_and_reconcile(pay_journal, pay_amount, date, writeoff_acc) + + @api.multi + def _track_subtype(self, init_values): + self.ensure_one() + if 'state' in init_values and self.state == 'paid' and self.type in ('out_invoice', 'out_refund'): + return 'account.mt_invoice_paid' + elif 'state' in init_values and self.state == 'open' and self.type in ('out_invoice', 'out_refund'): + return 'account.mt_invoice_validated' + elif 'state' in init_values and self.state == 'draft' and self.type in ('out_invoice', 'out_refund'): + return 'account.mt_invoice_created' + return super(AccountInvoice, self)._track_subtype(init_values) + + +class AccountInvoiceLine(models.Model): + _name = "account.invoice.line" + _description = "Invoice Line" + _order = "invoice_id,sequence,id" + + @api.multi + def _get_analytic_line(self): + ref = self.invoice_id.number + if not self.invoice_id.journal_id.analytic_journal_id: + raise UserError(_("No Analytic Journal! You have to define an analytic journal on the '%s' journal!") % (self.invoice_id.journal_id.name,)) + return { + 'name': self.name, + 'date': self.invoice_id.date_invoice, + 'account_id': self.account_analytic_id.id, + 'unit_amount': self.quantity, + 'amount': self.price_subtotal_signed, + 'product_id': self.product_id.id, + 'product_uom_id': self.uos_id.id, + 'general_account_id': self.account_id.id, + 'journal_id': self.invoice_id.journal_id.analytic_journal_id.id, + 'ref': ref, + } + + @api.one + @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity', + 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id') + def _compute_price(self): + currency = self.invoice_id and self.invoice_id.currency_id or None + price = self.price_unit * (1 - (self.discount or 0.0) / 100.0) + taxes = False + if self.invoice_line_tax_ids: + taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id, partner=self.invoice_id.partner_id) + self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price + if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id: + price_subtotal_signed = self.invoice_id.currency_id.compute(price_subtotal_signed, self.invoice_id.company_id.currency_id) + sign = self.invoice_id.type in ['in_refund', 'out_refund'] and -1 or 1 + self.price_subtotal_signed = price_subtotal_signed * sign + + @api.model + def _default_price_unit(self): + if not self._context.get('check_total'): + return 0 + currency = self.invoice_id and self.invoice_id.currency_id or None + total = self._context['check_total'] + for l in self._context.get('invoice_line_ids', []): + if isinstance(l, (list, tuple)) and len(l) >= 3 and l[2]: + vals = l[2] + price = vals.get('price_unit', 0) * (1 - vals.get('discount', 0) / 100.0) + total = total - (price * vals.get('quantity')) + taxes = vals.get('invoice_line_tax_ids') + if taxes and len(taxes[0]) >= 3 and taxes[0][2]: + taxes = self.env['account.tax'].browse(taxes[0][2]) + tax_res = taxes.compute_all(price, currency, vals.get('quantity'), vals.get('product_id'), self._context.get('partner_id')) + for tax in tax_res['taxes']: + total = total - tax['amount'] + return total + + @api.model + def _default_account(self): + if self._context.get('journal_id'): + journal = self.env['account.journal'].browse(self._context.get('journal_id')) + if self._context.get('type') in ('out_invoice', 'in_refund'): + return journal.default_credit_account_id.id + return journal.default_debit_account_id.id + + name = fields.Text(string='Description', required=True) + origin = fields.Char(string='Source Document', + help="Reference of the document that produced this invoice.") + sequence = fields.Integer(default=10, + help="Gives the sequence of this line when displaying the invoice.") + invoice_id = fields.Many2one('account.invoice', string='Invoice Reference', + ondelete='cascade', index=True) + uos_id = fields.Many2one('product.uom', string='Unit of Measure', + ondelete='set null', index=True) + product_id = fields.Many2one('product.product', string='Product', + ondelete='restrict', index=True) + account_id = fields.Many2one('account.account', string='Account', + required=True, domain=[('deprecated', '=', False)], + default=_default_account, + help="The income or expense account related to the selected product.") + price_unit = fields.Monetary(string='Unit Price', required=True, + default=_default_price_unit) + price_subtotal = fields.Monetary(string='Amount', + store=True, readonly=True, compute='_compute_price') + price_subtotal_signed = fields.Monetary(string='Amount Signed', currency_field='company_currency_id', + store=True, readonly=True, compute='_compute_price', + help="Total amount in the currency of the company, negative for credit notes.") + quantity = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), + required=True, default=1) + discount = fields.Float(string='Discount (%)', digits=dp.get_precision('Discount'), + default=0.0) + invoice_line_tax_ids = fields.Many2many('account.tax', + 'account_invoice_line_tax', 'invoice_line_id', 'tax_id', + string='Taxes', domain=[('type_tax_use','!=','none')], oldname='invoice_line_tax_id') + account_analytic_id = fields.Many2one('account.analytic.account', + string='Analytic Account') + company_id = fields.Many2one('res.company', string='Company', + related='invoice_id.company_id', store=True, readonly=True) + partner_id = fields.Many2one('res.partner', string='Partner', + related='invoice_id.partner_id', store=True, readonly=True) + currency_id = fields.Many2one('res.currency', related='invoice_id.currency_id', store=True) + company_currency_id = fields.Many2one('res.currency', related='invoice_id.company_currency_id', readonly=True) + + @api.model + def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + res = super(AccountInvoiceLine, self).fields_view_get( + view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + if self._context.get('type'): + doc = etree.XML(res['arch']) + for node in doc.xpath("//field[@name='product_id']"): + if self._context['type'] in ('in_invoice', 'in_refund'): + node.set('domain', "[('purchase_ok', '=', True)]") + else: + node.set('domain', "[('sale_ok', '=', True)]") + res['arch'] = etree.tostring(doc) + return res + + @api.v8 + def get_invoice_line_account(self, type, product, fpos, company): + accounts = product.product_tmpl_id.get_product_accounts(fpos) + if type in ('out_invoice', 'out_refund'): + return accounts['income'] + return accounts['expense'] + + def _set_taxes(self): + """ Used in on_change to set taxes """ + if self.invoice_id.type in ('out_invoice', 'out_refund'): + taxes = self.product_id.taxes_id or self.account_id.tax_ids + else: + taxes = self.product_id.supplier_taxes_id or self.account_id.tax_ids + self.invoice_line_tax_ids = self.invoice_id.fiscal_position_id.map_tax(taxes) + + @api.onchange('product_id') + def _onchange_product_id(self): + domain = {} + if not self.invoice_id: + return + + part = self.invoice_id.partner_id + fpos = self.invoice_id.fiscal_position_id + company = self.company_id + currency = self.invoice_id.currency_id + type = self.invoice_id.type + + if not part: + warning = { + 'title': _('Warning!'), + 'message': _('You must first select a partner!'), + } + return {'warning': warning} + + if not self.product_id: + if type not in ('in_invoice', 'in_refund'): + self.price_unit = 0.0 + domain['uos_id'] = [] + else: + if part.lang: + product = self.product_id.with_context(lang=part.lang) + else: + product = self.product_id + + self.name = product.partner_ref + account = self.get_invoice_line_account(type, product, fpos, company) + self.account_id = account.id + self._set_taxes() + + if type in ('in_invoice', 'in_refund'): + self.price_unit = self.price_unit or product.standard_price + if product.description_purchase: + self.name += '\n' + product.description_purchase + else: + self.price_unit = product.lst_price + if product.description_sale: + self.name += '\n' + product.description_sale + + if not self.uos_id or product.uom_id.category_id.id != self.uos_id.category_id.id: + self.uos_id = product.uom_id.id + domain['uos_id'] = [('category_id', '=', product.uom_id.category_id.id)] + + if company and currency: + if company.currency_id != currency: + if type in ('in_invoice', 'in_refund'): + self.price_unit = product.standard_price + self.price_unit = self.price_unit * currency.with_context(dict(self._context or {}, date=self.date_invoice)).rate + + if self.uos_id and self.uos_id.id != product.uom_id.id: + self.price_unit = self.env['product.uom']._compute_price( + product.uom_id.id, self.price_unit, self.uos_id.id) + return {'domain': domain} + + @api.onchange('account_id') + def _onchange_account_id(self): + if not self.account_id: + return + if not self.product_id: + fpos = self.invoice_id.fiscal_position_id + self.invoice_line_tax_ids = fpos.map_tax(self.account_id.tax_ids).ids + else: + self._set_taxes() + + @api.onchange('uos_id') + def _onchange_uos_id(self): + warning = {} + result = {} + self._onchange_product_id() + if not self.uos_id: + self.price_unit = 0.0 + if self.product_id and self.uos_id: + if self.product_id.uom_id.category_id.id != self.uos_id.category_id.id: + warning = { + 'title': _('Warning!'), + 'message': _('The selected unit of measure is not compatible with the unit of measure of the product.'), + } + self.uos_id = self.product_id.uom_id.id + if warning: + result['warning'] = warning + return result + + +class AccountInvoiceTax(models.Model): + _name = "account.invoice.tax" + _description = "Invoice Tax" + _order = 'sequence' + + invoice_id = fields.Many2one('account.invoice', string='Invoice', ondelete='cascade', index=True) + name = fields.Char(string='Tax Description', required=True) + tax_id = fields.Many2one('account.tax', string='Tax') + account_id = fields.Many2one('account.account', string='Tax Account', required=True, domain=[('deprecated', '=', False)]) + account_analytic_id = fields.Many2one('account.analytic.account', string='Analytic account') + amount = fields.Monetary() + manual = fields.Boolean(default=True) + sequence = fields.Integer(help="Gives the sequence order when displaying a list of invoice tax.") + company_id = fields.Many2one('res.company', string='Company', related='account_id.company_id', store=True, readonly=True) + currency_id = fields.Many2one('res.currency', related='invoice_id.currency_id', store=True, readonly=True) + + +class AccountPaymentTerm(models.Model): + _name = "account.payment.term" + _description = "Payment Term" + _order = "name" + + name = fields.Char(string='Payment Term', 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 term without removing it.") + note = fields.Text(string='Description', translate=True) + line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True) + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) + + @api.constrains('line_ids') + @api.one + def _check_lines(self): + if self.line_ids and self.line_ids[-1].value != 'balance': + raise UserError("A Payment Term should have its last line of type Balance") + + @api.one + def compute(self, value, date_ref=False): + date_ref = date_ref or datetime.now().strftime('%Y-%m-%d') + amount = value + result = [] + prec = self.company_id.currency_id.decimal_places + for line in self.line_ids: + if line.value == 'fixed': + amt = round(line.value_amount, prec) + elif line.value == 'percent': + amt = round(value * (line.value_amount / 100.0), prec) + elif line.value == 'balance': + amt = round(amount, prec) + if amt: + next_date = (datetime.strptime(date_ref, '%Y-%m-%d') + relativedelta(days=line.days)) + if line.days2 < 0: + next_first_date = next_date + relativedelta(day=1, months=1) # Getting 1st of next month + next_date = next_first_date + relativedelta(days=line.days2) + if line.days2 > 0: + next_date += relativedelta(day=line.days2, months=1) + result.append((next_date.strftime('%Y-%m-%d'), amt)) + amount -= amt + + amount = reduce(lambda x, y: x + y[1], result, 0.0) + dist = round(value - amount, prec) + if dist: + last_date = result and result[-1][0] or fields.Date.today() + result.append((last_date, dist)) + return result + + +class AccountPaymentTermLine(models.Model): + _name = "account.payment.term.line" + _description = "Payment Term Line" + _order = "days" + + value = fields.Selection([ + ('balance', 'Balance'), + ('percent', 'Percent'), + ('fixed', 'Fixed Amount') + ], string='Computation', required=True, default='balance', + help="Select here the kind of valuation related to this payment term line.") + value_amount = fields.Float(string='Amount To Pay', digits=dp.get_precision('Payment Term'), help="For percent enter a ratio between 0-100.") + days = fields.Integer(string='Number of Days', required=True, default=30, help="Number of days to add before computing the day of the month.") + days2 = fields.Integer(string='Day of the Month', required=True, default='0', + help="Day of the month \n\n Set : \n1)-1 for the last day of the current month. \n2) 0 for net days\n3) A positive number for the specific day of the next month.\n\nExample : if Date=15/01, Number of Days=22, Day of Month=-1, then the due date is 28/02.") + payment_id = fields.Many2one('account.payment.term', string='Payment Term', required=True, index=True, ondelete='cascade') + + @api.one + @api.constrains('value', 'value_amount') + def _check_percent(self): + if self.value == 'percent' and (self.value_amount < 0.0 or self.value_amount > 100.0): + raise UserError(_('Percentages for Payment Term Line must be between 0 and 100.')) + + +class MailComposeMessage(models.Model): + _inherit = 'mail.compose.message' + + @api.multi + def send_mail(self): + context = self._context + if context.get('default_model') == 'account.invoice' and \ + context.get('default_res_id') and context.get('mark_invoice_as_sent'): + invoice = self.env['account.invoice'].browse(context['default_res_id']) + invoice = invoice.with_context(mail_post_autofollow=True) + invoice.write({'sent': True}) + invoice.message_post(body=_("Invoice sent")) + return super(MailComposeMessage, self).send_mail() diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py new file mode 100644 index 00000000000..0f4d3f0eeb5 --- /dev/null +++ b/addons/account/models/account_journal_dashboard.py @@ -0,0 +1,351 @@ +import json +from datetime import datetime, timedelta + +from babel.dates import format_datetime, format_date + +from openerp import models, api, _, fields +from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DF +from openerp.tools.misc import formatLang + +class account_journal(models.Model): + _inherit = "account.journal" + + @api.one + def _kanban_dashboard(self): + self.kanban_dashboard = json.dumps(self.get_journal_dashboard_datas()) + + @api.one + def _kanban_dashboard_graph(self): + if (self.type in ['sale', 'purchase']): + self.kanban_dashboard_graph = json.dumps(self.get_bar_graph_datas()) + elif (self.type in ['cash', 'bank']): + self.kanban_dashboard_graph = json.dumps(self.get_line_graph_datas()) + + kanban_dashboard = fields.Text(compute='_kanban_dashboard') + kanban_dashboard_graph = fields.Text(compute='_kanban_dashboard_graph') + show_on_dashboard = fields.Boolean(string='Show journal on dashboard', help="Whether this journal should be displayed on the dashboard or not", default=True) + + @api.multi + def toggle_favorite(self): + self.write({'show_on_dashboard': False if self.show_on_dashboard else True}) + return False + + @api.multi + def get_line_graph_datas(self): + data = [] + today = datetime.today() + last_month = today + timedelta(days=-30) + bank_stmt = [] + # Query to optimize loading of data for bank statement graphs + # Return a list containing the latest bank statement balance per day for the + # last 30 days for current journal + query = """SELECT a.date, a.balance_end + FROM account_bank_statement AS a, + (SELECT c.date, max(c.id) AS stmt_id + FROM account_bank_statement AS c + WHERE c.journal_id = %s + AND c.date > %s + AND c.date <= %s + GROUP BY date, id + ORDER BY date, id) AS b + WHERE a.id = b.stmt_id;""" + + self.env.cr.execute(query, (self.id, last_month, today)) + bank_stmt = self.env.cr.dictfetchall() + + last_bank_stmt = self.env['account.bank.statement'].search([('journal_id', 'in', self.ids),('date', '<=', last_month.strftime(DF))], order="date desc, id desc", limit=1) + start_balance = last_bank_stmt and last_bank_stmt[0].balance_end or 0 + + locale = self._context.get('lang', 'en_US') + show_date = last_month + #get date in locale format + name = format_date(show_date, 'd LLLL Y', locale=locale) + short_name = format_date(show_date, 'd MMM', locale=locale) + data.append({'x':short_name,'y':start_balance, 'name':name}) + + for stmt in bank_stmt: + #fill the gap between last data and the new one + number_day_to_add = (datetime.strptime(stmt.get('date'), DF) - show_date).days + last_balance = data[len(data) - 1]['y'] + for day in range(0,number_day_to_add + 1): + show_date = show_date + timedelta(days=1) + #get date in locale format + name = format_date(show_date, 'd LLLL Y', locale=locale) + short_name = format_date(show_date, 'd MMM', locale=locale) + data.append({'x': short_name, 'y':last_balance, 'name': name}) + #add new stmt value + data[len(data) - 1]['y'] = stmt.get('balance_end') + + #continue the graph if the last statement isn't today + if show_date != today: + number_day_to_add = (today - show_date).days + last_balance = data[len(data) - 1]['y'] + for day in range(0,number_day_to_add): + show_date = show_date + timedelta(days=1) + #get date in locale format + name = format_date(show_date, 'd LLLL Y', locale=locale) + short_name = format_date(show_date, 'd MMM', locale=locale) + data.append({'x': short_name, 'y':last_balance, 'name': name}) + + return [{'values': data, 'color': '#ff7f0e', 'area': True}] + + @api.multi + def get_bar_graph_datas(self): + data = [] + title = _('Invoices owed to you') + if self.type == 'purchase': + title = _('Bills you need to pay') + today = datetime.strptime(fields.Date.context_today(self), DF) + data.append({'label': _('Past'), 'value':0.0, 'color': '#ff0000'}) + day_of_week = int(format_datetime(today, 'e', locale=self._context.get('lang', 'en_US'))) + 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 = _('Future') + 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=self._context.get('lang', 'en_US')) + else: + label = format_date(start_week, 'd MMM', locale=self._context.get('lang', 'en_US'))+'-'+format_date(end_week, 'd MMM', locale=self._context.get('lang', 'en_US')) + data.append({'label':label,'value':0.0, 'color': '#ff0000' if i<0 else '#ff7f0e'}) + + # Build SQL query to find amount aggregated by week + select_sql_clause = """SELECT sum(residual_signed) as total, min(date) as aggr_date from account_invoice where journal_id = %(journal_id)s and state = 'open'""" + query = '' + start_date = (first_day_of_week + timedelta(days=-7)) + for i in range(0,6): + if i == 0: + query += "("+select_sql_clause+" and date < '"+start_date.strftime(DF)+"')" + elif i == 6: + query += " UNION ALL ("+select_sql_clause+" and date >= '"+start_date.strftime(DF)+"')" + else: + next_date = start_date + timedelta(days=7) + query += " UNION ALL ("+select_sql_clause+" and date >= '"+start_date.strftime(DF)+"' and date < '"+next_date.strftime(DF)+"')" + start_date = next_date + + self.env.cr.execute(query, {'journal_id':self.id}) + query_results = self.env.cr.dictfetchall() + for index in range(0, len(query_results)): + if query_results[index].get('aggr_date') != None: + data[index]['value'] = query_results[index].get('total') + else: + data[index]['color'] = '#ffffff' + + return [{'values': data, 'title': title}] + + @api.multi + def get_journal_dashboard_datas(self): + number_to_reconcile = last_balance = account_sum = 0 + ac_bnk_stmt = [] + number_draft = number_waiting = number_late = sum_draft = sum_waiting = sum_late = 0 + if self.type in ['bank', 'cash']: + last_bank_stmt = self.env['account.bank.statement'].search([('journal_id', 'in', self.ids)], order="date desc, id desc", limit=1) + last_balance = last_bank_stmt and last_bank_stmt[0].balance_end or 0 + ac_bnk_stmt = self.env['account.bank.statement'].search([('journal_id', 'in', self.ids),('state', '=', 'open')]) + for ac_bnk in ac_bnk_stmt: + for line in ac_bnk.line_ids: + if not line.journal_entry_ids: + number_to_reconcile += 1 + # optimization to read sum of balance from account_move_line + account_ids = tuple(filter(None, [self.default_debit_account_id.id, self.default_credit_account_id.id])) + if account_ids: + query = """SELECT sum(balance) FROM account_move_line WHERE account_id in %s;""" + self.env.cr.execute(query, (account_ids,)) + query_results = self.env.cr.dictfetchall() + if query_results and query_results[0].get('sum') != None: + account_sum = query_results[0].get('sum') + #TODO need to check if all invoices are in the same currency than the journal!!!! + elif self.type in ['sale', 'purchase']: + # optimization to find total and sum of invoice that are in draft, open state + query = """SELECT state, count(id) AS count, sum(amount_total) AS total FROM account_invoice WHERE journal_id = %s AND state NOT IN ('paid', 'cancel') GROUP BY state;""" + self.env.cr.execute(query, (self.id,)) + query_results = self.env.cr.dictfetchall() + today = datetime.today() + query = """SELECT count(id) AS count_late, sum(amount_total) AS total FROM account_invoice WHERE journal_id = %s AND date < %s AND state = 'open';""" + self.env.cr.execute(query, (self.id, today)) + late_query_results = self.env.cr.dictfetchall() + for result in query_results: + if result.get('state') in ['draft', 'proforma', 'proforma2']: + number_draft = result.get('count') + sum_draft = result.get('total') + elif result.get('state') == 'open': + number_waiting = result.get('count') + sum_waiting = result.get('total') + if late_query_results and late_query_results[0].get('count_late') != None: + number_late = late_query_results[0].get('count_late') + sum_late = late_query_results[0].get('total') + + return { + 'number_to_reconcile': number_to_reconcile, + 'account_balance': formatLang(self.env, account_sum, currency_obj=self.currency_id or self.company_id.currency_id), + 'last_balance': formatLang(self.env, last_balance, currency_obj=self.currency_id or self.company_id.currency_id), + 'number_draft': number_draft, + 'number_waiting': number_waiting, + 'number_late': number_late, + 'sum_draft': formatLang(self.env, sum_draft or 0.0, currency_obj=self.currency_id or self.company_id.currency_id), + 'sum_waiting': formatLang(self.env, sum_waiting or 0.0, currency_obj=self.currency_id or self.company_id.currency_id), + 'sum_late': formatLang(self.env, sum_late or 0.0, currency_obj=self.currency_id or self.company_id.currency_id), + 'currency_id': self.currency_id and self.currency_id.id or self.company_id.currency_id.id, + 'show_import': True if self.type in ['bank', 'cash'] and len(ac_bnk_stmt) == 0 and last_balance == 0 else False, + } + + @api.multi + def action_create_new(self): + ctx = self._context.copy() + model = 'account.invoice' + if self.type == 'sale': + ctx.update({'journal_type': self.type, 'default_type': 'out_invoice', 'type': 'out_invoice', 'default_journal_id': self.id}) + if ctx.get('refund'): + ctx.update({'default_type':'out_refund', 'type':'out_refund'}) + view_id = self.env.ref('account.invoice_form').id + elif self.type == 'purchase': + ctx.update({'journal_type': self.type, 'default_type': 'in_invoice', 'type': 'in_invoice', 'default_journal_id': self.id}) + if ctx.get('refund'): + ctx.update({'default_type': 'in_refund', 'type': 'in_refund'}) + view_id = self.env.ref('account.invoice_supplier_form').id + else: + ctx.update({'default_journal_id': self.id}) + view_id = self.env.ref('account.view_move_form').id + model = 'account.move' + return { + 'name': _('Create invoice/bill'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': model, + 'view_id': view_id, + 'context': ctx, + } + + @api.multi + def create_cash_bank(self): + ctx = self._context.copy() + ctx.update({'journal_id': self.id, 'default_journal_id': self.id, 'default_journal_type': 'cash'}) + return { + 'name': _('Create cash statement'), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': 'account.bank.statement', + 'context': ctx, + } + + @api.multi + def action_open_reconcile(self): + if self.type in ['bank', 'cash']: + # Open reconciliation view for bank statements belonging to this journal + bank_stmt = self.env['account.bank.statement'].search([('journal_id', 'in', self.ids)]) + return { + 'type': 'ir.actions.client', + 'tag': 'bank_statement_reconciliation_view', + 'context': {'statement_ids': bank_stmt.ids}, + } + else: + # Open reconciliation view for customers/suppliers + action_context = {'show_mode_selector': False} + if self.type == 'sale': + action_context.update({'mode': 'customers'}) + elif self.type == 'purchase': + action_context.update({'mode': 'suppliers'}) + return { + 'type': 'ir.actions.client', + 'tag': 'manual_reconciliation_view', + 'context': action_context, + } + + @api.multi + def open_action(self): + """return action based on type for related journals""" + action_name = self._context.get('action_name', False) + if not action_name: + if self.type == 'bank': + action_name = 'action_bank_statement_tree' + elif self.type == 'cash': + action_name = 'action_view_bank_statement_tree' + elif self.type == 'sale': + action_name = 'action_invoice_tree1' + elif self.type == 'purchase': + action_name = 'action_invoice_tree2' + else: + action_name = 'action_move_journal_line' + + _journal_invoice_type_map = { + 'sale': 'out_invoice', + 'purchase': 'in_invoice', + 'bank': 'bank', + 'cash': 'cash', + 'general': 'general', + } + invoice_type = _journal_invoice_type_map[self.type] + + ctx = self._context.copy() + ctx.update({ + 'journal_type': self.type, + 'default_journal_id': self.id, + 'search_default_journal_id': self.id, + 'default_type': invoice_type, + 'type': invoice_type + }) + ir_model_obj = self.pool['ir.model.data'] + model, action_id = ir_model_obj.get_object_reference(self._cr, self._uid, 'account', action_name) + action = self.pool[model].read(self._cr, self._uid, action_id, context=self._context) + action['context'] = ctx + return action + + @api.multi + def open_spend_money(self): + return self.open_payments_action('outbound') + + @api.multi + def open_collect_money(self): + return self.open_payments_action('inbound') + + @api.multi + def open_transfer_money(self): + return self.open_payments_action('transfer') + + @api.multi + def open_payments_action(self, payment_type): + ctx = self._context.copy() + ctx.update({ + 'default_payment_type': payment_type, + 'default_journal_id': self.id + }) + action_rec = self.env['ir.model.data'].xmlid_to_object('account.action_account_payments') + if action_rec: + action = action_rec.read([])[0] + action['context'] = ctx + return action + + @api.multi + 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) + ir_model_obj = self.pool['ir.model.data'] + model, action_id = ir_model_obj.get_object_reference(self._cr, self._uid, 'account', action_name) + action = self.pool[model].read(self._cr, self._uid, action_id, context=self._context) + action['context'] = ctx + if ctx.get('use_domain', False): + action['domain'] = ['|', ('journal_id', '=', self.id), ('journal_id', '=', False)] + action['name'] += ' for journal '+self.name + return action + + @api.multi + def import_statement(self): + """return action to import bank/cash statements. This button should be called only on journals with type =='bank'""" + model = 'account.bank.statement' + action_name = 'action_account_bank_statement_import' + ir_model_obj = self.pool['ir.model.data'] + model, action_id = ir_model_obj.get_object_reference(self._cr, self._uid, 'account_bank_statement_import', action_name) + action = self.pool[model].read(self._cr, self._uid, action_id, context=self.env.context) + # Note: this drops action['context'], which is a dict stored as a string, which is not easy to update + action.update({'context': (u"{'journal_id': " + str(self.id) + u"}")}) + return action diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py new file mode 100644 index 00000000000..4a7554c2f54 --- /dev/null +++ b/addons/account/models/account_move.py @@ -0,0 +1,1195 @@ +# -*- coding: utf-8 -*- + +import time +from openerp import api, fields, models, _ +from openerp.osv import expression +from openerp.exceptions import RedirectWarning, UserError +from openerp.tools.misc import formatLang +from openerp.tools import float_is_zero +from openerp.tools.safe_eval import safe_eval + + +#---------------------------------------------------------- +# Entries +#---------------------------------------------------------- + + +class AccountMove(models.Model): + _name = "account.move" + _description = "Account Entry" + _order = 'date desc, id desc' + + @api.multi + @api.depends('name', 'state') + def name_get(self): + result = [] + for move in self: + if move.state == 'draft': + name = '* ' + str(move.id) + else: + name = move.name + result.append((move.id, name)) + return result + + @api.multi + @api.depends('line_ids.debit', 'line_ids.credit') + def _amount_compute(self): + for move in self: + total = 0.0 + for line in move.line_ids: + total += line.debit + move.amount = total + + @api.depends('line_ids.debit', 'line_ids.credit', 'line_ids.matched_debit_ids.amount', 'line_ids.matched_credit_ids.amount', 'line_ids.account_id.user_type_id.type') + def _compute_matched_percentage(self): + """Compute the percentage to apply for cash basis method. This value is relevant only for moves that + involve journal items on receivable or payable accounts. + """ + for move in self: + total_amount = 0.0 + total_reconciled = 0.0 + for line in move.line_ids: + if line.account_id.user_type_id.type in ('receivable', 'payable'): + amount = abs(line.debit - line.credit) + total_amount += amount + for partial_line in (line.matched_debit_ids + line.matched_credit_ids): + total_reconciled += partial_line.amount + if total_amount == 0.0: + move.matched_percentage = 100.0 + else: + move.matched_percentage = total_reconciled / total_amount + + @api.one + @api.depends('company_id') + def _compute_currency(self): + self.currency_id = self.company_id.currency_id or self.env.user.company_id.currency_id + + name = fields.Char(string='Number', required=True, copy=False, default='/') + ref = fields.Char(string='Partner Reference', copy=False) + date = fields.Date(required=True, states={'posted': [('readonly', True)]}, index=True, default=fields.Date.context_today) + journal_id = fields.Many2one('account.journal', string='Journal', required=True, states={'posted': [('readonly', True)]}) + currency_id = fields.Many2one('res.currency', compute='_compute_currency', store=True) + rate_diff_partial_rec_id = fields.Many2one('account.partial.reconcile', string='Exchange Rate Entry of', help="Technical field used to keep track of the origin of journal entries created in case of fluctuation of the currency exchange rate. This is needed when cancelling the source: it will post the inverse journal entry to cancel that part too.") + state = fields.Selection([('draft', 'Unposted'), ('posted', 'Posted')], string='Status', + required=True, readonly=True, copy=False, default='draft', + help='All manually created new journal entries are usually in the status \'Unposted\', ' + 'but you can set the option to skip that status on the related journal. ' + 'In that case, they will behave as journal entries automatically created by the ' + 'system on document validation (invoices, bank statements...) and will be created ' + 'in \'Posted\' status.') + line_ids = fields.One2many('account.move.line', 'move_id', string='Journal Items', + states={'posted': [('readonly', True)]}, copy=True) + partner_id = fields.Many2one('res.partner', related='line_ids.partner_id', string="Partner", store=True, states={'posted': [('readonly', True)]}) + amount = fields.Monetary(compute='_amount_compute', store=True) + narration = fields.Text(string='Internal Note') + company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', store=True, readonly=True, + default=lambda self: self.env.user.company_id) + matched_percentage = fields.Float('Percentage Matched', compute='_compute_matched_percentage', digits=0, store=True, readonly=True, help="Technical field used in cash basis method") + statement_line_id = fields.Many2one('account.bank.statement.line', string='Bank statement line reconciled with this entry', copy=False, readonly=True) + to_check = fields.Boolean('To Review', help='Check this box if you are unsure of that journal entry and if you want to note it as \'to be reviewed\' by an accounting expert.') + + @api.model + def create(self, vals): + move = super(AccountMove, self.with_context(check_move_validity=False)).create(vals) + move.assert_balanced() + return move + + @api.multi + def write(self, vals): + if 'line_ids' in vals: + res = super(AccountMove, self.with_context(check_move_validity=False)).write(vals) + self.assert_balanced() + else: + res = super(AccountMove, self).write(vals) + return res + + @api.multi + def post(self): + invoice = self._context.get('invoice', False) + self._post_validate() + + for move in self: + move.line_ids.create_analytic_lines() + if move.name == '/': + new_name = False + journal = move.journal_id + + if invoice and invoice.move_name: + new_name = invoice.move_name + else: + if journal.sequence_id: + # If invoice is actually refund and journal has a refund_sequence then use that one or use the regular one + sequence = journal.sequence_id + if invoice and invoice.type in ['out_refund', 'in_refund'] and journal.refund_sequence: + sequence = journal.refund_sequence_id + new_name = sequence.with_context(ir_sequence_date=move.date).next_by_id() + else: + raise UserError(_('Please define a sequence on the journal.')) + + if new_name: + move.name = new_name + return self.write({'state': 'posted'}) + + @api.multi + def button_cancel(self): + for move in self: + if not move.journal_id.update_posted: + raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.')) + if self.ids: + self._cr.execute('UPDATE account_move '\ + 'SET state=%s '\ + 'WHERE id IN %s', ('draft', tuple(self.ids),)) + self.invalidate_cache() + return True + + @api.multi + def unlink(self): + for move in self: + #check the lock date + check if some entries are reconciled + move.line_ids._update_check() + move.line_ids.unlink() + return super(AccountMove, self).unlink() + + @api.multi + def _post_validate(self): + for move in self: + if move.line_ids: + if not all([x.company_id.id == move.company_id.id for x in move.line_ids]): + raise UserError(_("Cannot create moves for different companies.")) + self.assert_balanced() + return self._check_lock_date() + + @api.multi + def _check_lock_date(self): + for move in self: + lock_date = max(move.company_id.period_lock_date, move.company_id.fiscalyear_lock_date) + if self.user_has_groups('account.group_account_manager'): + lock_date = move.company_id.fiscalyear_lock_date + if move.date <= lock_date: + raise UserError(_("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" % (lock_date))) + return True + + @api.multi + def assert_balanced(self): + if not self.ids: + return True + self._cr.execute("""\ + SELECT move_id + FROM account_move_line + WHERE move_id in %s + GROUP BY move_id + HAVING abs(sum(debit) - sum(credit)) > 0.00001 + """, (tuple(self.ids),)) + if len(self._cr.fetchall()) != 0: + raise UserError(_("Cannot create unbalanced journal entry.")) + return True + + @api.multi + def reverse_moves(self, date=None, journal_id=None): + date = date or fields.Date.today() + for ac_move in self: + reversed_move = ac_move.copy(default={'date': date, + 'journal_id': journal_id.id if journal_id else ac_move.journal_id.id, + 'ref': _('reversal of: ') + ac_move.name}) + for acm_line in reversed_move.line_ids: + acm_line.with_context(check_move_validity=False).write({ + 'debit': acm_line.credit, + 'credit': acm_line.debit, + 'amount_currency': -acm_line.amount_currency + }) + reversed_move._post_validate() + reversed_move.post() + return True + + +class AccountMoveLine(models.Model): + _name = "account.move.line" + _description = "Journal Item" + _order = "date desc, id desc" + + @api.depends('debit', 'credit', 'amount_currency', 'currency_id', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'account_id.currency_id') + def _amount_residual(self): + """ Computes the residual amount of a move line from a reconciliable account in the company currency and the line's currency. + This amount will be 0 for fully reconciled lines or lines from a non-reconciliable account, the original line amount + for unreconciled lines, and something in-between for partially reconciled lines. + """ + for line in self: + #amounts in the partial reconcile table aren't signed, so we need to use abs() + amount = abs(line.debit - line.credit) + amount_residual_currency = abs(line.amount_currency) or 0.0 + sign = 1 if (line.debit - line.credit) > 0 else -1 + + for partial_line in (line.matched_debit_ids + line.matched_credit_ids): + amount -= partial_line.amount + #getting the date of the matched item to compute the amount_residual in currency + date = partial_line.credit_move_id.date if partial_line.debit_move_id == line else partial_line.debit_move_id.date + if line.currency_id: + if partial_line.currency_id and partial_line.currency_id == line.currency_id: + amount_residual_currency -= partial_line.amount_currency + else: + amount_residual_currency -= line.company_id.currency_id.with_context(date=date).compute(partial_line.amount, line.currency_id) + + #computing the `reconciled` field. As we book exchange rate difference on each partial matching, + #we can only check the amount in company currency + reconciled = False + digits_rounding_precision = line.company_id.currency_id.rounding + if float_is_zero(amount, digits_rounding_precision): + reconciled = True + line.reconciled = reconciled + + line.amount_residual = line.company_id.currency_id.round(amount * sign) + line.amount_residual_currency = line.currency_id and line.currency_id.round(amount_residual_currency * sign) or 0.0 + + @api.depends('debit', 'credit') + def _store_balance(self): + for line in self: + line.balance = line.debit - line.credit + + @api.model + def _get_currency(self): + currency = False + context = self._context or {} + if context.get('default_journal_id', False): + currency = self.env['account.journal'].browse(context['default_journal_id']).currency_id + return currency + + @api.model + def _get_journal(self): + """ Return journal based on the journal type """ + context = dict(self._context or {}) + journal_id = context.get('journal_id', False) + if journal_id: + return journal_id + + journal_type = context.get('journal_type', False) + if journal_type: + recs = self.env['account.journal'].search([('type', '=', journal_type)]) + if not recs: + action = self.env.ref('account.action_account_journal_form') + msg = _("""Cannot find any account journal of "%s" type for this company, You should create one.\n Please go to Journal Configuration""") % journal_type.replace('_', ' ').title() + raise RedirectWarning(msg, action.id, _('Go to the configuration panel')) + journal_id = recs[0].id + return journal_id + + @api.depends('debit', 'credit', 'move_id.matched_percentage', 'move_id.journal_id') + def _compute_cash_basis(self): + for move_line in self: + if move_line.journal_id.type in ('sale', 'purchase'): + move_line.debit_cash_basis = move_line.debit * move_line.move_id.matched_percentage + move_line.credit_cash_basis = move_line.credit * move_line.move_id.matched_percentage + else: + move_line.debit_cash_basis = move_line.debit + move_line.credit_cash_basis = move_line.credit + move_line.balance_cash_basis = move_line.debit_cash_basis - move_line.credit_cash_basis + + @api.one + @api.depends('move_id.line_ids') + def _get_counterpart(self): + counterpart = set() + for line in self.move_id.line_ids: + if (line.account_id.code != self.account_id.code): + counterpart.add(line.account_id.code) + if len(counterpart) > 2: + counterpart = counterpart[0:2] + ["..."] + self.counterpart = ",".join(counterpart) + + name = fields.Char(required=True, string="Label") + quantity = fields.Float(digits=(16, 2), + 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.") + product_uom_id = fields.Many2one('product.uom', string='Unit of Measure') + product_id = fields.Many2one('product.product', string='Product') + debit = fields.Monetary(default=0.0, currency_field='company_currency_id') + credit = fields.Monetary(default=0.0, currency_field='company_currency_id') + balance = fields.Monetary(compute='_store_balance', store=True, currency_field='company_currency_id', default=0.0, help="Technical field holding the debit - credit in order to open meaningful graph views from reports") + debit_cash_basis = fields.Monetary(currency_field='company_currency_id', default=0.0, compute='_compute_cash_basis', store=True) + credit_cash_basis = fields.Monetary(currency_field='company_currency_id', default=0.0, compute='_compute_cash_basis', store=True) + balance_cash_basis = fields.Monetary(compute='_compute_cash_basis', store=True, currency_field='company_currency_id', default=0.0, help="Technical field holding the debit_cash_basis - credit_cash_basis in order to open meaningful graph views from reports") + amount_currency = fields.Monetary(default=0.0, help="The amount expressed in an optional other currency if it is a multi-currency entry.") + company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True, + help='Utility field to express amount currency', store=True) + currency_id = fields.Many2one('res.currency', string='Currency', default=_get_currency, + help="The optional other currency if it is a multi-currency entry.") + amount_residual = fields.Monetary(compute='_amount_residual', string='Residual Amount', store=True, currency_field='company_currency_id', + help="The residual amount on a journal item expressed in the company currency.") + amount_residual_currency = fields.Monetary(compute='_amount_residual', string='Residual Amount in Currency', store=True, + help="The residual amount on a journal item expressed in its currency (possibly not the company currency).") + account_id = fields.Many2one('account.account', string='Account', required=True, index=True, + ondelete="cascade", domain=[('deprecated', '=', False)], default=lambda self: self._context.get('account_id', False)) + move_id = fields.Many2one('account.move', string='Journal Entry', ondelete="cascade", + help="The move of this entry line.", index=True, required=True, auto_join=True) + narration = fields.Text(related='move_id.narration', string='Internal Note') + ref = fields.Char(related='move_id.ref', string='Partner Reference', store=True, copy=False) + payment_id = fields.Many2one('account.payment', string="Originator Payment", help="Payment that created this entry") + statement_id = fields.Many2one('account.bank.statement', string='Statement', + help="The bank statement used for bank reconciliation", index=True, copy=False) + reconciled = fields.Boolean(compute='_amount_residual', store=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.') + 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.') + journal_id = fields.Many2one('account.journal', related='move_id.journal_id', string='Journal', + default=_get_journal, required=True, index=True, store=True, copy=False) + 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, required=True, + help="This field is used for payable and receivable journal entries. You can put the limit date for the payment of this line.") + date = fields.Date(related='move_id.date', string='Date', required=True, index=True, default=fields.Date.context_today, store=True, copy=False) + analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines") + tax_ids = fields.Many2many('account.tax', string='Taxes', copy=False, readonly=True) + tax_line_id = fields.Many2one('account.tax', string='Originator tax', copy=False, readonly=True) + analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account') + company_id = fields.Many2one('res.company', related='account_id.company_id', string='Company', store=True) + counterpart = fields.Char("Counterpart", compute='_get_counterpart', help="Compute the counter part accounts of this journal item for this journal entry. This can be needed in reports.") + + # TODO: put the invoice link and partner_id on the account_move + invoice_id = fields.Many2one('account.invoice', oldname="invoice") + partner_id = fields.Many2one('res.partner', string='Partner', index=True, ondelete='restrict') + user_type_id = fields.Many2one('account.account.type', related='account_id.user_type_id', index=True, store=True, oldname="user_type") + + _sql_constraints = [ + ('credit_debit1', 'CHECK (credit*debit=0)', 'Wrong credit or debit value in accounting entry !'), + ('credit_debit2', 'CHECK (credit+debit>=0)', 'Wrong credit or debit value in accounting entry !'), + ] + + @api.multi + @api.constrains('currency_id', 'account_id') + def _check_currency(self): + for line in self: + if line.account_id.currency_id: + if not line.currency_id or line.currency_id.id != line.account_id.currency_id.id: + raise UserError(_('The selected account of your Journal Entry forces to provide a secondary currency. You should remove the secondary currency on the account or select a multi-currency view on the journal.')) + + @api.multi + @api.constrains('currency_id', 'amount_currency') + def _check_currency_and_amount(self): + for line in self: + if (line.amount_currency and not line.currency_id): + raise UserError(_("You cannot create journal items with a secondary currency without filling both 'currency' and 'amount currency' field.")) + + @api.multi + @api.constrains('amount_currency') + def _check_currency_amount(self): + for line in self: + if line.amount_currency: + if (line.amount_currency > 0.0 and line.credit > 0.0) or (line.amount_currency < 0.0 and line.debit > 0.0): + raise UserError(_('The amount expressed in the secondary currency must be positive when account is debited and negative when account is credited.')) + + #################################################### + # Reconciliation interface methods + #################################################### + + @api.model + def get_data_for_manual_reconciliation_widget(self, partner_ids, account_ids): + """ Returns the data required for the invoices & payments matching of partners/accounts. + If an argument is None, fetch all related reconciliations. Use [] to fetch nothing. + """ + return { + 'customers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'receivable'), + 'suppliers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'payable'), + 'accounts': self.get_data_for_manual_reconciliation('account', account_ids), + } + + @api.model + def get_data_for_manual_reconciliation(self, res_type, res_ids=None, account_type=None): + """ Returns the data required for the invoices & payments matching of partners/accounts (list of dicts). + If no res_ids is passed, returns data for all partners/accounts that can be reconciled. + + :param res_type: either 'partner' or 'account' + :param res_ids: ids of the partners/accounts to reconcile, use None to fetch data indiscriminately + of the id, use [] to prevent from fetching any data at all. + :param account_type: if a partner is both customer and supplier, you can use 'payable' to reconcile + the supplier-related journal entries and 'receivable' for the customer-related entries. + """ + if res_ids != None and len(res_ids) == 0: + # Note : this short-circuiting is better for performances, but also required + # since postgresql doesn't implement empty list (so 'AND id in ()' is useless) + return [] + + is_partner = res_type == 'partner' + res_alias = is_partner and 'p' or 'a' + self.env.cr.execute( + """ SELECT %s account_id, account_name, account_code, max_date, to_char(last_time_entries_checked, 'YYYY-MM-DD') AS last_time_entries_checked FROM ( + SELECT %s + %s.last_time_entries_checked AS last_time_entries_checked, + a.id AS account_id, + a.name AS account_name, + a.code AS account_code, + 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 account_account_type at ON (at.id = a.user_type_id) + %s + WHERE + a.reconcile IS TRUE + %s + %s + %s + AND EXISTS ( + SELECT NULL + FROM account_move_line l + WHERE l.account_id = a.id + %s + AND l.amount_residual > 0 + ) + AND EXISTS ( + SELECT NULL + FROM account_move_line l + WHERE l.account_id = a.id + %s + AND l.amount_residual < 0 + ) + GROUP BY %s a.id, a.name, a.code, %s.last_time_entries_checked + ORDER BY %s.last_time_entries_checked + ) as s + WHERE (last_time_entries_checked IS NULL OR max_date > last_time_entries_checked) + """ % ( + is_partner and 'partner_id, partner_name,' or ' ', + is_partner and 'p.id AS partner_id, p.name AS partner_name,' or ' ', + res_alias, + is_partner and 'RIGHT JOIN res_partner p ON (l.partner_id = p.id)' or ' ', + is_partner and ' ' or "AND at.type <> 'payable' AND at.type <> 'receivable'", + account_type and "AND at.type = '" + account_type + "'" or '', + res_ids and 'AND ' + res_alias + '.id in (' + ','.join(map(str, res_ids)) + ')' or '', + is_partner and 'AND l.partner_id = p.id' or ' ', + is_partner and 'AND l.partner_id = p.id' or ' ', + is_partner and 'l.partner_id, p.id,' or ' ', + res_alias, + res_alias, + )) + + # Apply ir_rules by filtering out + rows = self.env.cr.dictfetchall() + ids = [x['account_id'] for x in rows] + allowed_ids = set(self.env['account.account'].browse(ids).ids) + rows = [row for row in rows if row['account_id'] in allowed_ids] + if is_partner: + ids = [x['partner_id'] for x in rows] + allowed_ids = set(self.env['res.partner'].browse(ids).ids) + rows = [row for row in rows if row['partner_id'] in allowed_ids] + + # Fetch other data + for row in rows: + account = self.env['account.account'].browse(row['account_id']) + row['currency_id'] = account.currency_id.id or account.company_id.currency_id.id + partner_id = is_partner and row['partner_id'] or None + row['reconciliation_proposition'] = self.get_reconciliation_proposition(account.id, partner_id) + return rows + + @api.model + def get_reconciliation_proposition(self, account_id, partner_id=False): + """ Returns two lines whose amount are opposite """ + + # Get pairs + partner_id_condition = partner_id and 'AND a.partner_id = %d AND b.partner_id = %d' % (partner_id, partner_id) or '' + self.env.cr.execute( + """ SELECT a.id, b.id + FROM account_move_line a, account_move_line b + WHERE a.amount_residual = -b.amount_residual + AND NOT a.reconciled AND NOT b.reconciled + AND a.account_id = %d AND b.account_id = %d + %s + ORDER BY a.date asc + LIMIT 10 + """ % (account_id, account_id, partner_id_condition)) + pairs = self.env.cr.fetchall() + + # Apply ir_rules by filtering out + all_pair_ids = [element for tupl in pairs for element in tupl] + allowed_ids = set(self.env['account.move.line'].browse(all_pair_ids).ids) + pairs = [pair for pair in pairs if pair[0] in allowed_ids and pair[1] in allowed_ids] + + # Return lines formatted + if len(pairs) > 0: + target_currency = self.currency_id or self.company_id.currency_id + lines = self.browse(list(pairs[0])) + return lines.prepare_move_lines_for_reconciliation_widget(target_currency=target_currency) + return [] + + @api.model + def domain_move_lines_for_reconciliation(self, excluded_ids=None, str=False): + """ Returns the domain which is common to both manual and bank statement reconciliation. + + :param excluded_ids: list of ids of move lines that should not be fetched + :param str: search string + """ + context = (self._context or {}) + if excluded_ids is None: + excluded_ids = [] + domain = [] + + if excluded_ids: + domain = expression.AND([domain, [('id', 'not in', excluded_ids)]]) + if str: + str_domain = [ + '|', ('move_id.name', 'ilike', str), + '|', ('move_id.ref', 'ilike', str), + '|', ('date_maturity', 'like', str), + '&', ('name', '!=', '/'), ('name', 'ilike', str) + ] + try: + amount = float(str) + amount_domain = ['|', ('amount_residual', '=', amount), '|', ('amount_residual_currency', '=', amount), '|', ('amount_residual', '=', -amount), ('amount_residual_currency', '=', -amount)] + str_domain = expression.OR([str_domain, amount_domain]) + except: + pass + + # When building a domain for the bank statement reconciliation, if there's no partner + # and a search string, search also a match in the partner names + if 'bank_statement_line' in context and not context['bank_statement_line'].partner_id.id: + str_domain = expression.OR([str_domain, [('partner_id.name', 'ilike', str)]]) + + domain = expression.AND([domain, str_domain]) + return domain + + def _domain_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, str=False): + """ Create domain criteria that are relevant to manual reconciliation. """ + domain = ['&', ('reconciled', '=', False), ('account_id', '=', account_id)] + if partner_id: + domain = expression.AND([domain, [('partner_id', '=', partner_id)]]) + generic_domain = self.domain_move_lines_for_reconciliation(excluded_ids=excluded_ids, str=str) + + return expression.AND([generic_domain, domain]) + + @api.model + def get_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, str=False, offset=0, limit=None, target_currency_id=False): + """ Returns unreconciled move lines for an account or a partner+account, formatted for the manual reconciliation widget """ + domain = self._domain_move_lines_for_manual_reconciliation(account_id, partner_id, excluded_ids, str) + lines = self.search(domain, offset=offset, limit=limit, order="date_maturity asc, id asc") + if target_currency_id: + target_currency = self.env['res.currency'].browse(target_currency_id) + else: + account = self.env['account.account'].browse(account_id) + target_currency = account.currency_id or account.company_id.currency_id + return lines.prepare_move_lines_for_reconciliation_widget(target_currency=target_currency) + + @api.v7 + def prepare_move_lines_for_reconciliation_widget(self, cr, uid, line_ids, target_currency_id=False, context=None): + target_currency = target_currency_id and self.pool.get('res.currency').browse(cr, uid, target_currency_id, context=context) or False + return self.browse(cr, uid, line_ids, context).prepare_move_lines_for_reconciliation_widget(target_currency=target_currency) + + @api.v8 + def prepare_move_lines_for_reconciliation_widget(self, target_currency=False, target_date=False): + """ Returns move lines formatted for the manual/bank reconciliation widget + + :param target_currency: curreny you want the move line debit/credit converted into + :param target_date: date to use for the monetary conversion + """ + context = dict(self._context or {}) + ret = [] + + for line in self: + company_currency = line.account_id.company_id.currency_id + ret_line = { + 'id': line.id, + 'name': line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name, + 'ref': line.move_id.ref or '', + # For reconciliation between statement transactions and already registered payments (eg. checks) + # NB : we don't use the 'reconciled' field because the line we're selecting is not the one that gets reconciled + 'already_paid': line.account_id.internal_type == 'liquidity', + 'account_code': line.account_id.code, + 'account_name': line.account_id.name, + 'account_type': line.account_id.internal_type, + 'date_maturity': line.date_maturity, + 'date': line.date, + 'journal_name': line.journal_id.name, + 'partner_id': line.partner_id.id, + 'partner_name': line.partner_id.name, + 'currency_id': line.currency_id.id or False, + } + + debit = line.debit + credit = line.credit + amount = line.amount_residual + amount_currency = line.amount_residual_currency + + # For already reconciled lines, don't use amount_residual(_currency) + if line.account_id.internal_type == 'liquidity': + amount = abs(debit - credit) + amount_currency = abs(line.amount_currency) + + # Get right debit / credit: + target_currency = target_currency or company_currency + line_currency = line.currency_id or company_currency + amount_currency_str = "" + total_amount_currency_str = "" + if line_currency != company_currency: + total_amount = line.amount_currency + actual_debit = debit > 0 and amount_currency or 0.0 + actual_credit = credit > 0 and -amount_currency or 0.0 + else: + total_amount = abs(debit - credit) + actual_debit = debit > 0 and amount or 0.0 + actual_credit = credit > 0 and -amount or 0.0 + if line_currency != target_currency: + amount_currency_str = formatLang(self.env, abs(actual_debit or actual_credit), currency_obj=line_currency) + total_amount_currency_str = formatLang(self.env, total_amount, currency_obj=line_currency) + ctx = context.copy() + if target_date: + ctx.update({'date': target_date}) + total_amount = line_currency.with_context(ctx).compute(total_amount, target_currency) + actual_debit = line_currency.with_context(ctx).compute(actual_debit, target_currency) + actual_credit = line_currency.with_context(ctx).compute(actual_credit, target_currency) + amount_str = formatLang(self.env, abs(actual_debit or actual_credit), currency_obj=target_currency) + total_amount_str = formatLang(self.env, total_amount, currency_obj=target_currency) + + ret_line['debit'] = abs(actual_debit) + ret_line['credit'] = abs(actual_credit) + ret_line['amount_str'] = amount_str + ret_line['total_amount_str'] = total_amount_str + ret_line['amount_currency_str'] = amount_currency_str + ret_line['total_amount_currency_str'] = total_amount_currency_str + ret.append(ret_line) + return ret + + @api.v7 + def process_reconciliations(self, cr, uid, data, context=None): + """ Used to validate a batch of reconciliations in a single call + :param data: list of dicts containing: + - 'type': either 'partner' or 'account' + - 'id': id of the affected res.partner or account.account + - 'mv_line_ids': ids of exisiting account.move.line to reconcile + - 'new_mv_line_dicts': list of dicts containing values suitable for account_move_line.create() + """ + for datum in data: + if len(datum['mv_line_ids']) >= 1 or len(datum['mv_line_ids']) + len(datum['new_mv_line_dicts']) >= 2: + self.process_reconciliation(cr, uid, datum['mv_line_ids'], datum['new_mv_line_dicts'], context=context) + + if datum['type'] == 'partner': + self.env['res.partner'].browse(datum['id']).mark_as_reconciled() + if datum['type'] == 'account': + self.env['account.account'].browse(datum['id']).mark_as_reconciled() + + @api.v7 + def process_reconciliation(self, cr, uid, mv_line_ids, new_mv_line_dicts, context=None): + return self.browse(cr, uid, mv_line_ids, context).process_reconciliation(new_mv_line_dicts) + + @api.v8 + def process_reconciliation(self, new_mv_line_dicts): + """ Create new move lines from new_mv_line_dicts (if not empty) then call reconcile_partial on self and new move lines + + :param new_mv_line_dicts: list of dicts containing values suitable fot account_move_line.create() + """ + if len(self) < 1 or len(self) + len(new_mv_line_dicts) < 2: + raise UserError(_('Error!'), _('A reconciliation must involve at least 2 move lines.')) + + # Create writeoff move lines + if len(new_mv_line_dicts) > 0: + writeoff_lines = self.env['account.move.line'] + company_currency = self[0].account_id.company_id.currency_id + account_currency = self[0].account_id.currency_id or company_currency + for mv_line_dict in new_mv_line_dicts: + if account_currency != company_currency: + mv_line_dict['debit'] = account_currency.compute(mv_line_dict['debit'], company_currency) + mv_line_dict['credit'] = account_currency.compute(mv_line_dict['credit'], company_currency) + writeoff_lines += self._create_writeoff(mv_line_dict) + + (self + writeoff_lines).reconcile() + else: + self.reconcile() + + #################################################### + # Reconciliation methods + #################################################### + + def _get_pair_to_reconcile(self): + #field is either 'amount_residual' or 'amount_residual_currency' (if the reconciled account has a secondary currency set) + field = self[0].account_id.currency_id and 'amount_residual_currency' or 'amount_residual' + #target the pair of move in self that are the oldest + sorted_moves = sorted(self, key=lambda a: a.date) + debit = credit = False + for aml in sorted_moves: + if credit != False and debit != False: + break + if aml[field] > 0 and debit == False: + debit = aml + elif aml[field] < 0 and credit == False: + credit = aml + return debit, credit + + def auto_reconcile_lines(self): + """ This function iterates recursively on the recordset given as parameter as long as it + can find a debit and a credit to reconcile together. It returns the recordset of the + account move lines that were not reconciled during the process. + """ + if not self.ids: + return self + field = self[0].account_id.currency_id and 'amount_residual_currency' or 'amount_residual' + sm_debit_move, sm_credit_move = self._get_pair_to_reconcile() + #there is no more pair to reconcile so return what move_line are left + if not sm_credit_move or not sm_debit_move: + return self + + #Reconcile the pair together + amount_reconcile = min(sm_debit_move[field], -sm_credit_move[field]) + #Remove from recordset the one(s) that will be totally reconciled + if amount_reconcile == sm_debit_move[field]: + self -= sm_debit_move + if amount_reconcile == -sm_credit_move[field]: + self -= sm_credit_move + + #Check for the currency and amount_currency we can set + currency = False + amount_reconcile_currency = 0 + if sm_debit_move.currency_id == sm_credit_move.currency_id and sm_debit_move.currency_id.id: + currency = sm_credit_move.currency_id.id + amount_reconcile_currency = min(sm_debit_move.amount_residual_currency, -sm_credit_move.amount_residual_currency) + + amount_reconcile = min(sm_debit_move.amount_residual, -sm_credit_move.amount_residual) + + self.env['account.partial.reconcile'].create({ + 'debit_move_id': sm_debit_move.id, + 'credit_move_id': sm_credit_move.id, + 'amount': amount_reconcile, + 'amount_currency': amount_reconcile_currency, + 'currency_id': currency, + }) + + #Iterate process again on self + return self.auto_reconcile_lines() + + @api.multi + def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False): + #Perform all checks on lines + company_ids = set() + all_accounts = [] + partners = set() + for line in self: + company_ids.add(line.company_id.id) + all_accounts.append(line.account_id) + if (line.account_id.internal_type in ('receivable', 'payable')): + partners.add(line.partner_id.id) + if line.reconciled: + raise UserError(_('You are trying to reconcile some entries that are already reconciled!')) + if len(company_ids) > 1: + raise UserError(_('To reconcile the entries company should be the same for all entries!')) + if len(set(all_accounts)) > 1: + raise UserError(_('Entries are not of the same account!')) + if not all_accounts[0].reconcile: + raise UserError(_('The account %s (%s) is not marked as reconciliable !') % (all_accounts[0].name, all_accounts[0].code)) + if len(partners) > 1: + raise UserError(_('The partner has to be the same on all lines for receivable and payable accounts!')) + + #reconcile everything that can be + remaining_moves = self.auto_reconcile_lines() + + #if writeoff_acc_id specified, then create write-off move with value the remaining amount from move in self + if writeoff_acc_id and writeoff_journal_id and remaining_moves: + writeoff_to_reconcile = remaining_moves._create_writeoff({'account_id': writeoff_acc_id.id, 'journal_id': writeoff_journal_id.id}) + #add writeoff line to reconcile algo and finish the reconciliation + remaining_moves = (remaining_moves + writeoff_to_reconcile).auto_reconcile_lines() + + def _create_writeoff(self, vals): + """ Create a writeoff move for the account.move.lines in self. If debit/credit is not specified in vals, + the writeoff amount will be computed as the sum of amount_residual of the given recordset. + + :param vals: dict containing values suitable fot account_move_line.create(). The data in vals will + be processed to create bot writeoff acount.move.line and their enclosing account.move. + """ + # Check and complete vals + if 'account_id' not in vals or 'journal_id' not in vals: + raise UserError(_("It is mandatory to specify an account and a journal to create a write-off.")) + if ('debit' in vals) ^ ('credit' in vals): + raise UserError(_("Either pass both debit and credit or none.")) + if 'date' not in vals: + vals['date'] = self._context.get('date_p') or time.strftime('%Y-%m-%d') + if 'name' not in vals: + vals['name'] = self._context.get('comment') or _('Write-Off') + #compute the writeoff amount if not given + if 'credit' not in vals and 'debit' not in vals: + amount = sum([r.amount_residual for r in self]) + vals['credit'] = amount > 0 and amount or 0.0 + vals['debit'] = amount < 0 and abs(amount) or 0.0 + company_currency = self[0].account_id.company_id.currency_id + account_currency = self[0].account_id.currency_id or company_currency + if 'amount_currency' not in vals and account_currency != company_currency: + vals['currency_id'] = account_currency + vals['amount_currency'] = sum([r.amount_residual_currency for r in self]) + + # Writeoff line in the account of self + first_line_dict = vals.copy() + first_line_dict['account_id'] = self[0].account_id.id + first_line_dict['partner_id'] = self[0].partner_id.id + if 'analytic_account_id' in vals: + del vals['analytic_account_id'] + + # Writeoff line in specified writeoff account + second_line_dict = vals.copy() + second_line_dict['debit'], second_line_dict['credit'] = second_line_dict['credit'], second_line_dict['debit'] + if 'amount_currency' in vals: + second_line_dict['amount_currency'] = -second_line_dict['amount_currency'] + + # Create the move + writeoff_move = self.env['account.move'].create({ + 'journal_id': vals['journal_id'], + 'date': vals['date'], + 'state': 'draft', + 'line_ids': [(0, 0, first_line_dict), (0, 0, second_line_dict)], + }) + writeoff_move.post() + + # Return the writeoff move.line which is to be reconciled + return writeoff_move.line_ids.filtered(lambda r: r.account_id == self[0].account_id) + + @api.multi + def remove_move_reconcile(self): + """ Undo a reconciliation """ + if not self: + return True + rec_move_ids = self.env['account.partial.reconcile'] + for account_move_line in self: + rec_move_ids += account_move_line.matched_debit_ids + rec_move_ids += account_move_line.matched_credit_ids + return rec_move_ids.unlink() + + #################################################### + # CRUD methods + #################################################### + + #TODO: to check/refactor + @api.model + def create(self, vals, apply_taxes=True): + """ :param apply_taxes: set to False if you don't want vals['tax_ids'] to result in the creation of move lines for taxes and eventual + adjustment of the line amount (in case of a tax included in price). This is useful for use cases where you don't want to + apply taxes in the default fashion (eg. taxes). You can also pass 'dont_create_taxes' in context. + + :context's key `check_move_validity`: check data consistency after move line creation. Eg. set to false to disable verification that the move + debit-credit == 0 while creating the move lines composing the move. + + """ + AccountObj = self.env['account.account'] + MoveObj = self.env['account.move'] + context = dict(self._context or {}) + amount = vals.get('debit', 0.0) - vals.get('credit', 0.0) + + if vals.get('move_id', False): + move = MoveObj.browse(vals['move_id']) + if move.date and not vals.get('date'): + vals['date'] = move.date + if ('account_id' in vals) and AccountObj.browse(vals['account_id']).deprecated: + raise UserError(_('You cannot use deprecated account.')) + if 'journal_id' in vals and vals['journal_id']: + context['journal_id'] = vals['journal_id'] + if 'date' in vals and vals['date']: + context['date'] = vals['date'] + if ('journal_id' not in context) and ('move_id' in vals) and vals['move_id']: + m = MoveObj.browse(vals['move_id']) + context['journal_id'] = m.journal_id.id + context['date'] = m.date + #we need to treat the case where a value is given in the context for period_id as a string + if not context.get('journal_id', False) and context.get('search_default_journal_id', False): + context['journal_id'] = context.get('search_default_journal_id') + if 'date' not in context: + context['date'] = fields.Date.context_today(self) + move_id = vals.get('move_id', False) + journal = self.env['account.journal'].browse(context['journal_id']) + vals['journal_id'] = vals.get('journal_id') or context.get('journal_id') + vals['date'] = vals.get('date') or context.get('date') + vals['date_maturity'] = vals.get('date_maturity') if vals.get('date_maturity') else vals['date'] + if not move_id: + if not vals.get('move_id', False): + if journal.sequence_id: + #name = self.pool.get('ir.sequence').next_by_id(cr, uid, journal.sequence_id.id) + v = { + 'date': vals.get('date', time.strftime('%Y-%m-%d')), + 'journal_id': context['journal_id'] + } + if vals.get('ref', ''): + v.update({'ref': vals['ref']}) + move_id = MoveObj.with_context(context).create(v) + vals['move_id'] = move_id.id + else: + raise UserError(_('Cannot create an automatic sequence for this piece.\nPut a sequence in the journal definition for automatic numbering or create a sequence manually for this piece.')) + ok = not (journal.type_control_ids or journal.account_control_ids) + if ('account_id' in vals): + account = AccountObj.browse(vals['account_id']) + if journal.type_control_ids: + type = account.user_type_id + for t in journal.type_control_ids: + if type.code == t.code: + ok = True + break + if journal.account_control_ids and not ok: + for a in journal.account_control_ids: + if a.id == vals['account_id']: + ok = True + break + # Automatically convert in the account's secondary currency if there is one and + # the provided values were not already multi-currency + if account.currency_id and 'amount_currency' not in vals and account.currency_id.id != account.company_id.currency_id.id: + vals['currency_id'] = account.currency_id.id + ctx = {} + if 'date' in vals: + ctx['date'] = vals['date'] + vals['amount_currency'] = account.company_id.currency_id.with_context(ctx).compute(amount, account.currency_id) + if not ok: + raise UserError(_('You cannot use this general account in this journal, check the tab \'Entry Controls\' on the related journal.')) + + # Create tax lines + tax_lines_vals = [] + if apply_taxes and not context.get('dont_create_taxes') and vals.get('tax_ids'): + # Get ids from triplets : https://www.odoo.com/documentation/master/reference/orm.html#openerp.models.Model.write + tax_ids = map(lambda tax: tax[1], vals['tax_ids']) + # Since create() receives ids instead of recordset, let's just use the old-api bridge + taxes = self.env['account.tax'].browse(tax_ids) + res = taxes.compute_all(amount, + vals.get('currency_id'), 1, vals.get('product_id'), vals.get('partner_id')) + # Adjust line amount if any tax is price_include + if abs(res['total_excluded']) < abs(amount): + if vals['debit'] != 0.0: vals['debit'] = res['total_excluded'] + if vals['credit'] != 0.0: vals['credit'] = -res['total_excluded'] + if vals.get('amount_currency'): + vals['amount_currency'] = self.env['res.currency'].browse(vals['currency_id']).round(vals['amount_currency'] * (amount / res['total_excluded'])) + # Create tax lines + for tax_vals in res['taxes']: + account_id = (amount > 0 and tax_vals['account_id'] or tax_vals['refund_account_id']) + if not account_id: account_id = vals['account_id'] + tax_lines_vals.append({ + 'account_id': account_id, + 'name': vals['name'] + ' ' + tax_vals['name'], + 'tax_line_id': tax_vals['id'], + 'move_id': vals['move_id'], + 'date': vals['date'], + 'partner_id': vals.get('partner_id'), + 'ref': vals.get('ref'), + 'statement_id': vals.get('statement_id'), + 'debit': tax_vals['amount'] > 0 and tax_vals['amount'] or 0.0, + 'credit': tax_vals['amount'] < 0 and -tax_vals['amount'] or 0.0, + }) + + new_line = super(AccountMoveLine, self).create(vals) + for tax_line_vals in tax_lines_vals: + # TODO: remove .with_context(context) once this context nonsense is solved + self.with_context(context).create(tax_line_vals) + + if self._context.get('check_move_validity', True): + move = MoveObj.browse(vals['move_id']) + move.with_context(context)._post_validate() + + return new_line + + @api.multi + def unlink(self): + self._update_check() + move_ids = set() + for line in self: + if line.move_id.id not in move_ids: + move_ids.add(line.move_id.id) + result = super(AccountMoveLine, self).unlink() + if self._context.get('check_move_validity', True) and move_ids: + self.env['account.move'].browse(list(move_ids))._post_validate() + return result + + @api.multi + def write(self, vals): + if vals.get('tax_line_id') or vals.get('tax_ids'): + raise UserError(_('You cannot change the tax, you should remove and recreate lines.')) + if ('account_id' in vals) and self.env['account.account'].browse(vals['account_id']).deprecated: + raise UserError(_('You cannot use deprecated account.')) + if any(key in vals for key in ('account_id', 'journal_id', 'date', 'move_id', 'debit', 'credit', 'amount_currency', 'currency_id')): + self._update_check() + #when we set the expected payment date, log a note on the invoice_id related (if any) + if vals.get('expected_pay_date') and self.invoice_id: + msg = _('New expected payment date: ') + vals['expected_pay_date'] + '.\n' + vals.get('internal_note', '') + self.invoice_id.message_post(body=msg) #TODO: check it is an internal note (not a regular email)! + #when making a reconciliation on an existing liquidity journal item, mark the payment as reconciled + if 'statement_id' in vals and self.payment_id: + # In case of an internal transfer, there are 2 liquidity move lines to match with a bank statement + if all(line.statement_id for line in self.payment_id.move_line_ids.filtered(lambda r: r.id != self.id and r.account_id.internal_type=='liquidity')): + self.payment_id.state = 'reconciled' + + result = super(AccountMoveLine, self).write(vals) + if self._context.get('check_move_validity', True): + move_ids = set() + for line in self: + if line.move_id.id not in move_ids: + move_ids.add(line.move_id.id) + self.env['account.move'].browse(list(move_ids))._post_validate() + return result + + @api.multi + def _update_check(self): + """ Raise Warning to cause rollback if the move is posted, some entries are reconciled or the move is older than the lock date""" + move_ids = set() + for line in self: + err_msg = _('Move name (id): %s (%s)') % (line.move_id.name, str(line.move_id.id)) + if line.move_id.state != 'draft': + raise UserError(_('You cannot do this modification on a posted journal entry, you can just change some non legal fields. You must revert the journal entry to cancel it.\n%s.') % err_msg) + if line.reconciled: + raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg) + if line.move_id.id not in move_ids: + move_ids.add(line.move_id.id) + self.env['account.move'].browse(list(move_ids))._check_lock_date() + return True + + #################################################### + # Misc / utility methods + #################################################### + + @api.multi + @api.depends('ref', 'move_id') + def name_get(self): + result = [] + for line in self: + if line.ref: + result.append((line.id, (line.move_id.name or '') + '(' + line.ref + ')')) + else: + result.append((line.id, line.move_id.name)) + return result + + @api.model + def compute_amount_fields(self, amount, src_currency, company_currency): + """ Helper function to compute value for fields debit/credit/amount_currency based on an amount and the currencies given in parameter""" + amount_currency = False + if src_currency and src_currency != company_currency: + amount_currency = amount + amount = src_currency.with_context(self._context).compute(amount, company_currency) + debit = amount > 0 and amount or 0.0 + credit = amount < 0 and -amount or 0.0 + return debit, credit, amount_currency + + @api.multi + def create_analytic_lines(self): + """ Create analytic items upon validation of an account.move.line having an analytic account. This + method first remove any existing analytic item related to the line before creating any new one. + """ + for obj_line in self: + if obj_line.analytic_account_id: + if not obj_line.journal_id.analytic_journal_id: + raise UserError(_("You have to define an analytic journal on the '%s' journal!") % (obj_line.journal_id.name, )) + if obj_line.analytic_line_ids: + obj_line.analytic_line_ids.unlink() + vals_line = obj_line._prepare_analytic_line()[0] + self.env['account.analytic.line'].create(vals_line) + + @api.one + 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 { + 'name': self.name, + 'date': self.date, + 'account_id': self.analytic_account_id.id, + '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': (self.credit or 0.0) - (self.debit or 0.0), + 'general_account_id': self.account_id.id, + 'journal_id': self.journal_id.analytic_journal_id.id, + 'ref': self.ref, + 'move_id': self.id, + 'user_id': self._uid, + } + + @api.model + def _query_get(self, domain=None): + context = dict(self._context or {}) + domain = domain and safe_eval(domain) or [] + + 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)] + 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'])] + + if 'company_ids' in context: + domain += [('company_id', 'in', context['company_ids'])] + + where_clause = "" + where_clause_params = [] + if domain: + query = self._where_calc(domain) + dummy, where_clause, where_clause_params = query.get_sql() + return where_clause, where_clause_params + + +class AccountPartialReconcile(models.Model): + _name = "account.partial.reconcile" + _description = "Partial Reconcile" + + debit_move_id = fields.Many2one('account.move.line') + credit_move_id = fields.Many2one('account.move.line') + amount = fields.Monetary(currency_field='company_currency_id', help="Amount concerned by this matching. Assumed to be always positive") + amount_currency = fields.Monetary(string="Amount in Currency") + currency_id = fields.Many2one('res.currency', string='Currency') + company_currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True, + help='Utility field to express amount currency') + company_id = fields.Many2one('res.company', related='debit_move_id.company_id', store=True, string='Currency') + + def create_exchange_rate_entry(self): + """ Automatically create a journal entry to book the exchange rate difference between the `debit_move_id` + and the `credit_move_id`, if both share the same currency, and at the prorata of the amount matched + between them. + That new journal entry is made in the company `currency_exchange_journal_id` and one of its journal + items is matched with the other lines to ensure having the residual amount in company currency that + reflects perfectly the residual amount in foreign currency. + """ + for rec in self: + if rec.currency_id and rec.debit_move_id.amount_currency and rec.credit_move_id.amount_currency: + #create exchange rate difference journal entry + rate_diff = rec.debit_move_id.debit / rec.debit_move_id.amount_currency - rec.credit_move_id.credit / -rec.credit_move_id.amount_currency + if rec.amount_currency and rec.company_id.currency_id.round(rec.amount_currency * rate_diff): + if not rec.company_id.currency_exchange_journal_id: + raise UserError(_("You should configure the 'Exchange Rate Journal' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) + if not self.company_id.income_currency_exchange_account_id.id: + raise UserError(_("You should configure the 'Gain Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) + if not self.company_id.expense_currency_exchange_account_id.id: + raise UserError(_("You should configure the 'Loss Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates.")) + amount_diff = rec.company_id.currency_id.round(rec.amount_currency * rate_diff) + move = rec.env['account.move'].create({'journal_id': rec.company_id.currency_exchange_journal_id.id, 'rate_diff_partial_rec_id': rec.id}) + line_to_reconcile = rec.env['account.move.line'].with_context(check_move_validity=False).create({ + 'name': _('Currency exchange rate difference'), + 'debit': amount_diff < 0 and -amount_diff or 0.0, + 'credit': amount_diff > 0 and amount_diff or 0.0, + 'account_id': rec.debit_move_id.account_id.id, + 'move_id': move.id, + 'currency_id': rec.currency_id.id, + }) + rec.env['account.move.line'].create({ + 'name': _('Currency exchange rate difference'), + 'debit': amount_diff > 0 and amount_diff or 0.0, + 'credit': amount_diff < 0 and -amount_diff or 0.0, + 'account_id': amount_diff > 0 and rec.company_id.currency_exchange_journal_id.default_debit_account_id.id or rec.company_id.currency_exchange_journal_id.default_credit_account_id.id, + 'move_id': move.id, + 'currency_id': rec.currency_id.id, + }) + rec.env['account.partial.reconcile'].create({ + 'debit_move_id': amount_diff < 0 and line_to_reconcile.id or rec.debit_move_id.id, + 'credit_move_id': amount_diff > 0 and line_to_reconcile.id or rec.credit_move_id.id, + 'amount': abs(amount_diff), + 'amount_currency': 0.0, + 'currency_id': rec.debit_move_id.currency_id.id, + }) + move.post() + + @api.model + def create(self, vals): + res = super(AccountPartialReconcile, self).create(vals) + #eventually create a journal entry to book the difference due to foreign currency's exchange rate that fluctuates + res.create_exchange_rate_entry() + return res + + @api.multi + def unlink(self): + """ When removing a link between entries, we need to revert the eventual journal entries we created to book the + fluctuation of the foreign currency's exchange rate. + """ + exchange_rate_entries = self.env['account.move'].search([('rate_diff_partial_rec_id', 'in', self.ids)]) + exchange_rate_entries.reverse_moves() + return super(AccountPartialReconcile, self).unlink() diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py new file mode 100644 index 00000000000..1f8e252155e --- /dev/null +++ b/addons/account/models/account_payment.py @@ -0,0 +1,501 @@ +# -*- coding: utf-8 -*- + +from openerp import models, fields, api, _ +from openerp.exceptions import UserError, ValidationError + +MAP_INVOICE_TYPE_PARTNER_TYPE = { + 'out_invoice': 'customer', + 'out_refund': 'customer', + 'in_invoice': 'supplier', + 'in_refund': 'supplier', +} +# Since invoice amounts are unsigned, this is how we know if money comes in or goes out +MAP_INVOICE_TYPE_PAYMENT_SIGN = { + 'out_invoice': 1, + 'in_refund': 1, + 'in_invoice': -1, + 'out_refund': -1, +} + +class account_payment_method(models.Model): + _name = "account.payment.method" + _description = "Payment Methods" + + name = fields.Char(required=True) + code = fields.Char(required=True) # For internal identification + payment_type = fields.Selection([('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True) + + +class account_abstract_payment(models.AbstractModel): + _name = "account.abstract.payment" + _description = "Contains the logic shared between models which allows to register payments" + + payment_type = fields.Selection([('outbound', 'Send Money'), ('inbound', 'Receive Money')], string='Payment Type', required=True) + payment_method_id = fields.Many2one('account.payment.method', string='Payment Method', required=True, oldname="payment_method") + payment_method_code = fields.Char(related='payment_method_id.code', + help="Technical field used to adapt the interface to the payment method selected.") + + partner_type = fields.Selection([('customer', 'Customer'), ('supplier', 'Supplier')]) + partner_id = fields.Many2one('res.partner', string='Partner') + + amount = fields.Monetary(string='Payment Amount', required=True) + currency_id = fields.Many2one('res.currency', string='Currency', required=True, default=lambda self: self.env.user.company_id.currency_id) + payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True, copy=False) + communication = fields.Char(string='Memo') + journal_id = fields.Many2one('account.journal', string='Payment Mode', required=True, domain=[('type', 'in', ('bank', 'cash'))]) + company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', readonly=True) + + hide_payment_method = fields.Boolean(compute='_compute_hide_payment_method', + help="Technical field used to hide the payment method if the selected journal has only one available which is 'manual'") + + @api.one + @api.constrains('amount') + def _check_amount(self): + if not self.amount > 0.0: + raise ValidationError('The payment amount must be strictly positive.') + + @api.one + @api.depends('payment_type', 'journal_id') + def _compute_hide_payment_method(self): + if not self.journal_id: + self.hide_payment_method = True + return + journal_payment_methods = self.payment_type == 'inbound' and self.journal_id.inbound_payment_method_ids or self.journal_id.outbound_payment_method_ids + self.hide_payment_method = len(journal_payment_methods) == 1 and journal_payment_methods[0].code == 'manual' + + @api.onchange('journal_id') + def _onchange_journal(self): + if self.journal_id: + self.currency_id = self.journal_id.currency_id or self.company_id.currency_id + # Set default payment method (we consider the first to be the default one) + payment_methods = self.payment_type == 'inbound' and self.journal_id.inbound_payment_method_ids or self.journal_id.outbound_payment_method_ids + self.payment_method_id = payment_methods and payment_methods[0] or False + # Set payment method domain (restrict to methods enabled for the journal and to selected payment type) + payment_type = self.payment_type in ('outbound', 'transfer') and 'outbound' or 'inbound' + return {'domain': {'payment_method_id': [('payment_type', '=', payment_type), ('id', 'in', payment_methods.ids)]}} + return {} + + def _get_invoices(self): + """ Return the invoices of the payment. Must be overridden """ + raise NotImplementedError + + def _compute_total_invoices_amount(self): + """ Compute the sum of the residual of invoices, expressed in the payment currency """ + total = 0 + payment_currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id + for inv in self._get_invoices(): + total += inv.residual_signed + if self.company_id and self.company_id.currency_id != payment_currency: + total = self.company_id.currency_id.with_context(date=self.payment_date).compute(total, payment_currency) + return abs(total) + + +class account_register_payments(models.TransientModel): + _name = "account.register.payments" + _inherit = 'account.abstract.payment' + _description = "Register payments on multiple invoices" + + @api.onchange('payment_type') + def _onchange_payment_type(self): + if self.payment_type: + return {'domain': {'payment_method_id': [('payment_type', '=', self.payment_type)]}} + + def _get_invoices(self): + return self.env['account.invoice'].browse(self._context.get('active_ids')) + + @api.model + def default_get(self, fields): + rec = super(account_register_payments, self).default_get(fields) + context = dict(self._context or {}) + active_model = context.get('active_model') + active_ids = context.get('active_ids') + + # Checks on context parameters + if not active_model or not active_ids: + raise UserError(_("Programmation error: wizard action executed without active_model or active_ids in context.")) + if active_model != 'account.invoice': + raise UserError(_("Programmation error: the expected model for this action is 'account.invoice'. The provided one is '%d'." % active_model)) + + # Checks on received invoice records + invoices = self.env[active_model].browse(active_ids) + if any(invoice.state != 'open' for invoice in invoices): + raise UserError(_("You can only register payments for open invoices")) + if any(inv.partner_id != invoices[0].partner_id for inv in invoices): + raise UserError(_("In order to pay multiple invoices at once, they must belong to the same partner.")) + if any(MAP_INVOICE_TYPE_PARTNER_TYPE[inv.type] != MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type] for inv in invoices): + raise UserError(_("You cannot mix customer and supplier invoices in a single payment.")) + if any(inv.currency_id != invoices[0].currency_id for inv in invoices): + raise UserError(_("In order to pay multiple invoices at once, they must use the same currency.")) + + total_amount = sum(inv.residual * MAP_INVOICE_TYPE_PAYMENT_SIGN[inv.type] for inv in invoices) + rec.update({ + 'amount': abs(total_amount), + 'currency_id': invoices[0].currency_id.id, + 'payment_type': total_amount > 0 and 'inbound' or 'outbound', + 'partner_id': invoices[0].partner_id.id, + 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], + }) + return rec + + def get_payment_vals(self): + """ Hook for extension """ + return { + 'journal_id': self.journal_id.id, + 'payment_method_id': self.payment_method_id.id, + 'payment_date': self.payment_date, + 'communication': self.communication, + 'invoice_ids': [(4, inv.id, None) for inv in self._get_invoices()], + 'payment_type': self.payment_type, + 'amount': self.amount, + 'currency_id': self.currency_id.id, + 'partner_id': self.partner_id.id, + 'partner_type': self.partner_type, + } + + @api.multi + def create_payment(self): + payment = self.env['account.payment'].create(self.get_payment_vals()) + payment.post() + return {'type': 'ir.actions.act_window_close'} + + +class account_payment(models.Model): + _name = "account.payment" + _inherit = 'account.abstract.payment' + _description = "Payments" + _order = "payment_date desc, name desc" + + @api.one + @api.depends('invoice_ids') + def _get_has_invoices(self): + self.has_invoices = bool(self.invoice_ids) + + @api.one + @api.depends('invoice_ids', 'amount', 'payment_date', 'currency_id') + def _compute_payment_difference(self): + if len(self.invoice_ids) == 0: + return + self.payment_difference = self._compute_total_invoices_amount() - self.amount + + company_id = fields.Many2one(store=True) + + name = fields.Char(readonly=True, copy=False, default="Draft Payment") # The name is attributed upon post() + state = fields.Selection([('draft', 'Draft'), ('posted', 'Posted'), ('sent', 'Sent'), ('reconciled', 'Reconciled')], readonly=True, default='draft', copy=False, string="Status") + + payment_type = fields.Selection(selection_add=[('transfer', 'Internal Transfer')]) + payment_reference = fields.Char(copy=False, readonly=True, help="Reference of the document used to issue this payment. Eg. check number, file name, etc.") + + # Money flows from the journal_id's default_debit_account_id or default_credit_account_id to the destination_account_id + destination_account_id = fields.Many2one('account.account', compute='_compute_destination_account_id', readonly=True) + # For money transfer, money goes from journal_id to a transfer account, then from the transfer account to destination_journal_id + destination_journal_id = fields.Many2one('account.journal', string='Transfer To', domain=[('type', 'in', ('bank', 'cash'))]) + + invoice_ids = fields.Many2many('account.invoice', 'account_invoice_payment_rel', 'payment_id', 'invoice_id', string="Invoices", copy=False, readonly=True) + has_invoices = fields.Boolean(compute="_get_has_invoices", help="Technical field used for usablity purposes") + payment_difference = fields.Monetary(compute='_compute_payment_difference', readonly=True) + payment_difference_handling = fields.Selection([('open', 'Partial payment'), ('reconcile', 'Mark invoice as fully paid')], default='open', string="Payment Difference", copy=False) + writeoff_account_id = fields.Many2one('account.account', string="Counterpart Account", domain=[('deprecated', '=', False)], copy=False) + + # FIXME: ondelete='restrict' not working (eg. cancel a bank statement reconciliation with a payment) + move_line_ids = fields.One2many('account.move.line', 'payment_id', readonly=True, copy=False, ondelete='restrict') + + @api.one + @api.depends('invoice_ids', 'payment_type', 'partner_type', 'partner_id') + def _compute_destination_account_id(self): + if self.invoice_ids: + self.destination_account_id = self.invoice_ids[0].account_id.id + elif self.payment_type == 'transfer': + if not self.company_id.transfer_account_id.id: + raise UserError(_('Transfer account not defined on the company.')) + self.destination_account_id = self.company_id.transfer_account_id.id + elif self.partner_id: + if self.partner_type == 'customer': + self.destination_account_id = self.partner_id.property_account_receivable_id.id + else: + self.destination_account_id = self.partner_id.property_account_payable_id.id + + @api.onchange('partner_type') + def _onchange_partner_type(self): + # Set partner_id domain + if self.partner_type: + if getattr(self.partner_id, self.partner_type) is False: + self.partner_id = False + return {'domain': {'partner_id': [(self.partner_type, '=', True)]}} + + @api.onchange('payment_type') + def _onchange_payment_type(self): + # Set default partner type for the payment type + if self.payment_type == 'inbound': + self.partner_type = 'customer' + elif self.payment_type == 'outbound': + self.partner_type = 'supplier' + # Set payment method domain + res = self._onchange_journal() + if not res.get('domain', {}): + res['domain'] = {} + res['domain']['journal_id'] = self.payment_type == 'inbound' and [('at_least_one_inbound', '=', True)] or [('at_least_one_outbound', '=', True)] + res['domain']['journal_id'].append(('type', 'in', ('bank', 'cash'))) + return res + + @api.model + def default_get(self, fields): + rec = super(account_payment, self).default_get(fields) + invoice_ids = rec.get('invoice_ids') and rec['invoice_ids'][0][2] or None + if invoice_ids and len(invoice_ids) == 1: + invoice = self.env['account.invoice'].browse(invoice_ids) + rec['communication'] = invoice.reference + rec['currency_id'] = invoice.currency_id.id + rec['payment_type'] = invoice.type in ('out_invoice', 'in_refund') and 'inbound' or 'outbound' + rec['partner_type'] = MAP_INVOICE_TYPE_PARTNER_TYPE[invoice.type] + rec['partner_id'] = invoice.partner_id.id + rec['amount'] = invoice.residual_signed + return rec + + def _get_invoices(self): + return self.invoice_ids + + @api.model + def create(self, vals): + self._check_communication(vals['payment_method_id'], vals.get('communication', '')) + return super(account_payment, self).create(vals) + + def _check_communication(self, payment_method_id, communication): + """ This method is to be overwritten by payment type modules. The method body would look like : + if payment_method_id == self.env.ref('my_module.payment_method_id').id: + try: + communication.decode('ascii') + except UnicodeError: + raise ValidationError(_("The communication cannot contain any special character")) + """ + pass + + @api.multi + def button_journal_entries(self): + return { + 'name': _('Journal Items'), + 'view_type': 'form', + 'view_mode': 'tree', + 'res_model': 'account.move.line', + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('payment_id', 'in', self.ids)], + } + + @api.multi + def button_invoices(self): + return { + 'name': _('Paid Invoices'), + 'view_type': 'form', + 'view_mode': 'tree', + 'res_model': 'account.invoice', + 'view_id': False, + 'type': 'ir.actions.act_window', + 'domain': [('id', 'in', [x.id for x in self.invoice_ids])], + } + + @api.multi + def button_dummy(self): + return True + + @api.multi + def cancel(self): + for rec in self: + for move in rec.move_line_ids.mapped('move_id'): + if rec.invoice_ids: + move.line_ids.remove_move_reconcile() + move.button_cancel() + move.unlink() + rec.state = 'draft' + + @api.multi + def unlink(self): + if any(rec.state != 'draft' for rec in self): + raise UserError(_("You can not delete a payment that is already posted")) + return super(account_payment, self).unlink() + + @api.multi + def post(self): + """ Create the journal items for the payment and update the payment's state to 'posted'. + A journal entry is created containing an item in the source liquidity account (selected journal's default_debit or default_credit) + and another in the destination reconciliable account (see _compute_destination_account_id). + If invoice_ids is not empty, there will be one reconciliable move line per invoice to reconcile with. + If the payment is a transfer, a second journal entry is created in the destination journal to receive money from the transfer account. + """ + for rec in self: + + if rec.state != 'draft': + raise UserError(_("Only a draft payment can be posted. Trying to post a payment in state %s.") % rec.state) + + if any(inv.state != 'open' for inv in rec.invoice_ids): + raise ValidationError(_("The payment cannot be processed because an invoice of the payment is not open !")) + + # Use the right sequence to set the name + if rec.payment_type == 'transfer': + sequence = rec.env.ref('account.sequence_payment_transfer') + else: + if rec.partner_type == 'customer': + if rec.payment_type == 'inbound': + sequence = rec.env.ref('account.sequence_payment_customer_invoice') + if rec.payment_type == 'outbound': + sequence = rec.env.ref('account.sequence_payment_customer_refund') + if rec.partner_type == 'supplier': + if rec.payment_type == 'inbound': + sequence = rec.env.ref('account.sequence_payment_supplier_refund') + if rec.payment_type == 'outbound': + sequence = rec.env.ref('account.sequence_payment_supplier_invoice') + rec.name = sequence.with_context(ir_sequence_date=rec.payment_date).next_by_id() + + # Create the journal entry + amount = rec.amount * (rec.payment_type in ('outbound', 'transfer') and 1 or -1) + move = rec._create_payment_entry(amount) + + # In case of a transfer, the first journal entry created debited the source liquidity account and credited + # the transfer account. Now we debit the transfer account and credit the destination liquidity account. + if rec.payment_type == 'transfer': + transfer_credit_aml = move.line_ids.filtered(lambda r: r.account_id == rec.company_id.transfer_account_id) + transfer_debit_aml = rec._create_transfer_entry(amount) + (transfer_credit_aml + transfer_debit_aml).reconcile() + + rec.state = 'posted' + + def _create_payment_entry(self, amount): + """ Create a journal entry corresponding to a payment, if the payment references invoice(s) they are reconciled. + Return the journal entry. + """ + aml_obj = self.env['account.move.line'].with_context(check_move_validity=False) + debit, credit, amount_currency = aml_obj.with_context(date=self.payment_date).compute_amount_fields(amount, self.currency_id, self.company_id.currency_id) + + move = self.env['account.move'].create(self._get_move_vals()) + + #Write line corresponding to invoice payment + counterpart_aml_dict = self._get_shared_move_line_vals(debit, credit, amount_currency, move.id, False) + counterpart_aml_dict.update(self._get_counterpart_move_line_vals(self.invoice_ids)) + counterpart_aml_dict.update({'currency_id': self.currency_id != self.company_id.currency_id and self.currency_id.id or False}) + counterpart_aml = aml_obj.create(counterpart_aml_dict) + + #Reconcile with the invoices + if self.payment_difference_handling == 'reconcile': + self.invoice_ids.register_payment(counterpart_aml, self.writeoff_account, self.journal_id) + else: + self.invoice_ids.register_payment(counterpart_aml) + + #Write counterpart lines + liquidity_aml_dict = self._get_shared_move_line_vals(credit, debit, -amount_currency, move.id, False) + liquidity_aml_dict.update(self._get_liquidity_move_line_vals(-amount)) + aml_obj.create(liquidity_aml_dict) + + move.post() + return move + + def _create_transfer_entry(self, amount): + """ Create the journal entry corresponding to the 'incoming money' part of an internal transfer, return the reconciliable move line + """ + aml_obj = self.env['account.move.line'].with_context(check_move_validity=False) + debit, credit, amount_currency = aml_obj.with_context(date=self.payment_date).compute_amount_fields(amount, self.currency_id, self.company_id.currency_id) + amount_currency = self.destination_journal_id.currency_id and self.currency_id.with_context(date=self.payment_date).compute(amount, self.destination_journal_id.currency_id) or 0 + + dst_move = self.env['account.move'].create(self._get_move_vals(self.destination_journal_id)) + + dst_liquidity_aml_dict = self._get_shared_move_line_vals(debit, credit, amount_currency, dst_move.id) + dst_liquidity_aml_dict.update({ + 'name': _('Transfer from %s') % self.journal_id.name, + 'account_id': self.destination_journal_id.default_credit_account_id.id, + 'currency_id': self.destination_journal_id.currency_id.id, + 'payment_id': self.id, + 'journal_id': self.destination_journal_id.id}) + aml_obj.create(dst_liquidity_aml_dict) + + transfer_debit_aml_dict = self._get_shared_move_line_vals(credit, debit, 0, dst_move.id) + transfer_debit_aml_dict.update({ + 'name': self.name, + 'payment_id': self.id, + 'account_id': self.company_id.transfer_account_id.id, + 'journal_id': self.destination_journal_id.id}) + if self.currency_id != self.company_id.currency_id: + transfer_debit_aml_dict.update({ + 'currency_id': self.currency_id.id, + 'amount_currency': -self.amount, + }) + transfer_debit_aml = aml_obj.create(transfer_debit_aml_dict) + dst_move.post() + return transfer_debit_aml + + def _get_move_vals(self, journal=None): + """ Return dict to create the payment move + """ + journal = journal or self.journal_id + if not journal.sequence_id: + raise UserError(_('Configuration Error !'), _('The journal %s does not have a sequence, please specify one.') % journal.name) + if not journal.sequence_id.active: + raise UserError(_('Configuration Error !'), _('The sequence of journal %s is deactivated.') % journal.name) + name = journal.with_context(ir_sequence_date=self.payment_date).sequence_id.next_by_id() + return { + 'name': name, + 'date': self.payment_date, + 'ref': self.communication or '', + 'company_id': self.company_id.id, + 'journal_id': journal.id, + } + + def _get_shared_move_line_vals(self, debit, credit, amount_currency, move_id, invoice_id=False): + """ Returns values common to both move lines (except for debit, credit and amount_currency which are reversed) + """ + return { + 'partner_id': self.payment_type in ('inbound', 'outbound') and self.partner_id.commercial_partner_id.id or False, + 'invoice_id': invoice_id and invoice_id.id or False, + 'move_id': move_id, + 'debit': debit, + 'credit': credit, + 'amount_currency': amount_currency or False, + } + + def _get_counterpart_move_line_vals(self, invoice=False): + if self.payment_type == 'transfer': + name = self.name + else: + name = '' + if self.partner_type == 'customer': + if self.payment_type == 'inbound': + name += _("Customer Payment") + elif self.payment_type == 'outbound': + name += _("Customer Refund") + elif self.partner_type == 'supplier': + if self.payment_type == 'inbound': + name += _("Supplier Refund") + elif self.payment_type == 'outbound': + name += _("Supplier Payment") + if invoice: + name += ': ' + for inv in invoice: + name += inv.number+', ' + name = name[:len(name)-2] + return { + 'name': name, + 'account_id': self.destination_account_id.id, + 'journal_id': self.journal_id.id, + 'currency_id': self.currency_id != self.company_id.currency_id and self.currency_id.id or False, + 'payment_id': self.id, + } + + def _get_liquidity_move_line_vals(self, amount): + name = self.name + if self.payment_type == 'transfer': + name = _('Transfer to %s') % self.destination_journal_id.name + vals = { + 'name': name, + 'account_id': self.payment_type in ('outbound','transfer') and self.journal_id.default_debit_account_id.id or self.journal_id.default_credit_account_id.id, + 'payment_id': self.id, + 'journal_id': self.journal_id.id, + 'currency_id': self.currency_id != self.company_id.currency_id and self.currency_id.id or False, + } + + # If the journal has a currency specified, the journal item need to be expressed in this currency + if self.journal_id.currency_id and self.currency_id != self.journal_id.currency_id: + amount = self.currency_id.with_context(date=self.payment_date).compute(amount, self.journal_id.currency_id) + debit, credit, amount_currency = self.env['account.move.line'].with_context(date=self.payment_date).compute_amount_fields(amount, self.journal_id.currency_id, self.company_id.currency_id) + vals.update({ + 'amount_currency': amount_currency, + 'currency_id': self.journal_id.currency_id.id, + }) + + return vals diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py new file mode 100644 index 00000000000..329e492c67c --- /dev/null +++ b/addons/account/models/chart_template.py @@ -0,0 +1,802 @@ +# -*- coding: utf-8 -*- + +import time +import math + +from openerp.osv import expression +from openerp.tools.float_utils import float_round as round +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT +from openerp.exceptions import AccessError, UserError, ValidationError +import openerp.addons.decimal_precision as dp +from openerp import api, fields, models, _ + + +# --------------------------------------------------------------- +# Account Templates: Account, Tax, Tax Code and chart. + Wizard +# --------------------------------------------------------------- + + +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='Secondary 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.multi + @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 = "Templates for Account Chart" + + name = fields.Char(required=True) + company_id = fields.Many2one('res.company', string='Company') + 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_char = fields.Char(string='Code of the main bank account') + transfer_account_id = fields.Many2one('account.account.template', string='Transfer Account', required=True, + domain=lambda self: [('reconcile', '=', True), ('user_type_id.id', '=', self.env.ref('account.data_account_type_current_assets').id)], + help="Intermediary account used when moving money from a liquidity account to another") + 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)]) + property_account_receivable_id = fields.Many2one('account.account.template', string='Receivable Account', oldname="property_account_receivable") + property_account_payable_id = fields.Many2one('account.account.template', string='Payable Account', oldname="property_account_payable") + property_account_expense_categ_id = fields.Many2one('account.account.template', string='Expense Category Account', oldname="property_account_expense_categ") + property_account_income_categ_id = fields.Many2one('account.account.template', string='Income Category Account', oldname="property_account_income_categ") + property_account_expense_id = fields.Many2one('account.account.template', string='Expense Account on Product Template', oldname="property_account_expense") + property_account_income_id = fields.Many2one('account.account.template', string='Income Account on Product Template', oldname="property_account_income") + property_stock_account_input_categ_id = fields.Many2one('account.account.template', oldname="property_stock_account_input_categ") + property_stock_account_output_categ_id = fields.Many2one('account.account.template', oldname="property_stock_account_output_categ") + property_stock_valuation_account_id = fields.Many2one('account.account.template') + + @api.one + def try_loading_for_current_company(self): + self.ensure_one() + company = self.env.user.company_id + # If we don't have any chart of account on this company, install this chart of account + if not company.chart_template_id: + self.env['wizard.multi.charts.accounts'].create({ + 'company_id': self.env.user.company_id.id, + 'chart_template_id': self.id, + 'code_digits': self.code_digits, + 'transfer_account_id': self.transfer_account_id.id, + 'currency_id': self.currency_id.id, + 'bank_account_code_char': self.bank_account_code_char, + }).execute() + + @api.multi + 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'), 'type': 'automatic'}) + return True + + @api.model + def check_created_journals(self, vals_journal, company): + """ + This method used for checking journals already created or not. If not then create new journal. + """ + JournalObj = self.env['account.journal'] + rec_list = JournalObj.search([('name', '=', vals_journal['name']), ('company_id', '=', company.id)], limit=1) + if not rec_list: + journal = JournalObj.create(vals_journal) + if vals_journal['type'] == 'general' and not company.currency_exchange_journal_id.id: + company.write({'currency_exchange_journal_id': journal.id}) + return True + + @api.model + def generate_journals(self, acc_template_ref, company, journals_dict=None): + """ + This method is used for creating journals. + + :param chart_temp_id: Chart Template Id. + :param acc_template_ref: Account templates reference. + :param company_id: company_id selected from wizard.multi.charts.accounts. + :returns: True + """ + for vals_journal in self._prepare_all_journals(acc_template_ref, company, journals_dict=journals_dict): + self.check_created_journals(vals_journal, company) + return True + + @api.multi + def _prepare_all_journals(self, acc_template_ref, company, journals_dict=None): + def _get_analytic_journal(journal_type): + # Get the analytic journal + data = False + try: + if journal['type'] == 'sale': + data = self.env.ref('account.analytic_journal_sale') + elif journal['type'] == 'purchase': + data = self.env.ref('account.exp') + elif journal['type'] == 'general': + pass + except ValueError: + pass + return data and data.id or False + + 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) + elif journal['type'] == 'general' and journal['name'] == _('Exchange Rate Journal'): + if type=='credit': + default_account = acc_template_ref.get(self.income_currency_exchange_account_id.id) + else: + default_account = acc_template_ref.get(self.expense_currency_exchange_account_id.id) + return default_account + + journals = [{'name': _('Customer Invoices'), 'type': 'sale', 'code': _('INV'), 'favorite': True}, + {'name': _('Supplier Bills'), 'type': 'purchase', 'code': _('EXJ'), 'favorite': True}, + {'name': _('Exchange Rate Journal'), 'type': 'general', 'code': _('EXCH'), 'favorite': False}, + {'name': _('Miscellaneous Journal'), 'type': 'general', 'code': _('MISC'), 'favorite': False},] + 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, + 'analytic_journal_id': _get_analytic_journal(journal), + 'default_credit_account_id': _get_default_account(journal, 'credit'), + 'default_debit_account_id': _get_default_account(journal, 'debit'), + 'refund_sequence': True, + 'show_on_dashboard': journal['favorite'], + } + journal_data.append(vals) + return journal_data + + @api.multi + def generate_properties(self, acc_template_ref, company): + """ + This method used for creating properties. + + :param self: chart templates for which we need to create properties + :param acc_template_ref: Mapping between ids of account templates and real accounts created from them + :param company_id: company_id selected from wizard.multi.charts.accounts. + :returns: True + """ + self.ensure_one() + PropertyObj = self.env['ir.property'] + todo_list = [ + ('property_account_receivable_id', 'res.partner', 'account.account'), + ('property_account_payable_id', 'res.partner', 'account.account'), + ('property_account_expense_categ_id', 'product.category', 'account.account'), + ('property_account_income_categ_id', 'product.category', 'account.account'), + ('property_account_expense_id', 'product.template', 'account.account'), + ('property_account_income_id', 'product.template', 'account.account'), + ] + for record in todo_list: + account = getattr(self, record[0]) + value = account and 'account.account,' + str(acc_template_ref[account.id]) or False + if value: + field = self.env['ir.model.fields'].search([('name', '=', record[0]), ('model', '=', record[1]), ('relation', '=', record[2])], limit=1) + vals = { + 'name': record[0], + 'company_id': company.id, + 'fields_id': field.id, + 'value': value, + } + properties = PropertyObj.search([('name', '=', record[0]), ('company_id', '=', company.id)]) + if properties: + #the property exist: modify it + properties.write(vals) + else: + #create the property + PropertyObj.create(vals) + 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 + + @api.multi + def _install_template(self, company, code_digits=None, transfer_account_id=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 transfer_account_id: reference to the account template that will be used as intermediary account for transfers between 2 liquidity accounts + :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, transfer_account_id=transfer_account_id, acc_ref=acc_ref, taxes_ref=taxes_ref) + acc_ref.update(tmp1) + taxes_ref.update(tmp2) + tmp1, tmp2 = self._load_template(company, code_digits=code_digits, transfer_account_id=transfer_account_id, account_ref=acc_ref, taxes_ref=taxes_ref) + acc_ref.update(tmp1) + taxes_ref.update(tmp2) + return acc_ref, taxes_ref + + @api.multi + def _load_template(self, company, code_digits=None, transfer_account_id=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 transfer_account_id: reference to the account template that will be used as intermediary account for transfers between 2 liquidity accounts + :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 + if not transfer_account_id: + transfer_account_id = self.transfer_account_id + AccountTaxObj = self.env['account.tax'] + + # Generate taxes from templates. + generated_tax_res = self.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) + + # writing account values after creation of accounts + company.transfer_account_id = account_template_ref[transfer_account_id.id] + for key, value in generated_tax_res['account_dict'].items(): + if value['refund_account_id'] or value['account_id']: + AccountTaxObj.browse(key).write({ + 'refund_account_id': account_ref.get(value['refund_account_id'], False), + 'account_id': account_ref.get(value['account_id'], False), + }) + + # Create Journals + 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) + + return account_ref, taxes_ref + + @api.multi + def generate_account(self, tax_template_ref, acc_template_ref, code_digits, company): + """ This method for generating accounts from templates. + + :param tax_template_ref: Taxes templates reference for write taxes_id in account_account. + :param acc_template_ref: dictionary with the mappping between the account templates and the real accounts. + :param code_digits: number of digits got from wizard.multi.charts.accounts, this is use for account code. + :param company_id: company_id selected from wizard.multi.charts.accounts. + :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') + for account_template in acc_template: + tax_ids = [] + for tax in account_template.tax_ids: + tax_ids.append(tax_template_ref[tax.id]) + + 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 = { + '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])], + } + new_account = self.env['account.account'].create(vals) + acc_template_ref[account_template.id] = new_account.id + return acc_template_ref + + @api.multi + def generate_fiscal_position(self, tax_template_ref, acc_template_ref, company): + """ This method generate Fiscal Position, Fiscal Position Accounts and Fiscal Position Taxes from templates. + + :param chart_temp_id: Chart Template Id. + :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: company_id selected from wizard.multi.charts.accounts. + :returns: True + """ + self.ensure_one() + positions = self.env['account.fiscal.position.template'].search([('chart_template_id', '=', self.id)]) + for position in positions: + new_fp = self.env['account.fiscal.position'].create({'company_id': company.id, 'name': position.name, 'note': position.note}) + for tax in position.tax_ids: + self.env['account.fiscal.position.tax'].create({ + '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': new_fp.id + }) + for acc in position.account_ids: + self.env['account.fiscal.position.account'].create({ + 'account_src_id': acc_template_ref[acc.account_src_id.id], + 'account_dest_id': acc_template_ref[acc.account_dest_id.id], + 'position_id': new_fp.id + }) + 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, translate=True) + type_tax_use = fields.Selection([('sale', 'Sales'), ('purchase', 'Purchases'), ('none', 'None')], string='Tax Scope', required=True, default="sale", + help="Determines where the tax is selectable. Note : 'None' means a tax can't be used by itself, however it can still be used in a group.") + 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.") + company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) + 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, 3)) + account_id = fields.Many2one('account.account.template', string='Tax Account', ondelete='restrict', + help="Account that will be set on invoice tax lines for invoices. Leave empty to use the expense account.", oldname='account_collected_id') + refund_account_id = fields.Many2one('account.account.template', string='Tax Account on Refunds', ondelete='restrict', + help="Account that will be set on invoice tax lines for refunds. Leave empty to use the expense account.", oldname='account_paid_id') + 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)") + tag_ids = fields.Many2many('account.account.tag', string='Account tag', help="Optional tags you may want to assign for custom reporting") + + _sql_constraints = [ + ('name_company_uniq', 'unique(name, company_id)', 'Tax names must be unique !'), + ] + + @api.multi + @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): + """ This method generates a dictionnary of all the values for the tax that will be created. + """ + self.ensure_one() + return { + 'name': self.name, + 'type_tax_use': self.type_tax_use, + '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, + 'tag_ids': [(6, 0, [t.id for t in self.tag_ids])], + } + + @api.multi + 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 + } + """ + todo_dict = {} + tax_template_to_tax = {} + for tax in self: + # Compute children tax ids + children_ids = [] + for child_tax in tax.children_tax_ids: + if tax_template_to_tax.get(child_tax.id): + children_ids.append(tax_template_to_tax[child_tax.id]) + vals_tax = tax._get_tax_vals(company) + vals_tax['children_tax_ids'] = children_ids and [(6, 0, children_ids)] or [] + new_tax = self.env['account.tax'].create(vals_tax) + tax_template_to_tax[tax.id] = new_tax.id + # Since the accounts have not been created yet, we have to wait before filling these fields + todo_dict[new_tax.id] = { + 'account_id': tax.account_id.id, + 'refund_account_id': tax.refund_account_id.id, + } + + return { + 'tax_template_to_tax': tax_template_to_tax, + 'account_dict': todo_dict + } + + +# Fiscal Position Templates + +class AccountFiscalPositionTemplate(models.Model): + _name = 'account.fiscal.position.template' + _description = 'Template for Fiscal Position' + + 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') + + +class AccountFiscalPositionTaxTemplate(models.Model): + _name = 'account.fiscal.position.tax.template' + _description = 'Template Tax 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 = 'Template Account Fiscal Mapping' + _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) + +# --------------------------------------------------------- +# Account generation from template wizards +# --------------------------------------------------------- + + +class WizardMultiChartsAccounts(models.TransientModel): + """ + Create a new account chart for a company. + Wizards ask for: + * a company + * an account chart template + * a number of digits for formatting code of non-view accounts + * a list of bank accounts owned by the company + Then, the wizard: + * generates all accounts from the template and assigns them to the right company + * generates all taxes and tax codes, changing account assignations + * generates all accounting properties and assigns them correctly + """ + + _name = 'wizard.multi.charts.accounts' + _inherit = 'res.config' + + company_id = fields.Many2one('res.company', string='Company', required=True) + currency_id = fields.Many2one('res.currency', string='Currency', help="Currency as per company's country.", required=True) + only_one_chart_template = fields.Boolean(string='Only One Chart Template Available') + chart_template_id = fields.Many2one('account.chart.template', string='Chart Template', required=True) + bank_account_ids = fields.One2many('account.bank.accounts.wizard', 'bank_account_id', string='Cash and Banks', required=True, oldname="bank_accounts_id") + bank_account_code_char = fields.Char('Bank Accounts Code') + code_digits = fields.Integer(string='# of Digits', required=True, help="No. of Digits to use for account code") + sale_tax_id = fields.Many2one('account.tax.template', string='Default Sales Tax', oldname="sale_tax") + purchase_tax_id = fields.Many2one('account.tax.template', string='Default Purchase Tax', oldname="purchase_tax") + sale_tax_rate = fields.Float(string='Sales Tax(%)') + use_anglo_saxon = fields.Boolean(string='Use Anglo-Saxon Accounting', related='chart_template_id.use_anglo_saxon') + transfer_account_id = fields.Many2one('account.account.template', required=True, string='Transfer Account', + domain=lambda self: [('reconcile', '=', True), ('user_type_id.id', '=', self.env.ref('account.data_account_type_current_assets').id)], + help="Intermediary account used when moving money from a liquidity account to another") + purchase_tax_rate = fields.Float(string='Purchase Tax(%)') + complete_tax_set = fields.Boolean('Complete Set of Taxes', + help="This boolean helps you to choose if you want to propose to the user to encode the sales and purchase rates or use " + "the usual m2o fields. This last choice assumes that the set of tax defined for the chosen template is complete") + + @api.model + def _get_chart_parent_ids(self, chart_template): + """ Returns the IDs of all ancestor charts, including the chart itself. + (inverse of child_of operator) + + :param browse_record chart_template: the account.chart.template record + :return: the IDS of all ancestor charts, including the chart itself. + """ + result = [chart_template.id] + while chart_template.parent_id: + chart_template = chart_template.parent_id + result.append(chart_template.id) + return result + + @api.onchange('sale_tax_rate') + def onchange_tax_rate(self): + self.purchase_tax_rate = self.sale_tax_rate or False + + @api.onchange('chart_template_id') + def onchange_chart_template_id(self): + res = {} + tax_templ_obj = self.env['account.tax.template'] + if self.chart_template_id: + currency_id = self.chart_template_id.currency_id and self.chart_template_id.currency_id.id or self.env.user.company_id.currency_id.id + self.complete_tax_set = self.chart_template_id.complete_tax_set + self.currency_id = currency_id + if self.chart_template_id.complete_tax_set: + # default tax is given by the lowest sequence. For same sequence we will take the latest created as it will be the case for tax created while isntalling the generic chart of account + chart_ids = self._get_chart_parent_ids(self.chart_template_id) + base_tax_domain = [('chart_template_id', 'in', chart_ids)] + sale_tax_domain = base_tax_domain + [('type_tax_use', '=', 'sale')] + purchase_tax_domain = base_tax_domain + [('type_tax_use', '=', 'purchase')] + sale_tax = tax_templ_obj.search(sale_tax_domain, order="sequence, id desc", limit=1) + purchase_tax = tax_templ_obj.search(purchase_tax_domain, order="sequence, id desc", limit=1) + self.sale_tax_id = sale_tax.id + self.purchase_tax_id = purchase_tax.id + res.setdefault('domain', {}) + res['domain']['sale_tax_id'] = repr(sale_tax_domain) + res['domain']['purchase_tax_id'] = repr(purchase_tax_domain) + if self.chart_template_id.transfer_account_id: + self.transfer_account_id = self.chart_template_id.transfer_account_id.id + if self.chart_template_id.code_digits: + self.code_digits = self.chart_template_id.code_digits + if self.chart_template_id.bank_account_code_char: + self.bank_account_code_char = self.chart_template_id.bank_account_code_char + return res + + @api.model + def default_get(self, fields): + context = self._context or {} + res = super(WizardMultiChartsAccounts, self).default_get(fields) + tax_templ_obj = self.env['account.tax.template'] + account_chart_template = self.env['account.chart.template'] + + if 'bank_account_ids' in fields: + res.update({'bank_account_ids': [{'acc_name': _('Cash'), 'account_type': 'cash'}, {'acc_name': _('Bank'), 'account_type': 'bank'}]}) + if 'company_id' in fields: + res.update({'company_id': self.env.user.company_id.id}) + if 'currency_id' in fields: + company_id = res.get('company_id') or False + if company_id: + company = self.env['res.company'].browse(company_id) + currency_id = company.on_change_country(company.country_id.id)['value']['currency_id'] + res.update({'currency_id': currency_id}) + + chart_templates = account_chart_template.search([('visible', '=', True)]) + if chart_templates: + #in order to set default chart which was last created set max of ids. + chart_id = max(chart_templates.ids) + if context.get("default_charts"): + model_data = self.env['ir.model.data'].search_read([('model', '=', 'account.chart.template'), ('module', '=', context.get("default_charts"))], ['res_id']) + if model_data: + chart_id = model_data[0]['res_id'] + chart = account_chart_template.browse(chart_id) + chart_hierarchy_ids = self._get_chart_parent_ids(chart) + if 'chart_template_id' in fields: + res.update({'only_one_chart_template': len(chart_templates) == 1, + 'chart_template_id': chart_id}) + if 'sale_tax_id' in fields: + sale_tax = tax_templ_obj.search([('chart_template_id', 'in', chart_hierarchy_ids), + ('type_tax_use', '=', 'sale')], limit=1, order='sequence') + res.update({'sale_tax_id': sale_tax and sale_tax.id or False}) + if 'purchase_tax_id' in fields: + purchase_tax = tax_templ_obj.search([('chart_template_id', 'in', chart_hierarchy_ids), + ('type_tax_use', '=', 'purchase')], limit=1, order='sequence') + res.update({'purchase_tax_id': purchase_tax and purchase_tax.id or False}) + res.update({ + 'purchase_tax_rate': 15.0, + 'sale_tax_rate': 15.0, + }) + return res + + @api.model + def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + context = self._context or {} + res = super(WizardMultiChartsAccounts, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=False) + cmp_select = [] + CompanyObj = self.env['res.company'] + + companies = CompanyObj.search([]) + #display in the widget selection of companies, only the companies that haven't been configured yet (but don't care about the demo chart of accounts) + self._cr.execute("SELECT company_id FROM account_account WHERE deprecated = 'f' AND name != 'Chart For Automated Tests' AND name NOT LIKE '%(test)'") + configured_cmp = [r[0] for r in self._cr.fetchall()] + unconfigured_cmp = list(set(companies.ids) - set(configured_cmp)) + for field in res['fields']: + if field == 'company_id': + res['fields'][field]['domain'] = [('id', 'in', unconfigured_cmp)] + res['fields'][field]['selection'] = [('', '')] + if unconfigured_cmp: + cmp_select = [(line.id, line.name) for line in CompanyObj.browse(unconfigured_cmp)] + res['fields'][field]['selection'] = cmp_select + return res + + @api.one + def _create_tax_templates_from_rates(self, company_id): + ''' + This function checks if the chosen 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 wich the wizard is running + :return: True + ''' + obj_tax_temp = self.env['account.tax.template'] + all_parents = self._get_chart_parent_ids(self.chart_template_id) + # create tax templates from purchase_tax_rate and sale_tax_rate fields + if not self.chart_template_id.complete_tax_set: + value = self.sale_tax_rate + 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': value, 'name': _('Tax %.2f%%') % value}) + value = self.purchase_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': value, 'name': _('Purchase Tax %.2f%%') % value}) + return True + + @api.multi + def execute(self): + ''' + This function is called at the confirmation of the wizard to generate the COA from the templates. It will read + all the provided information to create the accounts, the banks, the journals, the taxes, the + accounting properties... accordingly for the chosen company. + ''' + if self._uid != self.sudo()._uid and not self.env.user.has_group('base.group_erp_manager'): + raise AccessError(_("Only administrators can change the settings")) + ir_values_obj = self.env['ir.values'] + company = self.company_id + self.company_id.write({'currency_id': self.currency_id.id, + 'accounts_code_digits': self.code_digits, + 'anglo_saxon_accounting': self.use_anglo_saxon, + 'bank_account_code_char': self.bank_account_code_char, + 'chart_template_id': self.chart_template_id.id}) + + # 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', 'purchase.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) + + # Install all the templates objects and generate the real objects + acc_template_ref, taxes_ref = self.chart_template_id._install_template(company, code_digits=self.code_digits, transfer_account_id=self.transfer_account_id) + + # write values of default taxes for product as super user + if self.sale_tax_id and taxes_ref: + ir_values_obj.sudo().set_default('product.template', "taxes_id", [taxes_ref[self.sale_tax_id.id]], for_all_users=True, company_id=company.id) + if self.purchase_tax_id and taxes_ref: + ir_values_obj.sudo().set_default('product.template', "supplier_taxes_id", [taxes_ref[self.purchase_tax_id.id]], for_all_users=True, company_id=company.id) + + # Create Bank journals + self._create_bank_journals_from_o2m(company, acc_template_ref) + + # Create the current year earning account (outside of the CoA) + self.env['account.account'].create({ + 'code': '0', + 'name': _('Undistributed Profits/Losses'), + 'user_type_id': self.env.ref("account.data_unaffected_earnings").id, + 'company_id': company.id,}) + return {} + + @api.multi + def _create_bank_journals_from_o2m(self, company, acc_template_ref): + ''' + This function creates bank journals and its accounts for each line encoded in the field bank_account_ids of the + wizard. + + :param company_id: the id of 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. + :return: True + ''' + self.ensure_one() + # Build a list with all the data to process + journal_data = [] + if self.bank_account_ids: + for acc in self.bank_account_ids: + vals = { + 'acc_name': acc.acc_name, + 'account_type': acc.account_type, + 'currency_id': acc.currency_id.id, + } + journal_data.append(vals) + ref_acc_bank = self.bank_account_code_char + if journal_data and not ref_acc_bank: + raise UserError(_('You have to set a code for the bank account defined on the selected chart of accounts.')) + company.write({'bank_account_code_char': ref_acc_bank}) + + for line in journal_data: + if line['account_type'] == 'bank': + #create the bank account that will trigger the journal and account.account creation + res_partner_bank_vals = { + 'acc_number': line['acc_name'], + 'currency_id': line['currency_id'], + 'company_id': company.id, + 'owner_name': company.partner_id.name, + 'partner_id': company.partner_id.id, + 'footer': True + } + self.env['res.partner.bank'].create(res_partner_bank_vals) + else: + #create the cash journal that will trigger the account.account creation + vals_journal = self.env['account.journal']._prepare_bank_journal(company, line) + self.env['account.journal'].create(vals_journal) + return True + + +class AccountBankAccountsWizard(models.TransientModel): + _name = 'account.bank.accounts.wizard' + + acc_name = fields.Char(string='Account Name.', required=True) + bank_account_id = fields.Many2one('wizard.multi.charts.accounts', string='Bank Account', required=True, ondelete='cascade') + currency_id = fields.Many2one('res.currency', string='Secondary Currency', + help="Forces all moves for this account to have this secondary currency.") + account_type = fields.Selection([('cash', 'Cash'), ('bank', 'Bank')]) diff --git a/addons/account/models/company.py b/addons/account/models/company.py new file mode 100644 index 00000000000..d56a10644a4 --- /dev/null +++ b/addons/account/models/company.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +from openerp import fields, models, api, _ +from datetime import timedelta + + +class ResCompany(models.Model): + _inherit = "res.company" + + #TODO check all the options/fields are in the views (settings + company form view) + #TODO: add a cash_register_code_char for allowing cash journals' accounts having a different numbering than bank journals' accounts. + fiscalyear_last_day = fields.Integer(default=31, required=True) + fiscalyear_last_month = fields.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')], default=12, required=True) + period_lock_date = fields.Date(help="Only users with the 'Adviser' role can edit accounts prior to and inclusive of this date") + fiscalyear_lock_date = fields.Date(string="Fiscal Year lock date", help="No users, including Advisers, can edit accounts prior to 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="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_char = fields.Char(string='Code of the main bank account') + accounts_code_digits = fields.Integer(string='Number of digits in an account code') + 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', + help="If you select 'Round per Line' : for each tax, the tax amount will first be computed and rounded for each PO/SO/invoice line and then these rounded amounts will be summed, leading to the total amount for that tax. If you select 'Round Globally': for each tax, the tax amount will be computed for each PO/SO/invoice line, then these amounts will be summed and eventually this total tax amount will be rounded. If you sell with tax included, you should choose 'Round per line' because you certainly want the sum of your tax-included line subtotals to be equal to the total amount with taxes.") + paypal_account = fields.Char(string='Paypal Account', size=128, help="Paypal username (usually email) for receiving online payments.") + currency_exchange_journal_id = fields.Many2one('account.journal', string="Currency Adjustments Journal", domain=[('type', '=', 'general')]) + income_currency_exchange_account_id = fields.Many2one('account.account', related='currency_exchange_journal_id.default_credit_account_id', + string="Gain Exchange Rate Account", domain="[('internal_type', '=', 'other'), ('deprecated', '=', False), ('company_id', '=', id)]") + expense_currency_exchange_account_id = fields.Many2one('account.account', related='currency_exchange_journal_id.default_debit_account_id', + string="Loss Exchange Rate Account", domain="[('internal_type', '=', 'other'), ('deprecated', '=', False), ('company_id', '=', id)]") + anglo_saxon_accounting = fields.Boolean(string="Use anglo-saxon accounting") + property_stock_account_input_categ_id = fields.Many2one('account.account', oldname="property_stock_account_input_categ") + property_stock_account_output_categ_id = fields.Many2one('account.account', oldname="property_stock_account_output_categ") + property_stock_valuation_account_id = fields.Many2one('account.account') + + @api.multi + def compute_fiscalyear_dates(self, date): + """ Computes the start and end dates of the fiscalyear where the given 'date' belongs to + @param date: a datetime object + @returns: a dictionary with date_from and date_to + """ + self = self[0] + last_month = self.fiscalyear_last_month + last_day = self.fiscalyear_last_day + if (date.month < last_month or (date.month == last_month and date.date <= last_day)): + date = date.replace(month=last_month, day=last_day) + else: + date = date.replace(month=last_month, day=last_day, year=date.year + 1) + date_to = date + date_from = date + timedelta(days=1) + date_from = date_from.replace(year=date_from.year - 1) + return {'date_from': date_from, 'date_to': date_to} + + def get_new_account_code(self, code, old_prefix, new_prefix, digits): + new_prefix_length = len(new_prefix) + number = str(int(code[len(old_prefix):])) + code = new_prefix + str('0' * (digits - new_prefix_length - len(number))) + number + return code + + @api.multi + def write(self, values): + # Repercute the change on accounts + if values.get('bank_account_code_char', False) or values.get('accounts_code_digits', False): + bank_code = values.get('bank_account_code_char', False) or self.bank_account_code_char + digits = values.get('accounts_code_digits', False) or self.accounts_code_digits + + accounts = self.env['account.account'].search([('code', 'like', self.bank_account_code_char), ('internal_type', '=', 'liquidity'), + ('company_id', '=', self.id)], order='code asc') + for account in accounts: + if account.code.startswith(self.bank_account_code_char): + account.write({'code': self.get_new_account_code(account.code, self.bank_account_code_char, bank_code, digits)}) + return super(ResCompany, self).write(values) diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py new file mode 100644 index 00000000000..85a17f07559 --- /dev/null +++ b/addons/account/models/partner.py @@ -0,0 +1,377 @@ +# -*- coding: utf-8 -*- + +from operator import itemgetter +import time +from openerp.exceptions import UserError + +from openerp import api, fields, models, _ +from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT +from datetime import datetime, timedelta +from openerp.tools.misc import formatLang + + +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('res.company', string='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') + auto_apply = fields.Boolean(string='Automatic', 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='Countries', + help="Apply only if delivery or invoicing country match.") + country_group_id = fields.Many2one('res.country.group', string='Country Group', + help="Apply only if delivery or invocing country match the group.") + + @api.one + @api.constrains('country_id', 'country_group_id') + def _check_country(self): + if self.country_id and self.country_group_id: + raise UserError(_('You can not select a country and a group of countries.')) + + @api.v7 + def map_tax(self, cr, uid, fposition_id, taxes, context=None): + if not taxes: + return [] + if not fposition_id: + return map(lambda x: x.id, taxes) + result = set() + for t in taxes: + ok = False + for tax in fposition_id.tax_ids: + if tax.tax_src_id.id == t.id: + if tax.tax_dest_id: + result.add(tax.tax_dest_id.id) + ok = True + if not ok: + result.add(t.id) + return list(result) + + @api.v8 # noqa + def map_tax(self, taxes): + result = self.env['account.tax'].browse() + for tax in taxes: + tax_count = 0 + for t in self.tax_ids: + if t.tax_src_id == tax: + tax_count += 1 + if t.tax_dest_id: + result |= t.tax_dest_id + if not tax_count: + result |= tax + return result + + @api.v7 + def map_account(self, cr, uid, fposition_id, account_id, context=None): + if not fposition_id: + return account_id + for pos in fposition_id.account_ids: + if pos.account_src_id.id == account_id: + account_id = pos.account_dest_id.id + break + return account_id + + @api.v8 + def map_account(self, account): + for pos in self.account_ids: + if pos.account_src_id == account: + return pos.account_dest_id + return account + + @api.v8 + 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.model + def get_fiscal_position(self, partner_id, delivery_id=None): + if not partner_id: + return False + # This can be easily overriden to apply more complex fiscal rules + PartnerObj = self.env['res.partner'] + partner = PartnerObj.browse(partner_id) + + # partner manually set fiscal position always win + if partner.property_account_position_id: + return partner.property_account_position_id.id + + # if no delivery use invocing + if delivery_id: + delivery = PartnerObj.browse(delivery_id) + else: + delivery = partner + + domains = [[('auto_apply', '=', True), ('vat_required', '=', partner.vat_subjected)]] + if partner.vat_subjected: + # Possibly allow fallback to non-VAT positions, if no VAT-required position matches + domains += [[('auto_apply', '=', True), ('vat_required', '=', False)]] + + for domain in domains: + if delivery.country_id.id: + fiscal_position = self.search(domain + [('country_id', '=', delivery.country_id.id)], limit=1) + if fiscal_position: + return fiscal_position.id + + fiscal_position = self.search(domain + [('country_group_id.country_ids', '=', delivery.country_id.id)], limit=1) + if fiscal_position: + return fiscal_position.id + + fiscal_position = self.search(domain + [('country_id', '=', None), ('country_group_id', '=', None)], limit=1) + if fiscal_position: + return fiscal_position.id + return False + + +class AccountFiscalPositionTax(models.Model): + _name = 'account.fiscal.position.tax' + _description = 'Taxes Fiscal Position' + _rec_name = 'position_id' + + position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', + required=True, ondelete='cascade') + tax_src_id = fields.Many2one('account.tax', string='Tax Source', required=True) + tax_dest_id = fields.Many2one('account.tax', string='Replacement Tax') + + _sql_constraints = [ + ('tax_src_dest_uniq', + 'unique (position_id,tax_src_id,tax_dest_id)', + 'A tax fiscal position could be defined only once time on same taxes.') + ] + + +class AccountFiscalPositionAccount(models.Model): + _name = 'account.fiscal.position.account' + _description = 'Accounts Fiscal Position' + _rec_name = 'position_id' + + position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', + required=True, ondelete='cascade') + account_src_id = fields.Many2one('account.account', string='Account Source', + domain=[('deprecated', '=', False)], required=True) + account_dest_id = fields.Many2one('account.account', string='Account Destination', + domain=[('deprecated', '=', False)], required=True) + + _sql_constraints = [ + ('account_src_dest_uniq', + 'unique (position_id,account_src_id,account_dest_id)', + 'An account fiscal position could be defined only once time on same accounts.') + ] + + +class ResPartner(models.Model): + _name = 'res.partner' + _inherit = 'res.partner' + _description = 'Partner' + + @api.multi + def _credit_debit_get(self): + where_clause, where_params = self.env['account.move.line']._query_get() + where_params = [tuple(self.ids)] + where_params + self._cr.execute("""SELECT l.partner_id, act.type, SUM(l.debit-l.credit) + FROM account_move_line l + LEFT JOIN account_account a ON (l.account_id=a.id) + LEFT JOIN account_account_type act ON (a.user_type_id=act.id) + WHERE act.type IN ('receivable','payable') + AND l.partner_id IN %s + AND l.reconciled IS FALSE + """ + where_clause + """ + GROUP BY l.partner_id, act.type + """, where_params) + for pid, type, val in self._cr.fetchall(): + partner = self.browse(pid) + if type == 'receivable': + partner.credit = val + elif type == 'payable': + partner.debit = -val + + @api.multi + def _asset_difference_search(self, type, args): + if not args: + return [] + having_values = tuple(map(itemgetter(2), args)) + where = ' AND '.join( + map(lambda x: '(SUM(bal2) %(operator)s %%s)' % { + 'operator':x[1]},args)) + query = self.env['account.move.line']._query_get() + self._cr.execute(('SELECT pid AS partner_id, SUM(bal2) FROM ' \ + '(SELECT CASE WHEN bal IS NOT NULL THEN bal ' \ + 'ELSE 0.0 END AS bal2, p.id as pid FROM ' \ + '(SELECT (debit-credit) AS bal, partner_id ' \ + 'FROM account_move_line l ' \ + 'WHERE account_id IN ' \ + '(SELECT id FROM account_account '\ + 'WHERE type=%s AND active) ' \ + 'AND reconciled IS FALSE ' \ + 'AND '+query+') AS l ' \ + 'RIGHT JOIN res_partner p ' \ + 'ON p.id = partner_id ) AS pl ' \ + 'GROUP BY pid HAVING ' + where), + (type,) + having_values) + res = self._cr.fetchall() + if not res: + return [('id', '=', '0')] + return [('id', 'in', map(itemgetter(0), res))] + + @api.multi + def _credit_search(self, args): + return self._asset_difference_search('receivable', args) + + @api.multi + def _debit_search(self, args): + return self._asset_difference_search('payable', args) + + @api.multi + def _invoice_total(self): + account_invoice_report = self.env['account.invoice.report'] + if not self.ids: + self.total_invoiced = 0.0 + return True + for partner in self: + invoices = account_invoice_report.search([('partner_id', 'child_of', partner.id), ('state', 'not in', ['draft', 'cancel'])]) + partner.total_invoiced = sum(inv.user_currency_price_total for inv in invoices) + + @api.multi + def _journal_item_count(self): + for partner in self: + partner.journal_item_count = self.env['account.move.line'].search_count([('partner_id', '=', partner.id)]) + partner.contracts_count = self.env['account.analytic.account'].search_count([('partner_id', '=', partner.id)]) + + def get_followup_lines_domain(self, date, overdue_only=False, only_unblocked=False): + domain = [('reconciled', '=', False), ('account_id.deprecated', '=', False), ('account_id.internal_type', '=', 'receivable')] + if only_unblocked: + domain += [('blocked', '=', False)] + if self.ids: + domain += [('partner_id', 'in', self.ids)] + #adding the overdue lines + overdue_domain = ['|', '&', ('date_maturity', '!=', False), ('date_maturity', '<=', date), '&', ('date_maturity', '=', False), ('date', '<=', date)] + if overdue_only: + domain += overdue_domain + return domain + + @api.multi + def _compute_issued_total(self): + """ Returns the issued total as will be displayed on partner view """ + today = fields.Date.context_today(self) + for partner in self: + domain = partner.get_followup_lines_domain(today, overdue_only=True) + issued_total = 0 + for aml in self.env['account.move.line'].search(domain): + issued_total += aml.amount_residual + partner.issued_total = formatLang(self.env, issued_total, currency_obj=self.env.user.company_id.currency_id) + + @api.one + def _compute_has_unreconciled_entries(self): + # Avoid useless work if has_unreconciled_entries is not relevant for this partner + if not self.active or not self.is_company and self.parent_id: + return + 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) + """ % (self.id,)) + self.has_unreconciled_entries = self.env.cr.rowcount == 1 + + @api.multi + def mark_as_reconciled(self): + return self.write({'last_time_entries_checked': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)}) + + @api.one + def _get_company_currency(self): + if self.company_id: + self.currency_id = self.company_id.currency_id + else: + self.currency_id = self.env.user.company_id.currency_id + + vat_subjected = fields.Boolean('VAT Legal Statement', + help="Check this box if the partner is subjected to the VAT. It will be used for the VAT legal statement.") + 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 supplier.") + debit_limit = fields.Monetary('Payable Limit') + total_invoiced = fields.Monetary(compute='_invoice_total', string="Total Invoiced", + groups='account.group_account_invoice') + currency_id = fields.Many2one('res.currency', compute='_get_company_currency', store=True, readonly=True, + help='Utility field to express amount currency') + + contracts_count = fields.Integer(compute='_journal_item_count', string="Contracts", type='integer') + journal_item_count = fields.Integer(compute='_journal_item_count', string="Journal Items", type="integer") + issued_total = fields.Char(compute='_compute_issued_total', string="Journal Items") + property_account_payable_id = fields.Many2one('account.account', company_dependent=True, + string="Account Payable", oldname="property_account_payable", + domain="[('internal_type', '=', 'payable'), ('deprecated', '=', False)]", + 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", oldname="property_account_receivable", + domain="[('internal_type', '=', 'receivable'), ('deprecated', '=', False)]", + 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", + help="The fiscal position will determine taxes and accounts used for the partner.", oldname="property_account_position") + property_payment_term_id = fields.Many2one('account.payment.term', company_dependent=True, + string ='Customer Payment Term', + help="This payment term will be used instead of the default one for sale orders and customer invoices", oldname="property_payment_term") + property_supplier_payment_term_id = fields.Many2one('account.payment.term', company_dependent=True, + string ='Supplier Payment Term', + help="This payment term will be used instead of the default one for purchase orders and supplier bills", oldname="property_supplier_payment_term") + ref_company_ids = fields.One2many('res.company', 'partner_id', + string='Companies that refers to partner', oldname="ref_companies") + 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(oldname='last_reconciliation_date', + 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.invoice', 'partner_id', string='Invoices', readonly=True, copy=False) + contract_ids = fields.One2many('account.analytic.account', 'partner_id', string='Contracts', readonly=True) + + 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'] diff --git a/addons/account/models/product.py b/addons/account/models/product.py new file mode 100644 index 00000000000..00e378386ab --- /dev/null +++ b/addons/account/models/product.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +from openerp import api, fields, models, _ +from openerp.exceptions import UserError + + +class ProductCategory(models.Model): + _inherit = "product.category" + + property_account_income_categ_id = fields.Many2one('account.account', company_dependent=True, + string="Income Account", oldname="property_account_income_categ", + domain=[('deprecated', '=', False)], + help="This account will be used for invoices to value sales.") + property_account_expense_categ_id = fields.Many2one('account.account', company_dependent=True, + string="Expense Account", oldname="property_account_expense_categ", + domain=[('deprecated', '=', False)], + help="This account will be used for invoices to value expenses.") + +#---------------------------------------------------------- +# Products +#---------------------------------------------------------- +class ProductTemplate(models.Model): + _inherit = "product.template" + + taxes_id = fields.Many2many('account.tax', 'product_taxes_rel', 'prod_id', 'tax_id', string='Customer Taxes', + domain=[('type_tax_use', '=', 'sale')]) + supplier_taxes_id = fields.Many2many('account.tax', 'product_supplier_taxes_rel', 'prod_id', 'tax_id', string='Supplier Taxes', + domain=[('type_tax_use', '=', 'purchase')]) + property_account_income_id = fields.Many2one('account.account', company_dependent=True, + string="Income Account", oldname="property_account_income", + domain=[('deprecated', '=', False)], + help="This account will be used for invoices instead of the default one to value sales for the current product.") + property_account_expense_id = fields.Many2one('account.account', company_dependent=True, + string="Expense Account", oldname="property_account_expense", + domain=[('deprecated', '=', False)], + help="This account will be used for invoices instead of the default one to value expenses for the current product.") + + @api.multi + def write(self, vals): + #TODO: really? i don't see the reason we'd need that constraint.. + check = self.ids and 'uom_po_id' in vals + if check: + self._cr.execute("SELECT id, uom_po_id FROM product_template WHERE id IN %s", [tuple(self.ids)]) + uoms = dict(self._cr.fetchall()) + res = super(ProductTemplate, self).write(vals) + if check: + self._cr.execute("SELECT id, uom_po_id FROM product_template WHERE id IN %s", [tuple(self.ids)]) + if dict(self._cr.fetchall()) != uoms: + products = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids)]) + if self.env['account.move.line'].search_count([('product_id', 'in', products.ids)]): + raise UserError(_('You can not change the unit of measure of a product that has been already used in an account journal item. If you need to change the unit of measure, you may deactivate this product.')) + return res + + @api.multi + 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 + } + + @api.multi + 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) diff --git a/addons/account/models/res_config.py b/addons/account/models/res_config.py new file mode 100644 index 00000000000..ee981ef634e --- /dev/null +++ b/addons/account/models/res_config.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- + +import time +import datetime +from dateutil.relativedelta import relativedelta + +import openerp +from openerp import SUPERUSER_ID +from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DF +from openerp import api, fields, models, _ +from openerp.exceptions import UserError + + +class AccountConfigSettings(models.TransientModel): + _name = 'account.config.settings' + _inherit = 'res.config.settings' + + company_id = fields.Many2one('res.company', string='Company', required=True, + default=lambda self: self.env.user.company_id) + has_default_company = fields.Boolean(readonly=True, + default=lambda self: self._default_has_default_company()) + expects_chart_of_accounts = fields.Boolean(related='company_id.expects_chart_of_accounts', + string='This company has its own chart of accounts', + help='Check this box if this company is a legal entity.') + currency_id = fields.Many2one('res.currency', related='company_id.currency_id', required=True, + string='Default company currency', help="Main currency of the company.") + paypal_account = fields.Char(related='company_id.paypal_account', size=128, string='Paypal account', + help="""Paypal account (email) for receiving online payments (credit card, etc.) + If you set a paypal account, the customer will be able to pay your invoices or quotations + with a button \"Pay with Paypal\" in automated emails or through the Odoo portal.""") + company_footer = fields.Text(related='company_id.rml_footer', string='Bank accounts footer preview', + readonly=True, help="Bank accounts as printed in the footer of each printed document") + + has_chart_of_accounts = fields.Boolean(string='Company has a chart of accounts') + chart_template_id = fields.Many2one('account.chart.template', string='Template', + domain="[('visible','=', True)]") + use_anglo_saxon = fields.Boolean(string='Use Anglo-Saxon Accounting', related='company_id.anglo_saxon_accounting') + code_digits = fields.Integer(string='# of Digits', related='company_id.accounts_code_digits', help="No. of digits to use for account code") + tax_calculation_rounding_method = fields.Selection( + [ + ('round_per_line', 'Round calculation of taxes per line'), + ('round_globally', 'Round globally calculation of taxes '), + ], related='company_id.tax_calculation_rounding_method', string='Tax calculation rounding method', + help="""If you select 'Round per line' : for each tax, the tax amount will first be + computed and rounded for each PO/SO/invoice line and then these rounded amounts will be summed, + leading to the total amount for that tax. If you select 'Round globally': for each tax, + the tax amount will be computed for each PO/SO/invoice line, then these amounts will be + summed and eventually this total tax amount will be rounded. If you sell with tax included, + you should choose 'Round per line' because you certainly want the sum of your tax-included line + subtotals to be equal to the total amount with taxes.""") + sale_tax_id = fields.Many2one('account.tax.template', string='Default sale tax', oldname="sale_tax") + purchase_tax_id = fields.Many2one('account.tax.template', string='Default purchase tax', oldname="purchase_tax") + sale_tax_rate = fields.Float(string='Sales tax (%)') + purchase_tax_rate = fields.Float(string='Purchase tax (%)') + bank_account_code_char = fields.Char(string='Bank Accounts Code', related='company_id.bank_account_code_char', help='Define the code for the bank account') + template_transfer_account_id = fields.Many2one('account.account.template', help="Intermediary account used when moving money from a liquidity account to another") + transfer_account_id = fields.Many2one('account.account', + related='company_id.transfer_account_id', + domain=lambda self: [('reconcile', '=', True), ('user_type_id.id', '=', self.env.ref('account.data_account_type_current_assets').id)], + help="Intermediary account used when moving money from a liquidity account to another") + complete_tax_set = fields.Boolean(string='Complete set of taxes', + help='''This boolean helps you to choose if you want to propose to the user to encode + the sales and purchase rates or use the usual m2o fields. This last choice assumes that + the set of tax defined for the chosen template is complete''') + + fiscalyear_last_day = fields.Integer(related='company_id.fiscalyear_last_day', default=31) + fiscalyear_last_month = fields.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')], related='company_id.fiscalyear_last_month', default=12) + period_lock_date = fields.Date(related='company_id.period_lock_date', help="Only users with the 'Adviser' role can edit accounts prior to and inclusive of this date") + fiscalyear_lock_date = fields.Date(string="Fiscal Year lock date", related='company_id.fiscalyear_lock_date', help="No users, including Advisers, can edit accounts prior to and inclusive of this date") + + module_account_check_writing = fields.Boolean(string='Pay your suppliers by check', + help='This allows you to check writing and printing.\n' + '-This installs the module account_check_writing.') + module_account_accountant = fields.Boolean(string='Full accounting features: journals, legal statements, chart of accounts, etc.', + help="""If you do not check this box, you will be able to do invoicing & payments, + but not accounting (Journal Items, Chart of Accounts, ...)""") + module_account_asset = fields.Boolean(string='Assets management & Revenue recognition', + help='Asset management: This allows you to manage the assets owned by a company or a person.' + 'It keeps track of the depreciation occurred on those assets, and creates account move for those depreciation lines.\n\n' + 'Revenue recognition: This allows you to manage the Revenue recognition on selling product.' + 'It keeps track of the installment occurred on those revenue recognition, and creates account move for those installment lines.\n' + '-This installs the module account_asset. If you do not check this box, you will be able to do invoicing & payments, ' + 'but not accounting (Journal Items, Chart of Accounts, ...)') + module_account_budget = fields.Boolean(string='Budget management', + help='This allows accountants to manage analytic and crossovered budgets. ' + 'Once the master budgets and the budgets are defined, ' + 'the project managers can set the planned amount on each analytic account.\n' + '-This installs the module account_budget.') + module_product_email_template = fields.Boolean(string='Send products tools and information at the invoice confirmation', + help='With this module, link your products to a template to send complete information and tools to your customer.\n' + 'For instance when invoicing a training, the training agenda and materials will automatically be send to your customers.') + module_account_bank_statement_import_ofx = fields.Boolean(string='Import of Bank Statements in .OFX Format', + help='Get your bank statements from you bank and import them in Odoo in .OFX format.\n' + '-that installs the module account_bank_statement_import.') + module_account_bank_statement_import_qif = fields.Boolean(string='Import of Bank Statements in .QIF Format.', + help='Get your bank statements from you bank and import them in Odoo in .QIF format.\n' + '-that installs the module account_bank_statement_import_qif.') + module_account_plaid = fields.Boolean(string="Import of Bank Statements from Plaid.", + help='Get your bank statements grom you bank and import them through plaid.com.\n' + '-that installs the module account_plaid.') + group_proforma_invoices = fields.Boolean(string='Allow pro-forma invoices', + implied_group='account.group_proforma_invoices', + help="Allows you to put invoices in pro-forma state.") + default_sale_tax_id = fields.Many2one('account.tax', help="This sale tax will be assigned by default on new products.", oldname="default_sale_tax") + default_purchase_tax_id = fields.Many2one('account.tax', help="This purchase tax will be assigned by default on new products.", oldname="default_purchase_tax") + group_multi_currency = fields.Boolean(string='Allow multi currencies', + implied_group='base.group_multi_currency', + help="Allows you multi currency environment") + group_analytic_accounting = fields.Boolean(string='Analytic accounting', + implied_group='analytic.group_analytic_accounting', + help="Allows you to use the analytic accounting.") + group_check_supplier_invoice_total = fields.Boolean(string='Check the total of supplier bills', + implied_group="account.group_supplier_inv_check_total") + currency_exchange_journal_id = fields.Many2one('account.journal', + related='company_id.currency_exchange_journal_id', + string="Rate Difference Journal",) + + @api.model + def _default_has_default_company(self): + count = self.env['res.company'].search_count([]) + return bool(count == 1) + + + @api.onchange('company_id') + def onchange_company_id(self): + # update related fields + self.currency_id = False + if self.company_id: + company = self.company_id + self.chart_template_id = company.chart_template_id + self.has_chart_of_accounts = len(company.chart_template_id) > 0 or False + self.expects_chart_of_accounts = company.expects_chart_of_accounts + self.currency_id = company.currency_id + self.transfer_account_id = company.transfer_account_id + self.paypal_account = company.paypal_account + self.company_footer = company.rml_footer + self.tax_calculation_rounding_method = company.tax_calculation_rounding_method + self.bank_account_code_char = company.bank_account_code_char + self.code_digits = company.accounts_code_digits + + # update taxes + ir_values = self.env['ir.values'] + taxes_id = ir_values.get_default('product.template', 'taxes_id', company_id = self.company_id.id) + supplier_taxes_id = ir_values.get_default('product.template', 'supplier_taxes_id', company_id = self.company_id.id) + self.default_sale_tax_id = isinstance(taxes_id, list) and taxes_id[0] or taxes_id + self.default_purchase_tax_id = isinstance(supplier_taxes_id, list) and supplier_taxes_id[0] or supplier_taxes_id + return {} + + @api.onchange('chart_template_id') + def onchange_chart_template_id(self): + tax_templ_obj = self.env['account.tax.template'] + self.complete_tax_set = self.sale_tax_id = self.purchase_tax_id = False + self.sale_tax_rate = self.purchase_tax_rate = 15 + if self.chart_template_id and not self.has_chart_of_accounts: + # update complete_tax_set, sale_tax_id and purchase_tax_id + self.complete_tax_set = self.chart_template_id.complete_tax_set + if self.chart_template_id.complete_tax_set: + ir_values_obj = self.env['ir.values'] + # default tax is given by the lowest sequence. For same sequence we will take the latest created as it will be the case for tax created while isntalling the generic chart of account + sale_tax = tax_templ_obj.search( + [('chart_template_id', '=', self.chart_template_id.id), ('type_tax_use', '=', 'sale')], limit=1, + order="sequence, id desc") + purchase_tax = tax_templ_obj.search( + [('chart_template_id', '=', self.chart_template_id.id), ('type_tax_use', '=', 'purchase')], limit=1, + order="sequence, id desc") + self.sale_tax_id = sale_tax + self.purchase_tax_id = purchase_tax + if self.chart_template_id.code_digits: + self.code_digits = self.chart_template_id.code_digits + if self.chart_template_id.transfer_account_id: + self.template_transfer_account_id = self.chart_template_id.transfer_account_id.id + if self.chart_template_id.bank_account_code_char: + self.bank_account_code_char = self.chart_template_id.bank_account_code_char + return {} + + @api.onchange('sale_tax_rate') + def onchange_tax_rate(self): + self.purchase_tax_rate = self.sale_tax_rate or False + + @api.multi + def set_group_multi_currency(self): + ir_model = self.env['ir.model.data'] + group_user = ir_model.get_object('base', 'group_user') + group_product = ir_model.get_object('product', 'group_sale_pricelist') + if self.group_multi_currency: + group_user.write({'implied_ids': [(4, group_product.id)]}) + return True + + @api.multi + def open_company_form(self): + return { + 'type': 'ir.actions.act_window', + 'name': 'Configure your Company', + 'res_model': 'res.company', + 'res_id': self.company_id.id, + 'view_mode': 'form', + } + + @api.multi + def set_transfer_account(self): + if self.transfer_account_id and self.transfer_account_id != self.company_id.transfer_account_id: + self.company_id.write({'transfer_account_id': self.transfer_account_id.id}) + + @api.multi + def set_product_taxes(self): + """ Set the product taxes if they have changed """ + ir_values_obj = self.env['ir.values'] + if self.default_sale_tax_id: + ir_values_obj.sudo().set_default('product.template', "taxes_id", [self.default_sale_tax_id.id], for_all_users=True, company_id=self.company_id.id) + if self.default_purchase_tax_id: + ir_values_obj.sudo().set_default('product.template', "supplier_taxes_id", [self.default_purchase_tax_id.id], for_all_users=True, company_id=self.company_id.id) + + @api.multi + def set_chart_of_accounts(self): + """ install a chart of accounts for the given company (if required) """ + if self.chart_template_id and not self.has_chart_of_accounts and self.expects_chart_of_accounts: + if self.company_id.chart_template_id and self.chart_template_id != self.company_id.chart_template_id: + raise UserError(_('You can not change a company chart of account once it has been installed')) + wizard = self.env['wizard.multi.charts.accounts'].create({ + 'company_id': self.company_id.id, + 'chart_template_id': self.chart_template_id.id, + 'transfer_account_id': self.template_transfer_account_id.id, + 'code_digits': self.code_digits or 6, + 'sale_tax_id': self.sale_tax_id.id, + 'purchase_tax_id': self.purchase_tax_id.id, + 'sale_tax_rate': self.sale_tax_rate, + 'purchase_tax_rate': self.purchase_tax_rate, + 'complete_tax_set': self.complete_tax_set, + 'currency_id': self.currency_id.id, + 'bank_account_code_char': self.bank_account_code_char, + }) + wizard.execute() + + @api.onchange('group_analytic_accounting') + def onchange_analytic_accounting(self): + if self.group_analytic_accounting: + self.module_account_accountant = True |
